Compare commits

..

7 Commits

Author SHA1 Message Date
kdletters dc8e03988f 修复AGC模型列表刷新无反馈与响应体无限等待
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m26s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m20s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m8s
Project CI / AI game creator shell Rust crates (push) Successful in 3m6s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m4s
Project CI / Repository checks (push) Successful in 3m27s
Project CI / Frontend tests (push) Successful in 4m39s
Project CI / Native shell tests (push) Successful in 7m27s
Project CI / AI game creator shell web tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 8m47s
ConversationModelSelect:手动刷新立即显示「刷新中…」,真实刷新成功后显示「模型列表已刷新」,revision 未变化也有反馈
ConversationModelSelect:刷新失败按 HTTP 状态与超时给出可辨认提示,沿用有效缓存时不报告刷新成功,已选模型与挂载/聚焦语义不变
clientApi:成功与错误响应体统一走 readClientHttpResponseText 的 15 秒上限,响应体卡住时结束等待并释放模型目录在途请求
测试:补齐模型目录响应体超时后保留缓存并可重试、迟到响应不覆盖新目录、手动刷新进行中/同版本成功/失败反馈用例
文档:同步 AGC 模型目录刷新反馈与响应体超时合同,并在共享记忆记录该排障口径
2026-09-15 14:41:57 +08:00
lhk229 8ae7ed0128 Merge branch 'master' into opt/design-debug
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m32s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m39s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m56s
Project CI / Repository checks (pull_request) Successful in 4m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m33s
Project CI / Backend tests (pull_request) Successful in 8m44s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m32s
Project CI / Frontend tests (pull_request) Successful in 4m31s
Project CI / Native shell tests (pull_request) Successful in 6m53s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m42s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m45s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m48s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m1s
Project CI / AI game creator shell Rust crates (push) Successful in 3m22s
Project CI / Frontend tests (push) Successful in 5m23s
Project CI / Backend tests (push) Successful in 7m54s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m41s
Project CI / Repository checks (push) Successful in 4m3s
Project CI / Native shell tests (push) Successful in 7m50s
Project CI / AI game creator shell web tests (push) Successful in 2m32s
2026-09-15 12:29:58 +08:00
lhk229 dfdcc43a0b Merge pull request '新增 AGC 首页策划补全入口' (#369) from feat/entrance into master
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m30s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m0s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m12s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m25s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m40s
Project CI / AI game creator shell Rust crates (push) Successful in 2m13s
Project CI / Repository checks (push) Successful in 3m59s
Project CI / Frontend tests (push) Successful in 5m36s
Project CI / Native shell tests (push) Successful in 6m35s
Project CI / Backend tests (push) Successful in 7m13s
Project CI / AI game creator shell web tests (push) Successful in 3m31s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/369
2026-09-15 12:29:47 +08:00
lhk229 9389c7fff8 修正 AGC 策划补全说明
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
更新首页启动模式注释以覆盖游戏策划补全分支

同步实施计划与当前测试覆盖边界
2026-09-15 12:26:57 +08:00
lhk229 77d3917d9c 统一策划 Agent Debug 运行时开关
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
策划 Debug 日志与快速推进入口共用 GENARRATIVE_AGC_DESIGN_DEBUG。

通过 Tauri 查询构建与运行时状态,移除前端 VITE Debug 开关依赖。

更新开发启动器、策划工作区测试和技术方案文档。
2026-09-15 04:22:45 +00:00
lhk229 fbf8d34f45 新增 AGC 首页策划补全入口
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m27s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m44s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m46s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m11s
Project CI / Repository checks (pull_request) Successful in 3m43s
Project CI / Frontend tests (pull_request) Successful in 4m35s
Project CI / Native shell tests (pull_request) Successful in 6m8s
Project CI / Backend tests (pull_request) Successful in 7m8s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m11s
首页做游戏入口增加策划补全勾选项

勾选后复用 planning Agent Runtime 启动模式

补充首页启动模式回归测试与实施计划
2026-09-15 12:14:03 +08:00
kdletters 33369f1180 修复生图资产实时刷新
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m5s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m50s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m13s
Project CI / AI game creator shell Rust crates (push) Successful in 2m2s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m12s
Project CI / Repository checks (push) Successful in 4m16s
Project CI / Frontend tests (push) Successful in 5m8s
Project CI / Native shell tests (push) Successful in 6m31s
Project CI / Backend tests (push) Successful in 7m32s
Project CI / AI game creator shell web tests (push) Successful in 4m12s
兼容 Windows 普通、verbatim 与 UNC 项目路径的失效事件匹配

让普通生图成功后立即发送 manifest 刷新通知

补充资源卡片与桥接通知回归测试
2026-09-15 10:37:47 +08:00
60 changed files with 1604 additions and 1420 deletions
-1
View File
@@ -164,7 +164,6 @@ module.exports = {
'server-rs/target-*',
'apps/desktop-shell/src-tauri/target',
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
'target',
'src/main.tsx',
'src/App.tsx',
@@ -22,7 +22,6 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
const AGC_DESIGN_DEBUG_ENV = 'GENARRATIVE_AGC_DESIGN_DEBUG';
const AGC_DESIGN_DEBUG_VITE_ENV = 'VITE_GENARRATIVE_AGC_DESIGN_DEBUG';
const designDebugEnabled =
process.env[AGC_DESIGN_DEBUG_ENV]?.trim() === '0' ? '0' : '1';
@@ -137,7 +136,6 @@ async function runTauriDev(
env: {
...withAgcDevEndpointEnv(endpoint),
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
[AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled,
},
});
const childResult = waitForCli(child);
@@ -191,7 +189,6 @@ async function prepareFrontendDev(endpoint, { onChild, signal }) {
cwd: repoRoot,
env: {
...withAgcDevEndpointEnv(endpoint),
[AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled,
},
},
);
@@ -16,7 +16,7 @@ mod design_runtime;
mod design_tools;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_codex_user_item;
mod direct_codex_references;
mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
@@ -44,7 +44,7 @@ pub(crate) use codex_provider_proxy::*;
pub(crate) use design_runtime::*;
pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_codex_user_item::*;
pub(crate) use direct_codex_references::*;
pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
@@ -10,11 +10,6 @@ use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufR
use tokio::sync::{mpsc, oneshot, Mutex, Notify};
use uuid::Uuid;
mod direct_project_history_wire;
use direct_project_history_wire::build_direct_project_history_injection_params;
mod direct_project_identity;
use direct_project_identity::*;
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str =
@@ -2625,7 +2620,6 @@ impl CodexAppServerConnection {
request,
None,
None,
None,
on_agent_message_delta,
direct_observer,
audit,
@@ -2640,7 +2634,6 @@ impl CodexAppServerConnection {
request: LlmRunRequest,
direct_history_root: Option<&std::path::Path>,
direct_client_turn_id: Option<&str>,
direct_user_item: Option<&serde_json::Value>,
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
@@ -2656,19 +2649,12 @@ impl CodexAppServerConnection {
));
}
if let Some(client_turn_id) = direct_client_turn_id {
let user_item = match direct_user_item {
Some(item) => {
direct_codex_user_item_to_response_item(history_root, item)
.map_err(platform_llm::LlmError::InvalidRequest)?;
item.clone()
}
None => direct_project_local_message_item(
let user_item = direct_project_local_message_item(
"user",
current_prompt,
Some(&format!("direct-codex:{client_turn_id}:user")),
)
.map_err(platform_llm::LlmError::InvalidRequest)?,
};
.map_err(platform_llm::LlmError::InvalidRequest)?;
append_direct_project_user_message_at(history_root, &user_item)
.map_err(platform_llm::LlmError::InvalidRequest)?;
}
@@ -2678,14 +2664,24 @@ impl CodexAppServerConnection {
let thread_id = thread_lease.thread_id.clone();
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
if thread_created {
let params =
match build_direct_project_history_injection_params(history_root, &thread_id) {
Ok(params) => params,
let items = match read_direct_project_history_items_at(history_root) {
Ok(items) => items,
Err(error) => {
self.release_thread(snapshot, &thread_id).await;
return Err(error);
return Err(platform_llm::LlmError::InvalidRequest(error));
}
};
let params = serde_json::json!({"threadId": thread_id.clone(), "items": items});
let payload_bytes = serde_json::to_vec(&params)
.map(|bytes| bytes.len().saturating_add(1))
.unwrap_or(usize::MAX);
// 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。
if let Some(error) =
direct_project_history_injection_oversize_error(&params, payload_bytes)
{
self.release_thread(snapshot, &thread_id).await;
return Err(platform_llm::LlmError::InvalidRequest(error));
}
if let Err(error) = self.request("thread/inject_items", params).await {
self.release_thread(snapshot, &thread_id).await;
return Err(platform_llm::LlmError::Transport(error));
@@ -3716,7 +3712,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
None,
None,
None,
None,
)
.await
}
@@ -3734,11 +3729,56 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
None,
Some(observer),
None,
None,
)
.await
}
fn direct_codex_canonical_project_identity(
root: &std::path::Path,
) -> Result<(std::path::PathBuf, String), String> {
let (canonical_root, _) = resolve_direct_codex_project_authority(root)?;
let manifest = read_manifest(&canonical_root.join(".agent/manifest.json"))
.map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?;
let manifest_project_id = manifest.project_id.trim();
if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 {
return Err("DirectProject manifest.projectId 不满足身份边界".to_string());
}
let path_identity = direct_codex_os_path_identity_bytes(&canonical_root);
Ok((
canonical_root,
direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()),
))
}
fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
return path.as_os_str().as_bytes().to_vec();
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
let mut bytes = Vec::new();
for unit in path.as_os_str().encode_wide() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
return bytes;
}
#[cfg(not(any(unix, windows)))]
path.as_os_str().to_string_lossy().as_bytes().to_vec()
}
fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String {
let mut digest = Sha256::new();
digest.update(b"genarrative-direct-project-identity.v1\0");
digest.update((path_identity.len() as u64).to_le_bytes());
digest.update(path_identity);
digest.update((project_id.len() as u64).to_le_bytes());
digest.update(project_id);
format!("{:x}", digest.finalize())
}
pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
root: &std::path::Path,
system_prompt: String,
@@ -3746,7 +3786,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
client_turn_id: Option<&str>,
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
) -> Result<String, String> {
// Resolve project authority before deriving the pool/thread identity. A
// caller may hold a stable symlink path whose target changes between
@@ -3810,7 +3849,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
request,
Some(&codex_root),
effective_client_turn_id,
direct_user_item.as_ref(),
None,
observer,
audit,
@@ -1,32 +0,0 @@
//! DirectProject 历史注入载荷的单一构造 seam。
//!
//! 历史读取与大小前置校验集中在这里;调用方只负责线程生命周期与 RPC 传输。
use super::super::*;
use super::direct_project_history_injection_oversize_error;
use serde_json::Value;
use std::path::Path;
pub(super) fn build_direct_project_history_injection_params(
history_root: &Path,
thread_id: &str,
) -> Result<Value, platform_llm::LlmError> {
let canonical_items = read_direct_project_history_items_at(history_root)
.map_err(platform_llm::LlmError::InvalidRequest)?;
let items = canonical_items
.iter()
.map(|item| {
direct_codex_user_item_to_response_item(history_root, item)
.map_err(platform_llm::LlmError::InvalidRequest)
})
.collect::<Result<Vec<_>, _>>()?;
let params = serde_json::json!({"threadId": thread_id, "items": items});
let payload_bytes = serde_json::to_vec(&params)
.map(|bytes| bytes.len().saturating_add(1))
.unwrap_or(usize::MAX);
// 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。
if let Some(error) = direct_project_history_injection_oversize_error(&params, payload_bytes) {
return Err(platform_llm::LlmError::InvalidRequest(error));
}
Ok(params)
}
@@ -1,50 +0,0 @@
//! DirectProject 线程池身份的 canonical 解析与摘要。
use super::super::*;
use sha2::{Digest, Sha256};
pub(super) fn direct_codex_canonical_project_identity(
root: &std::path::Path,
) -> Result<(std::path::PathBuf, String), String> {
let (canonical_root, _) = resolve_direct_codex_project_authority(root)?;
let manifest = read_manifest(&canonical_root.join(".agent/manifest.json"))
.map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?;
let manifest_project_id = manifest.project_id.trim();
if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 {
return Err("DirectProject manifest.projectId 不满足身份边界".to_string());
}
let path_identity = direct_codex_os_path_identity_bytes(&canonical_root);
Ok((
canonical_root,
direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()),
))
}
fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
return path.as_os_str().as_bytes().to_vec();
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
let mut bytes = Vec::new();
for unit in path.as_os_str().encode_wide() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
return bytes;
}
#[cfg(not(any(unix, windows)))]
path.as_os_str().to_string_lossy().as_bytes().to_vec()
}
fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String {
let mut digest = Sha256::new();
digest.update(b"genarrative-direct-project-identity.v1\0");
digest.update((path_identity.len() as u64).to_le_bytes());
digest.update(path_identity);
digest.update((project_id.len() as u64).to_le_bytes());
digest.update(project_id);
format!("{:x}", digest.finalize())
}
@@ -606,11 +606,7 @@ fn build_design_request(
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
fn design_debug(root: &Path, kind: &str, data: Value) {
if std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG")
.ok()
.as_deref()
!= Some("1")
{
if !design_debug_enabled() {
return;
}
type Entry = (PathBuf, Value);
@@ -1133,6 +1129,18 @@ fn resolve_design_runtime_mode(root: &Path) -> Result<Option<DesignRuntimeMode>,
}))
}
fn design_debug_enabled() -> bool {
std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG")
.ok()
.as_deref()
== Some("1")
}
#[tauri::command]
pub(crate) fn is_design_agent_debug_enabled() -> bool {
cfg!(debug_assertions) && design_debug_enabled()
}
#[tauri::command]
pub(crate) fn set_design_agent_runtime_mode(
project_path: String,
@@ -1159,12 +1167,7 @@ pub(crate) fn debug_fast_forward_design_session(
project_path: String,
target_phase: String,
) -> Result<DesignRuntimeMode, String> {
if !cfg!(debug_assertions)
|| std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG")
.ok()
.as_deref()
!= Some("1")
{
if !is_design_agent_debug_enabled() {
return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string());
}
let root = Path::new(project_path.trim());
@@ -0,0 +1,382 @@
use super::*;
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160;
const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80;
const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240;
const DIRECT_CODEX_REFERENCE_HEADER: &str =
"[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]";
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub(crate) enum DirectCodexTurnReference {
#[serde(rename = "resource")]
Resource(DirectCodexResourceReference),
#[serde(rename = "runtime-region")]
RuntimeRegion(DirectCodexRuntimeRegionReference),
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexResourceReference {
pub(crate) resource_id: String,
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) source: Option<String>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexRuntimeRegionReference {
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) run_id: Option<String>,
#[serde(default)]
pub(crate) version_id: Option<String>,
#[serde(default)]
pub(crate) element_tag: Option<String>,
#[serde(default)]
pub(crate) element_role: Option<String>,
#[serde(default)]
pub(crate) text: Option<String>,
#[serde(default)]
pub(crate) width: Option<f64>,
#[serde(default)]
pub(crate) height: Option<f64>,
#[serde(default)]
pub(crate) resource_ids: Vec<String>,
}
fn sanitize_reference_text(value: &str, max_chars: usize) -> Option<String> {
let sanitized = value
.trim()
.chars()
.filter(|character| !character.is_control())
.take(max_chars)
.collect::<String>();
if sanitized.is_empty() {
None
} else {
Some(sanitized)
}
}
fn sanitize_reference_label(value: Option<&str>) -> Option<String> {
value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS))
}
fn sanitize_reference_source(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_element(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_dimension(value: Option<f64>) -> Option<u32> {
value
.filter(|value| value.is_finite() && *value > 0.0)
.map(|value| value.round().clamp(1.0, 100_000.0) as u32)
}
fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String {
let basename = asset
.local_path
.rsplit(['/', '\\'])
.next()
.unwrap_or(&asset.id);
let without_extension = basename
.rsplit_once('.')
.map(|(name, _)| name)
.unwrap_or(basename)
.trim();
if without_extension.is_empty() {
asset.id.clone()
} else {
without_extension
.chars()
.filter(|character| !character.is_control())
.take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)
.collect()
}
}
fn validate_resource_reference_id(value: &str) -> Result<String, String> {
let resource_id = value.trim();
if resource_id.is_empty()
|| resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS
|| resource_id.chars().any(char::is_control)
{
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
}
Ok(resource_id.to_string())
}
fn render_resource_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexResourceReference,
) -> Result<String, String> {
let resource_id = validate_resource_reference_id(&reference.resource_id)?;
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let local_path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| asset_display_label(asset));
let source = sanitize_reference_source(reference.source.as_deref())
.unwrap_or_else(|| "unknown".to_string());
Ok(format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
))
}
fn render_runtime_region_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexRuntimeRegionReference,
) -> Result<String, String> {
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| "运行画面区域".to_string());
let run_id = sanitize_reference_source(reference.run_id.as_deref());
let version_id = sanitize_reference_source(reference.version_id.as_deref());
let element_tag = sanitize_reference_element(reference.element_tag.as_deref());
let element_role = sanitize_reference_element(reference.element_role.as_deref());
let text = reference
.text
.as_deref()
.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS));
let width = sanitize_reference_dimension(reference.width);
let height = sanitize_reference_dimension(reference.height);
// `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时,
// 每个 id 都要扫一遍 manifestO(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界
// 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的
// 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。
if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!(
"运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选"
));
}
let mut related_resource_ids = Vec::new();
for resource_id in &reference.resource_ids {
let resource_id = validate_resource_reference_id(resource_id)?;
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
return Err("运行画面引用的素材已变化,请重新点选".to_string());
}
// 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。
// 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。
if !related_resource_ids.contains(&resource_id) {
related_resource_ids.push(resource_id);
}
}
let mut parts = vec![format!("名称:{label}")];
if let Some(run_id) = run_id {
parts.push(format!("运行标识:{run_id}"));
}
if let Some(version_id) = version_id {
parts.push(format!("版本标识:{version_id}"));
}
if let Some(element_tag) = element_tag {
parts.push(format!("元素:{element_tag}"));
}
if let Some(element_role) = element_role {
parts.push(format!("角色:{element_role}"));
}
if let Some(text) = text {
parts.push(format!("文本摘要:{text}"));
}
if let (Some(width), Some(height)) = (width, height) {
parts.push(format!("尺寸:{width}x{height}"));
}
if !related_resource_ids.is_empty() {
parts.push(format!("关联素材 ID{}", related_resource_ids.join(",")));
}
Ok(format!("- 运行画面区域:{}", parts.join("")))
}
pub(crate) fn render_direct_codex_references_section(
root: &Path,
references: &[DirectCodexTurnReference],
) -> Result<Option<String>, String> {
if references.is_empty() {
return Ok(None);
}
if references.len() > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
let manifest = read_manifest_for_project(root)?;
let mut lines = Vec::with_capacity(references.len());
for reference in references {
lines.push(match reference {
DirectCodexTurnReference::Resource(reference) => {
render_resource_reference_line(&manifest, reference)?
}
DirectCodexTurnReference::RuntimeRegion(reference) => {
render_runtime_region_reference_line(&manifest, reference)?
}
});
}
Ok(Some(
std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string())
.chain(lines)
.collect::<Vec<_>>()
.join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_project() -> tempfile::TempDir {
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
std::fs::create_dir_all(root.join(".agent")).expect("create agent dir");
let mut manifest = new_game_creation_app_manifest("project-1", "测试项目");
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-hero".to_string(),
kind: "character".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/hero.png".to_string(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("character"),
tags: Vec::new(),
});
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
directory
}
#[test]
fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("素材 IDasset-hero"));
assert!(section.contains("名称:主角"));
assert!(section.contains("项目路径:assets/hero.png"));
assert!(!section.contains("C:\\secret.png"));
}
#[test]
fn deleted_resource_fails_closed() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#,
)
.expect("reference json");
let error = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect_err("missing resource");
assert_eq!(error, "引用的素材已不存在,请移除后重新选择");
}
#[test]
fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"<button onclick=secret>"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("运行画面区域:名称:开始按钮"));
assert!(section.contains("文本摘要:开始游戏"));
assert!(section.contains("尺寸:120x40"));
assert!(section.contains("关联素材 IDasset-hero"));
assert!(!section.contains("onclick"));
assert!(!section.contains("secret"));
}
#[test]
fn runtime_region_dedupes_and_bounds_related_resource_ids() {
let project = fixture_project();
// 同一个 id 重复出现只应产生一条关联。
let duplicated: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"runtime-region","label":"开始按钮","resourceIds":["asset-hero","asset-hero"," asset-hero "],"text":"开始游戏"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&duplicated),
)
.expect("render")
.expect("section");
assert!(
section.contains("关联素材 IDasset-hero\n")
|| section.trim_end().ends_with("关联素材 IDasset-hero"),
"{section}"
);
assert!(
!section.contains("asset-hero,"),
"重复 id 不得在注入提示词里重复出现:{section}"
);
// 超出上限直接失败关闭:不能按对方给的长度注入提示词。
let oversized_ids = (0..MAX_DIRECT_CODEX_REFERENCES + 1)
.map(|_| "\"asset-hero\"".to_string())
.collect::<Vec<_>>()
.join(",");
let oversized: DirectCodexTurnReference = serde_json::from_str(&format!(
r#"{{"type":"runtime-region","label":"开始按钮","resourceIds":[{oversized_ids}]}}"#
))
.expect("reference json");
let error = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&oversized),
)
.expect_err("oversized resource id list must fail closed");
assert!(error.contains("最多关联"), "{error}");
}
}
@@ -1,15 +0,0 @@
//! DirectProject user input 的 canonical Response item 深模块。
mod model;
mod validation;
mod wire;
pub(crate) use model::{
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_prompt_with_attachments,
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
};
@@ -1,118 +0,0 @@
use serde::{Deserialize, Serialize};
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/"))]
pub(crate) enum DirectCodexUserItem {
#[serde(rename = "message")]
Message(DirectCodexUserMessageItem),
}
#[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/"))]
pub(crate) struct DirectCodexUserMessageItem {
pub(crate) role: DirectCodexUserRole,
pub(crate) content: Vec<DirectCodexUserContentPart>,
pub(crate) id: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/"))]
pub(crate) enum DirectCodexUserContentPart {
#[serde(rename = "input_text")]
InputText { text: String },
#[serde(rename = "agc_resource_reference")]
AgcResourceReference { resource_id: String },
#[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
}
#[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/"))]
pub(crate) struct DirectCodexUserRuntimeRegionPart {
pub(crate) label: String,
#[serde(default)]
pub(crate) run_id: Option<String>,
#[serde(default)]
pub(crate) version_id: Option<String>,
#[serde(default)]
pub(crate) element_tag: Option<String>,
#[serde(default)]
pub(crate) element_role: Option<String>,
#[serde(default)]
pub(crate) text: Option<String>,
#[serde(default)]
pub(crate) width: Option<f64>,
#[serde(default)]
pub(crate) height: Option<f64>,
#[serde(default)]
pub(crate) resource_ids: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn resource_reference_serializes_with_only_camel_case_resource_id() {
let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem {
role: DirectCodexUserRole::User,
content: vec![DirectCodexUserContentPart::AgcResourceReference {
resource_id: "asset-hero".to_string(),
}],
id: "turn-1".to_string(),
});
assert_eq!(
serde_json::to_value(item).expect("serialize user item"),
json!({
"type": "message",
"role": "user",
"content": [{
"type": "agc_resource_reference",
"resourceId": "asset-hero"
}],
"id": "turn-1"
})
);
}
#[test]
fn resource_reference_rejects_extra_identity_fields() {
let error = serde_json::from_value::<DirectCodexUserItem>(json!({
"type": "message",
"role": "user",
"content": [{
"type": "agc_resource_reference",
"resourceId": "asset-hero",
"label": "主角"
}],
"id": "turn-1"
}))
.expect_err("label must not be accepted on resource reference");
assert!(error.to_string().contains("unknown field"), "{error}");
}
#[test]
fn unknown_content_part_fails_closed() {
serde_json::from_value::<DirectCodexUserItem>(json!({
"type": "message",
"role": "user",
"content": [{"type": "future_part", "value": "x"}],
"id": "turn-1"
}))
.expect_err("unknown content part must fail closed");
}
}
@@ -1,96 +0,0 @@
use super::model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRole,
DirectCodexUserRuntimeRegionPart,
};
use crate::agent::{
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
};
use std::path::Path;
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
pub(crate) fn validate_direct_codex_user_item(
root: &Path,
item: &DirectCodexUserItem,
) -> Result<GameCreationAppManifest, String> {
validate_direct_codex_user_item_with_empty_content(root, item, false)
}
pub(crate) fn validate_direct_codex_user_item_with_empty_content(
root: &Path,
item: &DirectCodexUserItem,
allow_empty_content: bool,
) -> Result<GameCreationAppManifest, String> {
let DirectCodexUserItem::Message(message) = item;
if !matches!(message.role, DirectCodexUserRole::User) {
return Err("DirectProject 只接受 user message item".to_string());
}
if message.id.trim().is_empty() {
return Err("DirectProject user item 缺少稳定 id".to_string());
}
if message.content.is_empty() && !allow_empty_content {
return Err("DirectProject user item content 不能为空".to_string());
}
let manifest = read_manifest_for_project(root)?;
let mut reference_count = 0usize;
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
if text.trim().is_empty() {
return Err("DirectProject input_text 不能为空".to_string());
}
}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
reference_count = reference_count.saturating_add(1);
validate_resource_id_and_manifest(&manifest, resource_id)?;
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?;
}
}
}
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
Ok(manifest)
}
pub(crate) fn validate_resource_id_and_manifest(
manifest: &GameCreationAppManifest,
resource_id: &str,
) -> Result<(), String> {
let resource_id = resource_id.trim();
if resource_id.is_empty()
|| resource_id.chars().count() > 200
|| resource_id.chars().any(char::is_control)
{
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
}
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
Ok(())
}
fn validate_runtime_region_reference(
manifest: &GameCreationAppManifest,
reference: &DirectCodexUserRuntimeRegionPart,
) -> Result<(), String> {
if reference.label.trim().is_empty() {
return Err("运行画面区域缺少名称".to_string());
}
if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!(
"运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材"
));
}
for resource_id in &reference.resource_ids {
validate_resource_id_and_manifest(manifest, resource_id)?;
}
Ok(())
}
@@ -1,226 +0,0 @@
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
use super::validation::{
validate_direct_codex_user_item, validate_direct_codex_user_item_with_empty_content,
};
use crate::agent::sanitize_attachment_local_path;
use serde_json::Value;
use std::path::Path;
/// 将历史中的 canonical user item 投影为 Codex `response_item` message。
/// 非 user message 的标准 Response item 原样返回;未知形状直接失败。
pub(crate) fn direct_codex_user_item_to_response_item(
root: &Path,
item: &Value,
) -> Result<Value, String> {
let is_user_message = item.get("type").and_then(Value::as_str) == Some("message")
&& item.get("role").and_then(Value::as_str) == Some("user");
if !is_user_message {
if item.get("type").and_then(Value::as_str).is_some() {
return Ok(item.clone());
}
return Err("DirectProject 历史 item 缺少 type,无法投影为 Codex item".to_string());
}
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
.map_err(|error| format!("DirectProject user item 无法转换为 Codex item{error}"))?;
let content = direct_codex_user_item_to_response_content(root, &canonical)?;
let mut projected = serde_json::json!({
"type": "message",
"role": "user",
"content": content,
});
if let Some(id) = item.get("id").and_then(Value::as_str) {
projected["id"] = Value::String(id.to_string());
}
Ok(projected)
}
fn direct_codex_user_item_to_response_content(
root: &Path,
item: &DirectCodexUserItem,
) -> Result<Vec<Value>, String> {
let Value::Array(input) = direct_codex_user_item_to_wire_input(root, item)? else {
return Err("DirectProject user item wire content 不是数组".to_string());
};
input
.into_iter()
.map(|part| {
let text = part
.get("text")
.and_then(Value::as_str)
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())?;
Ok(serde_json::json!({ "type": "input_text", "text": text }))
})
.collect()
}
/// 将 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> {
direct_codex_user_item_to_wire_input_with_empty_content(root, item, false)
}
fn direct_codex_user_item_to_wire_input_with_empty_content(
root: &Path,
item: &DirectCodexUserItem,
allow_empty_content: bool,
) -> Result<Value, String> {
let manifest = if allow_empty_content {
validate_direct_codex_user_item_with_empty_content(root, item, true)?
} else {
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()
)
}
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
}
};
input.push(serde_json::json!({ "type": "text", "text": text }));
}
Ok(Value::Array(input))
}
pub(crate) fn direct_codex_user_item_to_prompt(
root: &Path,
item: &DirectCodexUserItem,
) -> Result<String, String> {
let wire = direct_codex_user_item_to_wire_input(root, item)?;
wire.as_array()
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
.map(|parts| {
parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<String>()
})
.and_then(|prompt| {
if prompt.trim().is_empty() {
Err("DirectProject user item 不能转换为空 prompt".to_string())
} else {
Ok(prompt)
}
})
}
pub(crate) fn direct_codex_user_item_to_prompt_with_attachments(
root: &Path,
item: &DirectCodexUserItem,
) -> Result<String, String> {
let wire = direct_codex_user_item_to_wire_input_with_empty_content(root, item, true)?;
wire.as_array()
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
.map(|parts| {
parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<String>()
})
}
#[cfg(test)]
mod tests {
use super::super::model::{
DirectCodexUserItem, DirectCodexUserMessageItem, DirectCodexUserRole,
};
use super::{
direct_codex_user_item_to_prompt_with_attachments, direct_codex_user_item_to_response_item,
};
use serde_json::json;
use std::path::Path;
#[test]
fn standard_response_item_passes_through_without_agc_private_parts() {
let item = json!({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "完成"}]
});
assert_eq!(
direct_codex_user_item_to_response_item(Path::new("/unused"), &item)
.expect("assistant response item should pass through"),
item
);
}
#[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 history_item_without_type_fails_closed() {
let error = direct_codex_user_item_to_response_item(
Path::new("/unused"),
&json!({"role": "assistant"}),
)
.expect_err("history item without type must fail");
assert!(error.contains("缺少 type"), "{error}");
}
#[test]
fn attachment_only_user_item_projects_to_an_empty_text_sidecar_prompt() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "attachment-only", "attachment-only");
let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem {
role: DirectCodexUserRole::User,
content: vec![],
id: "turn-attachment-only:user".to_string(),
});
assert_eq!(
direct_codex_user_item_to_prompt_with_attachments(root.path(), &item)
.expect("empty canonical text is valid before attachment sidecar rendering"),
""
);
}
}
@@ -17,6 +17,9 @@ const DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED: usize = 1;
/// 写入侧与读取侧共用同一个信封类型:`project.jsonl` 由项目主对话与 DirectProject 共享,
/// 这个值一旦只在写入侧改动,读取侧就会把对方的行当成坏行,整份历史立刻读不出来。
pub(crate) const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
/// 格式切换到 `response_item`#282)之前,DirectProject 主对话通过通用对话写入器
/// 落到同一份 `project.jsonl`,行形状是 `PersistedLocalConversationMessageRecord`。
const DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION: &str = "game-creator-conversation.v1";
const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[
"host_skills.instructions",
"permissions.instructions",
@@ -177,22 +180,79 @@ fn direct_project_history_item_from_line(
Ok(Some(item))
}
/// 只接受 `response_item` 行信封;其它历史格式不提供迁移或 fallback,直接失败关闭。
/// 只接受两种行信封:`response_item`,以及白名单化的 legacy 行。返回 `None` 表示行已
/// 被识别、但不该进入 Codex 上下文(legacy 的 `tool` 行)。
/// 其余任何形状(含换了 `schemaVersion`、带 `type` 却不是 `response_item`、role 不在
/// legacy 写入器自己的角色集合内、content 不是非空字符串)都判定为损坏并失败关闭。
fn direct_project_history_item_from_parsed_line(
path: &Path,
parsed: &Value,
) -> Result<Option<Value>, String> {
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
return Err(format!(
"DirectProject 历史记录类型无效:{}",
path.display()
));
}
parsed
if parsed.get("type").and_then(Value::as_str) == Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
return parsed
.get("payload")
.cloned()
.map(Some)
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload{}", path.display()))
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload{}", path.display()));
}
direct_project_legacy_row(parsed)
.map(|row| row.message_item())
.ok_or_else(|| format!("DirectProject 历史记录类型无效:{}", path.display()))
}
/// 白名单化 legacy 行的投影结果。
enum DirectProjectLegacyRow {
/// user/assistant 行:投影成 message item 进入历史。
Message(Value),
/// 已识别但不可注入的行(`tool`):与 developer/system item 同样处理,不进 Codex
/// 上下文。legacy 行不是 Responses item`tool` 行无法还原成真正的工具 item,
/// 注入会造出假的工具消息;聊天投影本来也只展示 user/assistant。
NotChat,
}
impl DirectProjectLegacyRow {
fn message_item(self) -> Option<Value> {
match self {
DirectProjectLegacyRow::Message(item) => Some(item),
DirectProjectLegacyRow::NotChat => None,
}
}
}
/// 格式切换前的 legacy 行投影。存量用户项目的历史文件全是这种行,读取时投影成与
/// `direct_project_local_message_item` 同形状的 message item`role` 与 `content`
/// 原样保留(不 trim、不改写、不合并),未知字段忽略。
///
/// 角色白名单取的是 legacy 写入器自己的角色集合,也就是
/// `project/conversation.rs` 里 `matches!(role, "user" | "assistant" | "tool")` 这一
/// 条校验,所以「legacy 写入器能写出的行」被完整覆盖,不会有第三种角色漏进来;
/// 白名单之外的角色(手改文件、未来写入器)仍按损坏失败关闭。
fn direct_project_legacy_row(parsed: &Value) -> Option<DirectProjectLegacyRow> {
let object = parsed.as_object()?;
if object.contains_key("type")
|| object.get("schemaVersion").and_then(Value::as_str)
!= Some(DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION)
{
return None;
}
let role = object.get("role").and_then(Value::as_str)?;
if !matches!(role, "user" | "assistant" | "tool") {
return None;
}
let content = object.get("content").and_then(Value::as_str)?;
if content.is_empty() {
return None;
}
if role == "tool" {
return Some(DirectProjectLegacyRow::NotChat);
}
let message_id = object
.get("messageId")
.and_then(Value::as_str)
.filter(|message_id| !message_id.is_empty());
Some(DirectProjectLegacyRow::Message(
direct_project_message_item(role, content, message_id),
))
}
/// 测试专用探针:在"锁外幂等回扫"这一段的两端回调。
@@ -452,8 +512,8 @@ pub(crate) fn direct_project_local_message_item(
))
}
/// 本地补写与 Codex response item 共用同一种 Responses message item 形状;本地补写额外做
/// trim 校验,历史读取只接受 response_item envelope
/// 本地补写与 legacy 投影共用同一种 Responses message item 形状,两者的区别只在
/// 是否对入参做 trim 校验:本地补写走上面的校验,legacy 行按文件内容逐字节投影
fn direct_project_message_item(role: &str, content: &str, message_id: Option<&str>) -> Value {
let mut item = serde_json::json!({
"type": "message",
@@ -566,7 +626,7 @@ mod tests {
fn init_history_project(name: &str) -> tempfile::TempDir {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), name, "Response item 历史测试")
crate::init_local_game_project_at(root.path(), name, "历史格式兼容测试")
.expect("init project");
root
}
@@ -577,8 +637,9 @@ mod tests {
std::fs::write(&path, format!("{}\n", lines.join("\n"))).expect("write history fixture");
}
const LEGACY_USER_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"user","content":"请创建菜单","agentId":null,"messageId":"direct-codex:turn-0001:user","updatedAt":1757000000}"#;
const LEGACY_ASSISTANT_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"assistant","content":"已完成 第一行\n第二行 ","agentId":null,"updatedAt":1757000001}"#;
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
/// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。
///
@@ -587,7 +648,7 @@ mod tests {
#[test]
fn contention_failure_is_retried_with_bounded_backoff() {
let root = init_history_project("contention-retry");
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
write_history_lines(root.path(), &[LEGACY_USER_ROW]);
let marker = root
.path()
.join(".agent/runtime/test-fail-next-direct-project-history-append");
@@ -619,7 +680,7 @@ mod tests {
#[test]
fn contention_failure_beyond_the_backoff_budget_fails_closed() {
let root = init_history_project("contention-bounded");
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
write_history_lines(root.path(), &[LEGACY_USER_ROW]);
let marker = root
.path()
.join(".agent/runtime/test-fail-next-direct-project-history-append");
@@ -662,7 +723,7 @@ mod tests {
write_history_lines(
root.path(),
&[
RESPONSE_ITEM_ROW,
LEGACY_USER_ROW,
r#"{"schemaVersion":"game-creator"#,
RESPONSE_ITEM_ROW,
],
@@ -700,7 +761,7 @@ mod tests {
let root = init_history_project("scan-outside-lock");
write_history_lines(
root.path(),
&[RESPONSE_ITEM_ROW, RESPONSE_ITEM_ROW, RESPONSE_ASSISTANT_ROW],
&[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW],
);
let path = history_path(root.path());
let fired = Arc::new(AtomicBool::new(false));
@@ -744,7 +805,7 @@ mod tests {
#[test]
fn concurrent_append_after_the_out_of_lock_scan_still_prevents_a_duplicate_row() {
let root = init_history_project("scan-stale-state");
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
write_history_lines(root.path(), &[LEGACY_USER_ROW]);
let path = history_path(root.path());
let item = json!({
"type": "message",
@@ -784,7 +845,7 @@ mod tests {
#[test]
fn conflicting_item_id_still_fails_closed_with_the_scan_outside_the_lock() {
let root = init_history_project("id-conflict-outside-lock");
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
write_history_lines(root.path(), &[LEGACY_USER_ROW]);
append_direct_project_history_item_at(
root.path(),
&json!({
@@ -972,13 +1033,97 @@ mod tests {
}
#[test]
fn non_response_item_history_fails_closed() {
fn legacy_conversation_rows_project_into_responses_message_items() {
let root = init_history_project("legacy-projection");
write_history_lines(root.path(), &[LEGACY_USER_ROW, LEGACY_ASSISTANT_ROW]);
let items = read_direct_project_history_items_at(root.path()).expect("read legacy history");
assert_eq!(
items,
vec![
json!({
"type": "message",
"role": "user",
"id": "direct-codex:turn-0001:user",
"content": [{"type": "input_text", "text": "请创建菜单"}],
}),
json!({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "已完成 第一行\n第二行 "}],
}),
]
);
let chat = read_direct_project_chat_history_at(root.path()).expect("read legacy chat");
assert_eq!(chat.messages.len(), 2);
assert_eq!(chat.messages[0].role, "user");
assert_eq!(chat.messages[0].content, "请创建菜单");
assert_eq!(
chat.messages[0].message_id.as_deref(),
Some("direct-codex:turn-0001:user")
);
assert_eq!(chat.messages[1].role, "assistant");
assert_eq!(chat.messages[1].content, "已完成 第一行\n第二行 ");
assert_eq!(chat.messages[1].message_id, None);
}
#[test]
fn mixed_legacy_and_response_item_history_reads_in_file_order() {
let root = init_history_project("mixed-history");
write_history_lines(
root.path(),
&[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW],
);
let items = read_direct_project_history_items_at(root.path()).expect("read mixed history");
assert_eq!(
items
.iter()
.map(|item| item.get("id").and_then(Value::as_str))
.collect::<Vec<_>>(),
vec![
Some("direct-codex:turn-0001:user"),
Some("codex-item-2"),
None
]
);
assert_eq!(
items[1],
json!({
"type": "message",
"role": "user",
"id": "codex-item-2",
"content": [{"type": "input_text", "text": "再加一个按钮"}],
})
);
assert_eq!(
items[2]["content"][0]["text"],
json!("已完成 第一行\n第二行 ")
);
let chat = read_direct_project_chat_history_at(root.path()).expect("read mixed chat");
assert_eq!(
chat.messages
.iter()
.map(|message| (message.role.as_str(), message.content.as_str()))
.collect::<Vec<_>>(),
vec![
("user", "请创建菜单"),
("user", "再加一个按钮"),
("assistant", "已完成 第一行\n第二行 "),
]
);
}
#[test]
fn legacy_compat_keeps_unrecognized_rows_failing_closed() {
let unrecognized_rows = [
// 带 type 却不是 response_item
r#"{"type":"message","role":"user","content":[{"type":"input_text","text":"x"}]}"#,
// schema 不提供 fallback
// schemaVersion 不在白名单里
r#"{"schemaVersion":"game-creator-conversation.v2","role":"user","content":"x","agentId":null,"updatedAt":1}"#,
// 旧 schema 的其它角色也拒绝
// legacy 写入器角色集合之外的角色
r#"{"schemaVersion":"game-creator-conversation.v1","role":"system","content":"x","agentId":null,"updatedAt":1}"#,
r#"{"schemaVersion":"game-creator-conversation.v1","role":"developer","content":"x","agentId":null,"updatedAt":1}"#,
// content 不是字符串
@@ -1014,4 +1159,61 @@ mod tests {
.expect_err("broken json must fail closed");
assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}");
}
#[test]
fn legacy_tool_row_is_recognized_but_stays_out_of_the_codex_context() {
let root = init_history_project("legacy-tool-row");
write_history_lines(
root.path(),
&[
LEGACY_USER_ROW,
r#"{"schemaVersion":"game-creator-conversation.v1","role":"tool","content":"{\"ok\":true}","agentId":null,"updatedAt":1757000002}"#,
LEGACY_ASSISTANT_ROW,
],
);
let items = read_direct_project_history_items_at(root.path()).expect("read history");
assert_eq!(
items
.iter()
.map(|item| item["role"].as_str().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["user", "assistant"]
);
let chat = read_direct_project_chat_history_at(root.path()).expect("read chat");
assert_eq!(
chat.messages
.iter()
.map(|message| message.role.as_str())
.collect::<Vec<_>>(),
vec!["user", "assistant"]
);
}
#[test]
fn generic_writer_rows_are_covered_by_the_legacy_whitelist() {
let root = init_history_project("generic-writer-shape");
crate::append_local_conversation_message_at(
root.path(),
None,
crate::LocalConversationMessage {
role: "user".to_string(),
content: "通用写入器写的旧格式回合".to_string(),
agent_id: None,
},
)
.expect("append legacy row through the generic writer");
let items =
read_direct_project_history_items_at(root.path()).expect("read generic writer row");
assert_eq!(
items,
vec![json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "通用写入器写的旧格式回合"}],
})]
);
}
}
@@ -6,9 +6,6 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
mod user_input;
pub(crate) use user_input::chat_with_game_creator_direct_codex;
const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
@@ -4058,7 +4055,6 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
creation_type,
None,
None,
None,
)
.await
}
@@ -4069,7 +4065,6 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
creation_type: Option<&str>,
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
) -> Result<String, String> {
if !root.is_absolute() || !root.is_dir() {
return Err("当前项目目录不存在或不是绝对路径".to_string());
@@ -4085,15 +4080,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
if let Some(emitter) = turn_emitter {
emitter.emit("accepted", Some("request-accepted"), None);
}
match run_direct_game_creator_turn_inner(
root,
prompt,
creation_type,
turn_emitter,
audit,
direct_user_item,
)
.await
match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await
{
Ok(reply) => Ok(reply),
Err(failure) => {
@@ -4112,7 +4099,6 @@ async fn run_direct_game_creator_turn_inner(
creation_type: Option<&str>,
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
) -> Result<String, DirectCodexTurnFailure> {
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
if let Some(emitter) = turn_emitter {
@@ -4166,7 +4152,6 @@ async fn run_direct_game_creator_turn_inner(
Some(&client_turn_id),
Some(&mut observer),
audit,
direct_user_item,
)
.await
} else {
@@ -4177,7 +4162,6 @@ async fn run_direct_game_creator_turn_inner(
None,
None,
audit,
direct_user_item,
)
.await
}
@@ -4469,6 +4453,95 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
))
}
fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result<String, String> {
let Some(client_turn_id) = client_turn_id else {
return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string());
};
let client_turn_id = client_turn_id.trim();
let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS)
.contains(&client_turn_id.len());
let mut bytes = client_turn_id.bytes();
let valid_first = bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphanumeric());
let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
if !valid_length || !valid_first || !valid_rest {
return Err(format!(
"clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字"
));
}
Ok(client_turn_id.to_string())
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
prompt: String,
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
references: Option<Vec<DirectCodexTurnReference>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
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();
let references = references.unwrap_or_default();
let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) {
Ok(prompt) => prompt,
Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(),
Err(error) => {
audit.finish(false);
return Err(error);
}
};
if let Some(reference_section) = match render_direct_codex_references_section(root, &references)
{
Ok(section) => section,
Err(error) => {
audit.finish(false);
return Err(error);
}
} {
if !user_prompt.trim().is_empty() {
user_prompt.push_str("\n\n");
}
user_prompt.push_str(&reference_section);
}
if user_prompt.trim().is_empty() {
audit.finish(false);
return Err("聊天内容不能为空".to_string());
}
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),
)
.await
{
Ok(reply) => reply,
Err(error) => {
audit.finish(false);
return Err(error);
}
};
audit.finish(true);
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
Ok(reply)
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_home_direct_codex(
prompt: String,
@@ -1,86 +0,0 @@
//! DirectProject 用户输入命令适配器。
//!
//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item
//! 深模块,回合编排仍由父模块负责。
use super::*;
fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result<String, String> {
let Some(client_turn_id) = client_turn_id else {
return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string());
};
let client_turn_id = client_turn_id.trim();
let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS)
.contains(&client_turn_id.len());
let mut bytes = client_turn_id.bytes();
let valid_first = bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphanumeric());
let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
if !valid_length || !valid_first || !valid_rest {
return Err(format!(
"clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS}{MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字"
));
}
Ok(client_turn_id.to_string())
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
prompt: String,
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())?;
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
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();
let user_prompt = if attachments.is_empty() {
direct_codex_user_item_to_prompt(root, &user_item)
} else {
direct_codex_user_item_to_prompt_with_attachments(root, &user_item)
}
.map_err(|error| {
audit.finish(false);
error
})?;
let user_prompt =
render_direct_codex_user_prompt(&user_prompt, &attachments).map_err(|error| {
audit.finish(false);
error
})?;
let canonical_user_item =
Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?);
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),
canonical_user_item,
)
.await
{
Ok(reply) => reply,
Err(error) => {
audit.finish(false);
return Err(error);
}
};
audit.finish(true);
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
Ok(reply)
}
@@ -2159,6 +2159,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
generate_platform_art_asset_with_options_at(&state.root, &prompt, &[], &options),
)
.await?;
emit_game_creator_manifest_invalidated(&state.root, "direct-codex-art");
let resources = bridge_art_resources(
&state.root,
std::slice::from_ref(&generated.asset.local_path),
@@ -2672,6 +2672,7 @@ fn main() {
hydrate_design_agent_session,
reset_design_agent_session,
get_design_agent_runtime_mode,
is_design_agent_debug_enabled,
set_design_agent_runtime_mode,
debug_fast_forward_design_session,
continue_design_agent_session,
@@ -1088,6 +1088,90 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
fs::remove_dir_all(ui_config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn direct_image_generation_notifies_after_manifest_commit() {
let root = unique_project_path();
let config_dir = unique_project_path();
let canvas_base_url = spawn_mock_external_canvas_generation_api_server(None);
let _session = crate::platform_session::install_test_platform_session(
"direct-image-refresh-user",
"editor-runtime-key",
&canvas_base_url,
);
fs::create_dir_all(&config_dir).expect("create config directory");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" }
})
.to_string(),
)
.expect("write config");
let _config = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(&root, "direct-image-refresh", "生成图片刷新测试")
.expect("init project");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow generation");
let listener =
TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).expect("bind event receiver");
let sink = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
sink.configure(listener.local_addr().unwrap().port(), &"d".repeat(64))
.expect("configure event receiver");
let bridge = start_direct_tool_bridge(&root, false)
.await
.expect("start tool bridge");
let client = reqwest::Client::new();
let result: Value = client.post(bridge.url()).json(&serde_json::json!({
"tool": "agc_generate_image",
"arguments": { "prompt": "像素月光主角", "kind": "icon-spec", "outputPath": "assets/art-spec.png" }
})).send().await.expect("generate through bridge").json().await.expect("read tool result");
assert_eq!(result["isError"], false, "{result}");
let manifest = read_existing_manifest_for_project(&root).expect("read committed manifest");
assert!(manifest
.assets
.iter()
.any(|asset| asset.local_path == "assets/art-spec.png"));
assert!(root.join("assets/art-spec.png").is_file());
let payload = read_manifest_invalidation_relay_payload_with_deadline(&listener)
.expect("generation must notify the client");
let envelope: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&payload).expect("event envelope");
assert_eq!(
envelope.event.project_path,
fs::canonicalize(&root).unwrap().to_string_lossy()
);
assert_eq!(envelope.event.agent_id, "direct-codex-art");
let rejected: Value = client
.post(bridge.url())
.json(&serde_json::json!({
"tool": "agc_generate_image", "arguments": { "prompt": "", "kind": "icon-spec" }
}))
.send()
.await
.expect("send rejected request")
.json()
.await
.expect("read rejected result");
assert_eq!(rejected["isError"], true);
assert_eq!(
read_manifest_invalidation_relay_payload_with_deadline(&listener)
.expect_err("rejected generation must not emit a commit")
.kind(),
io::ErrorKind::TimedOut
);
drop(bridge);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_manifest() {
let root = unique_project_path();
+56 -96
View File
@@ -190,6 +190,7 @@ import {
missingChatCommandArgumentMessage,
projectFileActionDrafts,
projectPathHasControlCharacter,
projectPathsMatchForInvalidation,
readableArtifactsFromAgentRunTrace,
sortCheckpointManifestFiles,
summarizeAgentRunSupportFileReadDrafts,
@@ -253,9 +254,11 @@ import { handleProjectSummaryChatCommand } from './features/project-workspace/pr
import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView';
import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane';
import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput';
import type { ChatReference } from './features/project-workspace/resourceReferences';
import type {
ChatComposerDraft,
ChatReference,
} from './features/project-workspace/resourceReferences';
import {
chatComposerDraftToDirectCodexUserItem,
RESOURCE_REFERENCE_INSERT_EVENT,
type ResourceReferenceInsertEventDetail,
} from './features/project-workspace/resourceReferences';
@@ -547,7 +550,6 @@ type ExecuteChatAgentReplyInput = {
attachments?: DirectCodexTurnAttachment[];
directPolicyChecked?: boolean;
references?: ChatReference[];
userItem?: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>;
};
/**
@@ -730,22 +732,13 @@ export function App({
);
}
const [chatInput, setChatInput] = useState(() =>
supervisorChatOnly && initialProjectPath
? readSupervisorChatDraft(initialProjectPath)
: '',
);
const [chatReferences, setChatReferences] = useState<ChatReference[]>([]);
const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null);
const initialChatDraftHydratedRef = useRef(false);
useEffect(() => {
if (
initialChatDraftHydratedRef.current ||
!supervisorChatOnly ||
!initialProjectPath
) {
return;
}
initialChatDraftHydratedRef.current = true;
const persistedText = readSupervisorChatDraft(initialProjectPath);
if (persistedText) {
chatComposerRef.current?.replaceText(persistedText);
}
}, [initialProjectPath, supervisorChatOnly]);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
const [directCodexProgress, setDirectCodexProgress] = useState('');
const [directCodexStatus, setDirectCodexStatus] = useState<
@@ -2200,10 +2193,17 @@ export function App({
void subscribeTauriEvent<GameCreatorManifestInvalidatedEvent>(
'game-creator-manifest-invalidated',
(event) => {
if (event.payload.projectPath !== localProjectPathRef.current) {
const activeProjectPath = localProjectPathRef.current;
if (
!activeProjectPath ||
!projectPathsMatchForInvalidation(
event.payload.projectPath,
activeProjectPath,
)
) {
return;
}
void refreshManifest(event.payload.projectPath);
void refreshManifest(activeProjectPath);
},
)
.then((unlisten) => {
@@ -2460,6 +2460,13 @@ export function App({
supervisorChatOnly,
]);
useEffect(() => {
if (!supervisorChatOnly) {
return;
}
persistSupervisorChatDraft(initialProjectPath, chatInput);
}, [chatInput, initialProjectPath, supervisorChatOnly]);
useEffect(() => {
latestMessagesRef.current = messages;
const invoke = resolveTauriInvoke();
@@ -3683,13 +3690,9 @@ export function App({
setWorkspaceProjectKind(projectKind);
setLocalProject(openedProject);
if (supervisorChatOnly) {
clearChatComposer();
chatComposerRef.current?.replaceText(
readSupervisorChatDraft(openedProject.projectPath),
);
} else {
clearChatComposer();
setChatInput(readSupervisorChatDraft(openedProject.projectPath));
}
setChatReferences([]);
setManifest(openedProject.manifest);
setProjectFiles([]);
setProjectCheckpoints([]);
@@ -3927,29 +3930,15 @@ export function App({
closeAgentConversation();
}
function readChatComposerDraft() {
return (
chatComposerRef.current?.getDraft() ?? {
text: '',
references: [],
content: [],
}
);
}
function prepareChatCommandDraft(commandDraft: string) {
chatComposerRef.current?.replaceText(commandDraft);
setChatInput(commandDraft);
setChatReferences([]);
window.setTimeout(() => chatInputRef.current?.focus(), 0);
}
function clearChatComposer() {
chatComposerRef.current?.clear();
}
function handleChatComposerChange(draft: { text: string }) {
if (supervisorChatOnly) {
persistSupervisorChatDraft(initialProjectPath, draft.text);
}
function handleChatComposerChange(draft: ChatComposerDraft) {
setChatInput(draft.text);
setChatReferences(draft.references);
}
useEffect(() => {
@@ -4977,9 +4966,8 @@ export function App({
async function handleChatSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const draft = readChatComposerDraft();
const prompt = draft.text.trim();
const references = draft.references;
const prompt = chatInput.trim();
const references = chatReferences;
if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) {
setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题');
return;
@@ -4988,7 +4976,8 @@ export function App({
return;
}
clearChatComposer();
setChatInput('');
setChatReferences([]);
setMessages((current) => [
...current,
{
@@ -6076,16 +6065,7 @@ export function App({
return;
}
const clientTurnId = directCodexProductRuntime
? createDirectCodexConversationTurnId()
: undefined;
const userItem = clientTurnId
? chatComposerDraftToDirectCodexUserItem(
draft,
directCodexConversationMessageId(clientTurnId, 'user'),
)
: undefined;
void executeChatAgentReply({ prompt, references, userItem, clientTurnId });
void executeChatAgentReply({ prompt, references });
}
async function executeLlmConfigStatus() {
@@ -6314,7 +6294,6 @@ export function App({
attachments,
directPolicyChecked = false,
references,
userItem,
}: ExecuteChatAgentReplyInput) {
if (planningV2ActiveRef.current || planningStartMode) {
const nextProjectPath = resolveChatProjectPath(localProject);
@@ -6345,13 +6324,6 @@ export function App({
if (directProjectPath && directInvoke) {
const clientTurnId =
directConversationTurnId ?? createDirectCodexConversationTurnId();
if (!userItem) {
setProjectSupervisorRuntimeError(
'DirectProject 缺少 canonical user item,已拒绝发送。',
);
return;
}
const effectiveUserItem = userItem;
if (
!directPolicyChecked &&
projectConversationWriteConfirmedRef.current !== directProjectPath
@@ -6363,7 +6335,6 @@ export function App({
creationType,
attachments,
references,
userItem: effectiveUserItem,
});
try {
const policyPaused = await queueProjectPolicyConfirmationIfNeeded(
@@ -6488,12 +6459,11 @@ export function App({
clientTurnId: string;
creationType?: HomeCreationType;
attachments?: DirectCodexTurnAttachment[];
userItem: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>;
references?: ChatReference[];
} = {
projectPath: directProjectPath,
prompt,
clientTurnId,
userItem: effectiveUserItem,
};
if (creationType) {
directTurnInput.creationType = creationType;
@@ -6501,6 +6471,9 @@ export function App({
if (attachments?.length) {
directTurnInput.attachments = attachments;
}
if (references?.length) {
directTurnInput.references = references;
}
const reply = await withDirectCodexSessionRefresh(() => {
// 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。
activeDirectCodexTurnRef.current = {
@@ -6777,18 +6750,6 @@ export function App({
const directConversationTurnId = directCodexProductRuntime
? createDirectCodexConversationTurnId()
: undefined;
const initialUserItem = directConversationTurnId
? chatComposerDraftToDirectCodexUserItem(
{
text: latch.prompt,
references: [],
content: latch.prompt.trim()
? [{ type: 'input_text', text: latch.prompt }]
: [],
},
directCodexConversationMessageId(directConversationTurnId, 'user'),
)
: undefined;
setMessages((current) => [
...current,
{
@@ -6811,7 +6772,6 @@ export function App({
clientTurnId: directConversationTurnId,
creationType: latch.creationType,
attachments: latch.attachments,
userItem: initialUserItem,
});
}, [
chatAgentBusy,
@@ -11817,9 +11777,8 @@ export function App({
event: FormEvent<HTMLFormElement>,
) {
event.preventDefault();
const draft = readChatComposerDraft();
const prompt = draft.text.trim();
const references = draft.references;
const prompt = chatInput.trim();
const references = chatReferences;
if (
!directCodexProductRuntime &&
supervisorChatOnly &&
@@ -11843,7 +11802,8 @@ export function App({
if (!nextProjectPath) {
return;
}
clearChatComposer();
setChatInput('');
setChatReferences([]);
void loadProjectConversation(nextProjectPath, false, 'replace');
return;
}
@@ -11853,7 +11813,7 @@ export function App({
}
supervisorChatShouldFollowLatestRef.current = true;
const clientTurnId = createAgentChatRunId('planning-v2-turn');
clearChatComposer();
setChatInput('');
setMessages((current) => [
...current,
{
@@ -11873,13 +11833,8 @@ export function App({
const directConversationTurnId = directCodexProductRuntime
? createDirectCodexConversationTurnId()
: undefined;
const directUserItem = directConversationTurnId
? chatComposerDraftToDirectCodexUserItem(
draft,
directCodexConversationMessageId(directConversationTurnId, 'user'),
)
: undefined;
clearChatComposer();
setChatInput('');
setChatReferences([]);
setMessages((current) => [
...current,
{
@@ -11901,7 +11856,6 @@ export function App({
prompt,
clientTurnId: directConversationTurnId,
references,
userItem: directUserItem,
});
}
@@ -11920,6 +11874,8 @@ export function App({
<SupervisorChatOnlyView
activeVersionId={chatActiveVersionId}
chatAgentBusy={chatAgentBusy}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets}
messagesRef={supervisorChatMessagesRef}
@@ -11963,6 +11919,8 @@ export function App({
return (
<ProjectSupervisorView
activeVersionId={chatActiveVersionId}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets}
directCodex={directCodexProductRuntime}
@@ -12157,6 +12115,8 @@ export function App({
}
cancelUiCommandConfirmation={cancelUiCommandConfirmation}
chatAgentBusy={chatAgentBusy}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets}
chatInputRef={chatInputRef}
@@ -1,102 +0,0 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import {
COMMAND_PRIORITY_HIGH,
type EditorState,
KEY_ENTER_COMMAND,
type Klass,
type LexicalNode,
} from 'lexical';
import type { ReactElement, ReactNode, Ref } from 'react';
import { useEffect } from 'react';
type RichTextInputProps = {
namespace: string;
nodes: Klass<LexicalNode>[];
initialEditorState?: EditorState | null;
contentEditable?: ReactElement<typeof ContentEditable>;
placeholder?: ReactElement;
containerClassName?: string;
containerRef?: Ref<HTMLDivElement>;
disabled?: boolean;
onChange?: (editorState: EditorState) => void;
onEnter?: () => void;
children?: ReactNode;
};
function SubmitOnEnter({ onEnter }: { onEnter?: () => void }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!onEnter) return undefined;
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) return false;
event.preventDefault();
onEnter();
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor, onEnter]);
return null;
}
function SetEditorEditable({ disabled }: { disabled: boolean }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
editor.setEditable(!disabled);
}, [disabled, editor]);
return null;
}
export default function RichTextInput({
namespace,
nodes,
initialEditorState,
contentEditable = <ContentEditable />,
placeholder,
containerClassName,
containerRef,
disabled = false,
onChange,
onEnter,
children,
}: RichTextInputProps) {
return (
<LexicalComposer
initialConfig={{
namespace,
nodes,
editorState: initialEditorState ?? undefined,
onError: (error) => {
throw error;
},
}}
>
<div
ref={containerRef}
className={containerClassName}
data-disabled={disabled ? 'true' : undefined}
>
<RichTextPlugin
contentEditable={contentEditable}
placeholder={placeholder}
ErrorBoundary={LexicalErrorBoundary}
/>
{children}
<SetEditorEditable disabled={disabled} />
<SubmitOnEnter onEnter={onEnter} />
{onChange ? <OnChangePlugin onChange={onChange} /> : null}
</div>
</LexicalComposer>
);
}
@@ -17,6 +17,27 @@ export function projectPathHasControlCharacter(value: string) {
});
}
// 失效事件只是重读提示:匹配 Windows 的普通 / verbatim 路径后,调用方仍用当前项目路径
// 读取权威清单。此比较不解析链接,也不作为文件访问授权依据。
export function projectPathsMatchForInvalidation(
eventPath: string,
activePath: string | null,
) {
if (!eventPath || !activePath) return false;
function normalize(path: string) {
if (/^\\\\\?\\UNC\\/i.test(path)) {
path = `\\\\${path.slice(8)}`;
} else if (/^\\\\\?\\[a-z]:\\/i.test(path)) {
path = path.slice(4);
}
if (/^[a-z]:[\\/]/i.test(path) || /^\\\\[^?.\\][^\\]*\\[^\\]+/.test(path)) {
return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
}
return path;
}
return normalize(eventPath) === normalize(activePath);
}
export function isSafeProjectRelativePath(value: string) {
const path = value.trim();
return (
@@ -80,6 +80,7 @@ export {
isAbsoluteProjectPath,
isSafeProjectRelativePath,
projectPathHasControlCharacter,
projectPathsMatchForInvalidation,
} from './projectPath';
export {
summarizeProjectDependencyMap,
@@ -14,6 +14,8 @@ import type {
ClientLlmModel,
ClientLlmModelCatalog,
} from '../../services/clientApi';
import { ClientAuthRequestError } from '../../services/clientApi';
import { ClientHttpTimeoutError } from '../../services/clientHttp';
import {
cachedLlmModelCatalog,
refreshLlmModelCatalog,
@@ -27,6 +29,14 @@ export type ConversationModelSelectHandle = {
/** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */
class ModelSelectionConfigError extends Error {}
function modelCatalogErrorMessage(error: unknown) {
if (error instanceof ClientHttpTimeoutError)
return '模型列表请求超时,请重试';
if (error instanceof ClientAuthRequestError && error.status)
return `模型列表加载失败(HTTP ${error.status}`;
return '模型列表加载失败';
}
export function ConversationModelSelect({
className,
disabled,
@@ -51,6 +61,7 @@ export function ConversationModelSelect({
const [busy, setBusy] = useState(!initialCatalog);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [manualRefreshBusy, setManualRefreshBusy] = useState(false);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const appliedRevisionRef = useRef<number | null>(
@@ -180,7 +191,11 @@ export function ConversationModelSelect({
);
const syncCatalog = useCallback(
async (showBusy: boolean) => {
async (showBusy: boolean, manualRefresh = false) => {
if (manualRefresh && mountedRef.current) {
setManualRefreshBusy(true);
setNotice('正在刷新模型列表');
}
const busyToken = showBusy
? ++busyTokenRef.current
: busyTokenRef.current;
@@ -195,12 +210,17 @@ export function ConversationModelSelect({
try {
let catalog: ClientLlmModelCatalog;
let usingCachedCatalog = false;
let catalogError: unknown = null;
try {
catalog = await refreshLlmModelCatalog();
} catch {
} catch (error) {
catalogError = error;
const cached = cachedLlmModelCatalog();
if (!cached) {
if (mountedRef.current) setError('模型列表加载失败');
if (mountedRef.current) {
setError(modelCatalogErrorMessage(error));
setNotice('');
}
markReady(false);
return false;
}
@@ -209,10 +229,14 @@ export function ConversationModelSelect({
}
const ready = await applyCatalog(catalog, showBusy, epochAtRequest);
if (usingCachedCatalog && mountedRef.current)
setError('模型列表加载失败');
setError(modelCatalogErrorMessage(catalogError));
if (manualRefresh && mountedRef.current) {
setNotice(usingCachedCatalog ? '' : '模型列表已刷新');
}
return ready;
} catch (error) {
if (mountedRef.current) {
setNotice('');
setError(
error instanceof ModelSelectionConfigError
? error.message
@@ -230,6 +254,7 @@ export function ConversationModelSelect({
) {
setBusy(false);
}
if (manualRefresh && mountedRef.current) setManualRefreshBusy(false);
}
},
[applyCatalog, markReady],
@@ -375,11 +400,12 @@ export function ConversationModelSelect({
type="button"
className="conversation-model-menu-refresh"
aria-label="刷新模型列表"
disabled={disabled || busy}
onClick={() => void syncCatalog(true)}
disabled={disabled || busy || manualRefreshBusy}
aria-busy={manualRefreshBusy}
onClick={() => void syncCatalog(true, true)}
>
<RefreshCcw size={13} aria-hidden="true" />
<span></span>
<span>{manualRefreshBusy ? '刷新中…' : '刷新模型列表'}</span>
</button>
</div>
) : null}
@@ -185,6 +185,7 @@ export function DesignWorkspacePanel({
const [loading, setLoading] = useState(true);
const [previewLoading, setPreviewLoading] = useState(false);
const [debugPreparing, setDebugPreparing] = useState(false);
const [designDebugEnabled, setDesignDebugEnabled] = useState(false);
const [error, setError] = useState('');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
() => new Set(),
@@ -264,6 +265,16 @@ export function DesignWorkspacePanel({
void loadWorkspace({ showLoading: true, hydrateSession: true });
}, [loadWorkspace]);
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
void invoke<boolean>('is_design_agent_debug_enabled')
.then(setDesignDebugEnabled)
.catch(() => setDesignDebugEnabled(false));
}, []);
useEffect(() => {
if (!canSubscribeTauriEvents() || !projectPath.trim()) {
return;
@@ -360,7 +371,7 @@ export function DesignWorkspacePanel({
<p>Agent </p>
</div>
<div className="design-workspace-panel__actions">
{import.meta.env.VITE_GENARRATIVE_AGC_DESIGN_DEBUG === '1' ? (
{designDebugEnabled ? (
<button
type="button"
className="design-workspace-panel__refresh"
@@ -51,7 +51,7 @@ import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft } from './resourceReferences';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
@@ -76,6 +76,8 @@ function directStatusTitle(status: string | null | undefined) {
type ProjectSupervisorViewProps = RuntimePanelProps & {
activeVersionId?: string | null;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: RefObject<ResourceReferenceInputHandle | null>;
directCodex?: boolean;
@@ -128,6 +130,8 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
export function ProjectSupervisorView({
activeVersionId = null,
chatInput,
chatReferences,
chatProjectAssets,
composerRef,
directCodex = false,
@@ -477,6 +481,8 @@ export function ProjectSupervisorView({
Boolean(designView?.session.pendingClarification)
}
rows={3}
value={chatInput}
references={chatReferences}
showTriggerButton={!directCodex}
placeholder={
directCodex

Some files were not shown because too many files have changed in this diff Show More