Merge branch 'master' into feat/agc-use-ui-design
This commit is contained in:
+1
@@ -1743,6 +1743,7 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"ts-rs",
|
||||
"ttf-parser",
|
||||
"typed_floats",
|
||||
|
||||
@@ -50,6 +50,7 @@ tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tempfile = "3"
|
||||
toml = "0.8"
|
||||
ttf-parser = "0.25.1"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
url = "2"
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock, Weak};
|
||||
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
use tokio::sync::{mpsc, oneshot, Mutex, Notify};
|
||||
|
||||
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";
|
||||
@@ -28,6 +28,7 @@ const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000;
|
||||
const DIRECT_PROJECT_IDLE_TIMEOUT_MS: u64 = 15 * 60 * 1_000;
|
||||
const DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000;
|
||||
const DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS: u64 = 120 * 60 * 1_000;
|
||||
const DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS: u64 = 120_000;
|
||||
const DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration =
|
||||
std::time::Duration::from_millis(250);
|
||||
const DIRECT_CODEX_SHELL_ENVIRONMENT_POLICY: &str = "shell_environment_policy.inherit=\"core\"";
|
||||
@@ -526,6 +527,55 @@ fn should_emit_direct_codex_activity(
|
||||
true
|
||||
}
|
||||
|
||||
fn is_terminal_client_mcp_startup_status(status: Option<&str>) -> bool {
|
||||
matches!(status, Some("ready") | Some("failed") | Some("cancelled"))
|
||||
}
|
||||
|
||||
async fn wait_for_client_mcp_startup_gate(
|
||||
client_mcp_server_ids_by_name: &HashMap<String, String>,
|
||||
client_mcp_startup_statuses: &Mutex<HashMap<String, String>>,
|
||||
client_mcp_startup_notify: &Notify,
|
||||
grace: std::time::Duration,
|
||||
) {
|
||||
let deadline = tokio::time::Instant::now() + grace;
|
||||
loop {
|
||||
// Register before reading the shared state. `notify_waiters` does not retain a
|
||||
// permit for a future created after the notification, so this ordering is part
|
||||
// of the readiness gate's correctness contract.
|
||||
let notified = client_mcp_startup_notify.notified();
|
||||
tokio::pin!(notified);
|
||||
|
||||
let pending = {
|
||||
let statuses = client_mcp_startup_statuses.lock().await;
|
||||
client_mcp_server_ids_by_name.keys().any(|name| {
|
||||
!is_terminal_client_mcp_startup_status(statuses.get(name).map(String::as_str))
|
||||
})
|
||||
};
|
||||
if !pending {
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
eprintln!(
|
||||
"agent.direct_codex.client_mcp_startup_wait timed out after {}ms",
|
||||
grace.as_millis()
|
||||
);
|
||||
return;
|
||||
}
|
||||
tokio::select! {
|
||||
_ = &mut notified => {}
|
||||
_ = tokio::time::sleep(remaining) => {
|
||||
eprintln!(
|
||||
"agent.direct_codex.client_mcp_startup_wait timed out after {}ms",
|
||||
grace.as_millis()
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn game_creator_codex_app_server_idle_timeout_ms(
|
||||
workspace_mode: CodexAppServerWorkspaceMode,
|
||||
request_timeout_ms: u64,
|
||||
@@ -568,9 +618,14 @@ struct CodexAppServerInner {
|
||||
_working_dir: tempfile::TempDir,
|
||||
workspace_path: std::path::PathBuf,
|
||||
workspace_mode: CodexAppServerWorkspaceMode,
|
||||
client_mcp_server_ids_by_name: HashMap<String, String>,
|
||||
client_mcp_connection_id: Option<String>,
|
||||
client_mcp_startup_statuses: Mutex<HashMap<String, String>>,
|
||||
client_mcp_startup_notify: Notify,
|
||||
initial_client_mcp_startup_waited: AtomicBool,
|
||||
_provider_proxy: Option<CodexProviderProxy>,
|
||||
tool_bridge: Option<DirectToolBridge>,
|
||||
_skill_root: Option<std::path::PathBuf>,
|
||||
_skill_roots: Option<Vec<std::path::PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -615,6 +670,18 @@ fn game_creator_codex_app_server_pool_key(
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
};
|
||||
let client_skill_identity = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
crate::client_extensions::enabled_client_skill_fingerprint()
|
||||
.unwrap_or_else(|_| "invalid-client-skills".to_string())
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
};
|
||||
let client_mcp_identity = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
crate::client_extensions::enabled_client_mcp_fingerprint()
|
||||
.unwrap_or_else(|_| "invalid-client-mcp".to_string())
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
};
|
||||
let stable = serde_json::json!({
|
||||
"credentialFingerprint": credential_fingerprint,
|
||||
"baseUrl": llm.base_url,
|
||||
@@ -628,6 +695,8 @@ fn game_creator_codex_app_server_pool_key(
|
||||
},
|
||||
"workspaceMode": workspace_mode.pool_identity(),
|
||||
"skillPackIdentity": skill_pack_identity,
|
||||
"clientSkillIdentity": client_skill_identity,
|
||||
"clientMcpIdentity": client_mcp_identity,
|
||||
"controlledWebSearch": llm.web_search_enabled,
|
||||
"directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" },
|
||||
"providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" },
|
||||
@@ -1108,17 +1177,20 @@ fn configure_game_creator_codex_app_server_command_for_mode(
|
||||
let controlled_web_search =
|
||||
workspace_mode == CodexAppServerWorkspaceMode::DirectProject && llm.web_search_enabled;
|
||||
command.arg("app-server").arg("--stdio");
|
||||
command
|
||||
.arg("-c")
|
||||
.arg("mcp_servers={}")
|
||||
.arg("-c")
|
||||
.arg("web_search=\"disabled\"");
|
||||
if workspace_mode != CodexAppServerWorkspaceMode::DirectProject {
|
||||
command.arg("-c").arg("mcp_servers={}");
|
||||
}
|
||||
command.arg("-c").arg("web_search=\"disabled\"");
|
||||
if workspace_mode != CodexAppServerWorkspaceMode::DirectProject {
|
||||
command.arg("-c").arg("agents.enabled=false");
|
||||
}
|
||||
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
let current_executable = direct_tools_mcp_executable_path()?;
|
||||
command
|
||||
.arg("-c")
|
||||
.arg(format!(
|
||||
"mcp_optional_startup_grace_ms={DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS}"
|
||||
))
|
||||
.arg("-c")
|
||||
.arg(format!(
|
||||
"mcp_servers.agc_tools.command={}",
|
||||
@@ -1306,14 +1378,41 @@ fn prepare_isolated_game_creator_codex_home(
|
||||
fn trust_isolated_game_creator_codex_workspace(
|
||||
codex_home: &std::path::Path,
|
||||
workspace: &std::path::Path,
|
||||
client_mcp_servers: &[crate::client_extensions::ClientMcpRuntimeServer],
|
||||
) -> Result<(), platform_llm::LlmError> {
|
||||
let workspace = workspace.to_string_lossy();
|
||||
let quoted_workspace = quoted_toml_string(&workspace)?;
|
||||
let config = format!("[projects.{quoted_workspace}]\ntrust_level = \"trusted\"\n");
|
||||
std::fs::write(codex_home.join("config.toml"), config).map_err(|error| {
|
||||
platform_llm::LlmError::Transport(format!(
|
||||
"写入隔离 Codex app-server 项目信任配置失败:{error}"
|
||||
let mut project = toml::map::Map::new();
|
||||
project.insert(
|
||||
"trust_level".to_string(),
|
||||
toml::Value::String("trusted".to_string()),
|
||||
);
|
||||
let mut projects = toml::map::Map::new();
|
||||
projects.insert(
|
||||
workspace.to_string_lossy().into_owned(),
|
||||
toml::Value::Table(project),
|
||||
);
|
||||
let mut root = toml::map::Map::new();
|
||||
root.insert("projects".to_string(), toml::Value::Table(projects));
|
||||
root.insert(
|
||||
"mcp_optional_startup_grace_ms".to_string(),
|
||||
toml::Value::Integer(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS as i64),
|
||||
);
|
||||
if !client_mcp_servers.is_empty() {
|
||||
let mut mcp_servers = toml::map::Map::new();
|
||||
for server in client_mcp_servers {
|
||||
mcp_servers.insert(
|
||||
server.name.clone(),
|
||||
toml::Value::Table(server.config.clone().into_iter().collect()),
|
||||
);
|
||||
}
|
||||
root.insert("mcp_servers".to_string(), toml::Value::Table(mcp_servers));
|
||||
}
|
||||
let config = toml::to_string(&toml::Value::Table(root)).map_err(|error| {
|
||||
platform_llm::LlmError::InvalidConfig(format!(
|
||||
"序列化隔离 Codex app-server 配置失败:{error}"
|
||||
))
|
||||
})?;
|
||||
std::fs::write(codex_home.join("config.toml"), config).map_err(|error| {
|
||||
platform_llm::LlmError::Transport(format!("写入隔离 Codex app-server 配置失败:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1496,8 +1595,25 @@ impl CodexAppServerConnection {
|
||||
.unwrap_or_else(|| isolated_workspace.clone()),
|
||||
)
|
||||
};
|
||||
let client_mcp_servers = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
crate::client_extensions::prepare_enabled_client_mcp_servers()
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let client_mcp_server_ids_by_name = client_mcp_servers
|
||||
.iter()
|
||||
.map(|server| (server.name.clone(), server.extension_id.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let client_mcp_connection_id = (!client_mcp_servers.is_empty()
|
||||
&& workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
.then(|| uuid::Uuid::new_v4().simple().to_string());
|
||||
if workspace_override.is_some() && workspace_mode.allows_workspace_writes() {
|
||||
trust_isolated_game_creator_codex_workspace(&isolated_codex_home, &workspace_path)?;
|
||||
trust_isolated_game_creator_codex_workspace(
|
||||
&isolated_codex_home,
|
||||
&workspace_path,
|
||||
&client_mcp_servers,
|
||||
)?;
|
||||
}
|
||||
let isolated_os_home = working_dir.path().join("home");
|
||||
let isolated_app_data = isolated_os_home.join("appdata");
|
||||
@@ -1513,10 +1629,18 @@ impl CodexAppServerConnection {
|
||||
))
|
||||
})?;
|
||||
}
|
||||
let skill_root = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
let skill_roots = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
install_agc_skill_pack(&isolated_os_home)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
Some(isolated_os_home.join(".agents").join("skills"))
|
||||
let bundled_root = isolated_os_home.join(".agents").join("skills");
|
||||
let mut roots = vec![bundled_root];
|
||||
if let Some(client_root) =
|
||||
crate::client_extensions::prepare_enabled_client_skill_root(&isolated_os_home)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?
|
||||
{
|
||||
roots.push(client_root);
|
||||
}
|
||||
Some(roots)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1630,10 +1754,27 @@ impl CodexAppServerConnection {
|
||||
_working_dir: working_dir,
|
||||
workspace_path,
|
||||
workspace_mode,
|
||||
client_mcp_server_ids_by_name,
|
||||
client_mcp_connection_id: client_mcp_connection_id.clone(),
|
||||
client_mcp_startup_statuses: Mutex::new(HashMap::new()),
|
||||
client_mcp_startup_notify: Notify::new(),
|
||||
initial_client_mcp_startup_waited: AtomicBool::new(false),
|
||||
_provider_proxy: provider_proxy,
|
||||
tool_bridge,
|
||||
_skill_root: skill_root,
|
||||
_skill_roots: skill_roots,
|
||||
});
|
||||
if let Some(connection_id) = client_mcp_connection_id.as_deref() {
|
||||
if let Err(error) = crate::client_extensions::claim_client_mcp_connection(
|
||||
client_mcp_servers
|
||||
.iter()
|
||||
.map(|server| server.extension_id.clone()),
|
||||
connection_id,
|
||||
) {
|
||||
shutdown_game_creator_codex_app_server_inner(&inner, "客户端 MCP 连接登记失败")
|
||||
.await;
|
||||
return Err(platform_llm::LlmError::InvalidConfig(error));
|
||||
}
|
||||
}
|
||||
tokio::spawn(read_game_creator_codex_app_server_stdout(
|
||||
Arc::downgrade(&inner),
|
||||
stdout,
|
||||
@@ -1664,11 +1805,11 @@ impl CodexAppServerConnection {
|
||||
if let Some(reason) = remote_control_disable_reason {
|
||||
eprintln!("agent.codex_app_server.remote_control disabled reason={reason}");
|
||||
}
|
||||
if let Some(skill_root) = connection.inner._skill_root.as_ref() {
|
||||
if let Some(skill_roots) = connection.inner._skill_roots.as_ref() {
|
||||
connection
|
||||
.request(
|
||||
"skills/extraRoots/set",
|
||||
serde_json::json!({ "extraRoots": [skill_root] }),
|
||||
serde_json::json!({ "extraRoots": skill_roots }),
|
||||
)
|
||||
.await
|
||||
.map_err(platform_llm::LlmError::Transport)?;
|
||||
@@ -1706,6 +1847,26 @@ impl CodexAppServerConnection {
|
||||
.map_err(|error| format!("刷新 Codex app-server stdin 失败:{error}"))
|
||||
}
|
||||
|
||||
async fn wait_for_initial_client_mcp_startup(&self) {
|
||||
if self.inner.workspace_mode != CodexAppServerWorkspaceMode::DirectProject
|
||||
|| self.inner.client_mcp_server_ids_by_name.is_empty()
|
||||
|| self
|
||||
.inner
|
||||
.initial_client_mcp_startup_waited
|
||||
.swap(true, Ordering::AcqRel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
wait_for_client_mcp_startup_gate(
|
||||
&self.inner.client_mcp_server_ids_by_name,
|
||||
&self.inner.client_mcp_startup_statuses,
|
||||
&self.inner.client_mcp_startup_notify,
|
||||
std::time::Duration::from_millis(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn notify(&self, method: &str, params: serde_json::Value) -> Result<(), String> {
|
||||
self.write_message(&serde_json::json!({
|
||||
"method": method,
|
||||
@@ -1927,6 +2088,7 @@ impl CodexAppServerConnection {
|
||||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||||
let _turn_guard = self.inner.turn_gate.lock().await;
|
||||
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
|
||||
self.wait_for_initial_client_mcp_startup().await;
|
||||
let thread_id = thread_lease.thread_id.clone();
|
||||
let prompt = if self.inner.workspace_mode.uses_direct_conversation() {
|
||||
direct_codex_user_prompt(&request)
|
||||
@@ -2510,6 +2672,37 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
.get("method")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if method == "mcpServer/startupStatus/updated"
|
||||
&& inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
|
||||
{
|
||||
if let (Some(name), Some(status)) = (
|
||||
message
|
||||
.pointer("/params/name")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
message
|
||||
.pointer("/params/status")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
) {
|
||||
if inner.client_mcp_server_ids_by_name.contains_key(name) {
|
||||
inner
|
||||
.client_mcp_startup_statuses
|
||||
.lock()
|
||||
.await
|
||||
.insert(name.to_string(), status.to_string());
|
||||
inner.client_mcp_startup_notify.notify_waiters();
|
||||
}
|
||||
if let Some(extension_id) = inner.client_mcp_server_ids_by_name.get(name) {
|
||||
if let Some(connection_id) = inner.client_mcp_connection_id.as_deref() {
|
||||
let _ = crate::client_extensions::record_client_mcp_startup_status(
|
||||
extension_id,
|
||||
connection_id,
|
||||
status,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let safe_activity = direct_codex_safe_activity_for_notification(method);
|
||||
if !matches!(
|
||||
method,
|
||||
@@ -2750,6 +2943,7 @@ async fn fail_game_creator_codex_app_server_connection(
|
||||
if inner.closed.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
release_client_mcp_connection_for_inner(&inner);
|
||||
let exit_status = inner
|
||||
.child
|
||||
.lock()
|
||||
@@ -2776,6 +2970,7 @@ async fn shutdown_game_creator_codex_app_server_inner(
|
||||
reason: &str,
|
||||
) {
|
||||
inner.closed.store(true, Ordering::Release);
|
||||
release_client_mcp_connection_for_inner(inner);
|
||||
for (_, pending) in inner.pending.lock().await.drain() {
|
||||
let _ = pending.sender.send(Err(reason.to_string()));
|
||||
}
|
||||
@@ -2789,6 +2984,16 @@ async fn shutdown_game_creator_codex_app_server_inner(
|
||||
}
|
||||
}
|
||||
|
||||
fn release_client_mcp_connection_for_inner(inner: &Arc<CodexAppServerInner>) {
|
||||
let Some(connection_id) = inner.client_mcp_connection_id.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let _ = crate::client_extensions::release_client_mcp_connection(
|
||||
inner.client_mcp_server_ids_by_name.values().cloned(),
|
||||
connection_id,
|
||||
);
|
||||
}
|
||||
|
||||
pub(in crate::agent) async fn request_game_creator_agent_codex_app_server(
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
@@ -3190,6 +3395,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_mcp_startup_terminal_statuses_are_recognized() {
|
||||
for status in ["ready", "failed", "cancelled"] {
|
||||
assert!(is_terminal_client_mcp_startup_status(Some(status)));
|
||||
}
|
||||
for status in [None, Some("starting"), Some("unknown")] {
|
||||
assert!(!is_terminal_client_mcp_startup_status(status));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_mcp_startup_gate_rechecks_after_status_notification() {
|
||||
let server_ids_by_name = Arc::new(HashMap::from([(
|
||||
"search".to_string(),
|
||||
"extension-search".to_string(),
|
||||
)]));
|
||||
let statuses = Arc::new(Mutex::new(HashMap::new()));
|
||||
let notify = Arc::new(Notify::new());
|
||||
let wait_server_ids_by_name = Arc::clone(&server_ids_by_name);
|
||||
let wait_statuses = Arc::clone(&statuses);
|
||||
let wait_notify = Arc::clone(¬ify);
|
||||
let waiter = tokio::spawn(async move {
|
||||
wait_for_client_mcp_startup_gate(
|
||||
&wait_server_ids_by_name,
|
||||
&wait_statuses,
|
||||
&wait_notify,
|
||||
std::time::Duration::from_millis(250),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
statuses
|
||||
.lock()
|
||||
.await
|
||||
.insert("search".to_string(), "ready".to_string());
|
||||
notify.notify_waiters();
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), waiter)
|
||||
.await
|
||||
.expect("startup gate should wake after terminal status")
|
||||
.expect("startup gate task should not panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_home_mode_is_read_only_and_rejects_non_passive_items() {
|
||||
assert!(CodexAppServerWorkspaceMode::DirectHome.uses_direct_conversation());
|
||||
@@ -3690,6 +3939,46 @@ mod tests {
|
||||
assert!(joined.contains("mcp_servers.agc_tools.command="));
|
||||
assert!(joined.contains(DIRECT_TOOLS_MCP_MODE_FLAG));
|
||||
assert!(joined.contains("mcp_servers.agc_tools.required=true"));
|
||||
assert!(joined.contains("mcp_optional_startup_grace_ms=120000"));
|
||||
assert!(!joined.contains("mcp_servers={}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_direct_project_config_contains_client_mcp_without_agc_tools() {
|
||||
let directory = tempfile::tempdir().expect("temporary config home");
|
||||
let workspace = directory.path().join("workspace");
|
||||
std::fs::create_dir(&workspace).expect("create workspace");
|
||||
let client_mcp = crate::client_extensions::ClientMcpRuntimeServer {
|
||||
extension_id: "extension-1".to_string(),
|
||||
name: "plugin-search".to_string(),
|
||||
config: std::collections::BTreeMap::from([
|
||||
(
|
||||
"command".to_string(),
|
||||
toml::Value::String("fixture-search".to_string()),
|
||||
),
|
||||
("required".to_string(), toml::Value::Boolean(false)),
|
||||
]),
|
||||
};
|
||||
trust_isolated_game_creator_codex_workspace(directory.path(), &workspace, &[client_mcp])
|
||||
.expect("write isolated config");
|
||||
let config = std::fs::read_to_string(directory.path().join("config.toml"))
|
||||
.expect("read isolated config");
|
||||
let parsed = config
|
||||
.parse::<toml::Value>()
|
||||
.expect("parse isolated config");
|
||||
assert_eq!(
|
||||
parsed["mcp_servers"]["plugin-search"]["command"].as_str(),
|
||||
Some("fixture-search")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["mcp_servers"]["plugin-search"]["required"].as_bool(),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["mcp_optional_startup_grace_ms"].as_integer(),
|
||||
Some(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS as i64)
|
||||
);
|
||||
assert!(parsed["mcp_servers"].get("agc_tools").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,7 +10,7 @@ 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;
|
||||
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
|
||||
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
|
||||
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
|
||||
const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png";
|
||||
const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png";
|
||||
const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png";
|
||||
@@ -4362,6 +4362,8 @@ mod tests {
|
||||
assert!(prompt.contains("不要等待 Supervisor"));
|
||||
assert!(prompt.contains("提示词与技能"));
|
||||
assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)"));
|
||||
assert!(prompt.contains("客户端扩展列表中用户已启用的第三方 MCP"));
|
||||
assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时"));
|
||||
assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`"));
|
||||
assert!(prompt.contains("agc_write_file"));
|
||||
assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -230,6 +230,7 @@ mod agent_native_tools;
|
||||
mod assets;
|
||||
mod browser;
|
||||
mod cli;
|
||||
mod client_extensions;
|
||||
mod collaboration;
|
||||
mod command_exec;
|
||||
mod command_output;
|
||||
@@ -268,6 +269,7 @@ use agent_native_tools::*;
|
||||
use assets::*;
|
||||
use browser::*;
|
||||
use cli::*;
|
||||
use client_extensions::*;
|
||||
use collaboration::*;
|
||||
use command_exec::*;
|
||||
use command_output::*;
|
||||
@@ -2404,6 +2406,13 @@ fn main() {
|
||||
inspect_local_project_directory,
|
||||
pick_local_project_directory,
|
||||
pick_local_file,
|
||||
pick_client_extension_file,
|
||||
pick_client_extension_directory,
|
||||
list_client_extensions,
|
||||
import_client_extension,
|
||||
set_client_extension_enabled,
|
||||
rename_client_extension,
|
||||
remove_client_extension,
|
||||
open_local_project_directory,
|
||||
open_local_project_plan_gdd_markdown,
|
||||
control_agent_run,
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# DirectProject 扩展导入阶段 0 样例
|
||||
|
||||
本目录只保存导入分类和命名契约使用的最小样例,不作为运行时扩展安装目录。阶段 0 不启动其中的 MCP,也不执行 Skill 中的脚本。
|
||||
|
||||
## 样例与预期
|
||||
|
||||
| 样例 | 预期拆分 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| `single-skill/` | 1 个 Skill:`single-skill` | 根目录 `SKILL.md` |
|
||||
| `multi-skill/` | 2 个 Skill:`art-skill`、`code-skill` | 一个 Skill root,分别导入 |
|
||||
| `mcp-config/` | 2 个 MCP:`search`、`filesystem` | 原生 `config.toml` |
|
||||
| `mcp-json/` | 2 个 MCP:`search`、`filesystem` | 兼容 `.mcp.json` |
|
||||
| `plugin/` | 1 个 Skill:`plugin-skill`、1 个 MCP:`plugin-search` | Plugin 只作为导入来源 |
|
||||
| `mixed-source/` | 1 个 Skill:`mixed-skill`、2 个 MCP:`search`、`filesystem` | 一个来源拆成多个独立项 |
|
||||
| `unknown/` | 1 个未知项:`unknown.bin` | 不执行、不作为 MCP 入口 |
|
||||
|
||||
`mixed-source/` 表示目录或 zip 解压后的标准内容。实际 zip 测试可以在测试运行时将该目录压缩为临时归档,不把生成的二进制 zip 提交到仓库。
|
||||
|
||||
## 阶段 0 固定规则
|
||||
|
||||
- 一个来源里的每个 Skill 和每个 MCP Server 都是独立扩展项。
|
||||
- 导入后已识别项默认启用;未知项保留但不可启动。
|
||||
- 同名或重复内容再次导入时保留新项,名称使用原生标识追加 `-2`、`-3`。
|
||||
- 前端列表名称和 Codex 运行时名称相同,不维护两套名称。
|
||||
- 单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。
|
||||
- Plugin 只提取支持的 Skill/MCP,不开启 hooks、apps 或完整 Plugin Runtime。
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"schemaVersion": "direct-project-extension-fixture.v1",
|
||||
"cases": [
|
||||
{
|
||||
"id": "single-skill",
|
||||
"source": "single-skill",
|
||||
"items": [
|
||||
{ "type": "skill", "name": "single-skill" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "multi-skill",
|
||||
"source": "multi-skill",
|
||||
"items": [
|
||||
{ "type": "skill", "name": "art-skill" },
|
||||
{ "type": "skill", "name": "code-skill" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mcp-config",
|
||||
"source": "mcp-config/config.toml",
|
||||
"items": [
|
||||
{ "type": "mcp", "name": "search" },
|
||||
{ "type": "mcp", "name": "filesystem" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mcp-json",
|
||||
"source": "mcp-json/.mcp.json",
|
||||
"items": [
|
||||
{ "type": "mcp", "name": "search" },
|
||||
{ "type": "mcp", "name": "filesystem" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "plugin",
|
||||
"source": "plugin",
|
||||
"items": [
|
||||
{ "type": "skill", "name": "plugin-skill" },
|
||||
{ "type": "mcp", "name": "plugin-search" }
|
||||
],
|
||||
"ignoredCapabilities": ["hooks", "apps", "remote_plugin"]
|
||||
},
|
||||
{
|
||||
"id": "mixed-source",
|
||||
"source": "mixed-source",
|
||||
"items": [
|
||||
{ "type": "skill", "name": "mixed-skill" },
|
||||
{ "type": "mcp", "name": "search" },
|
||||
{ "type": "mcp", "name": "filesystem" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unknown",
|
||||
"source": "unknown/unknown.bin",
|
||||
"items": [
|
||||
{ "type": "unknown", "name": "unknown.bin", "launchable": false }
|
||||
]
|
||||
}
|
||||
],
|
||||
"duplicateImport": {
|
||||
"source": "single-skill",
|
||||
"items": [
|
||||
{ "type": "skill", "name": "single-skill-2" }
|
||||
],
|
||||
"preserveOriginal": true
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
[mcp_servers.search]
|
||||
command = "fixture-search"
|
||||
|
||||
[mcp_servers.filesystem]
|
||||
command = "fixture-filesystem"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"search": {
|
||||
"command": "fixture-search"
|
||||
},
|
||||
"filesystem": {
|
||||
"command": "fixture-filesystem"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
[mcp_servers.search]
|
||||
command = "fixture-search"
|
||||
|
||||
[mcp_servers.filesystem]
|
||||
command = "fixture-filesystem"
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: mixed-skill
|
||||
description: Stage 0 fixture for a source containing independent Skill and MCP entries.
|
||||
---
|
||||
|
||||
# Mixed Source Skill Fixture
|
||||
|
||||
This fixture is used only to verify independent import items.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: art-skill
|
||||
description: Stage 0 fixture for an imported art Skill.
|
||||
---
|
||||
|
||||
# Art Skill Fixture
|
||||
|
||||
This fixture is used only to verify that one Skill root becomes independent items.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: code-skill
|
||||
description: Stage 0 fixture for an imported code Skill.
|
||||
---
|
||||
|
||||
# Code Skill Fixture
|
||||
|
||||
This fixture is used only to verify that one Skill root becomes independent items.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "stage-0-fixture-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "Fixture for extracting standard Skill content from a Plugin source.",
|
||||
"skills": "./skills/"
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"plugin-search": {
|
||||
"type": "stdio",
|
||||
"command": "fixture-plugin-search"
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: plugin-skill
|
||||
description: Stage 0 fixture for a Skill contained in a Plugin.
|
||||
---
|
||||
|
||||
# Plugin Skill Fixture
|
||||
|
||||
This fixture is used only to verify Plugin source extraction.
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: single-skill
|
||||
description: Stage 0 fixture for a single imported Skill.
|
||||
---
|
||||
|
||||
# Single Skill Fixture
|
||||
|
||||
This fixture is used only to verify discovery and naming.
|
||||
+1
@@ -0,0 +1 @@
|
||||
This is an inert unknown-file fixture. It must never be executed as an MCP server.
|
||||
@@ -51,6 +51,27 @@ export type LauncherImportedAttachment = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type ClientExtensionType = 'skill' | 'mcp' | 'unknown';
|
||||
|
||||
export type ClientExtensionItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
originalName: string;
|
||||
extensionType: ClientExtensionType;
|
||||
sourceName: string;
|
||||
sourceRelativePath: string;
|
||||
enabled: boolean;
|
||||
status: 'enabled' | 'disabled' | 'unknown' | 'startup-failed';
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type ClientExtensionImportResult = {
|
||||
imported: ClientExtensionItem[];
|
||||
sourceName: string;
|
||||
renamed: boolean;
|
||||
duplicate: boolean;
|
||||
};
|
||||
|
||||
export type LocalProjectKind = 'web' | 'godot';
|
||||
|
||||
export type ProjectStartMode = 'planning' | 'direct-build';
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
CircleAlert,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type FormEvent, useEffect, useRef, useState } from 'react';
|
||||
@@ -23,6 +26,8 @@ import {
|
||||
} from '../../app/dialogs';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import {
|
||||
type ClientExtensionImportResult,
|
||||
type ClientExtensionItem,
|
||||
type GameCreatorAgentLlmConfig,
|
||||
type GameCreatorAppConfig,
|
||||
type GameCreatorAppConfigView,
|
||||
@@ -87,6 +92,7 @@ type RuntimeSettingsSection =
|
||||
| 'general'
|
||||
| 'agents'
|
||||
| 'connections'
|
||||
| 'extensions'
|
||||
| 'advanced'
|
||||
| 'about';
|
||||
|
||||
@@ -95,6 +101,8 @@ type RuntimeConfigToast = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ClientExtensionsLoadState = 'loading' | 'ready' | 'error';
|
||||
|
||||
const runtimeSettingsSections = [
|
||||
{
|
||||
id: 'general',
|
||||
@@ -114,6 +122,12 @@ const runtimeSettingsSections = [
|
||||
description: '外部服务',
|
||||
icon: Cable,
|
||||
},
|
||||
{
|
||||
id: 'extensions',
|
||||
label: '扩展',
|
||||
description: 'Skill 与 MCP',
|
||||
icon: Upload,
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: '高级参数',
|
||||
@@ -384,6 +398,17 @@ export function RuntimeConfigDialog({
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<RuntimeSettingsSection>('general');
|
||||
const [expandedAgentIds, setExpandedAgentIds] = useState<string[]>([]);
|
||||
const [clientExtensions, setClientExtensions] = useState<
|
||||
ClientExtensionItem[]
|
||||
>([]);
|
||||
const [clientExtensionsBusy, setClientExtensionsBusy] = useState(false);
|
||||
const [clientExtensionsLoadState, setClientExtensionsLoadState] =
|
||||
useState<ClientExtensionsLoadState>('loading');
|
||||
const [clientExtensionsStatus, setClientExtensionsStatus] = useState('');
|
||||
const [editingExtensionId, setEditingExtensionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editingExtensionName, setEditingExtensionName] = useState('');
|
||||
const [appUpdateStatus, setAppUpdateStatus] = useState('');
|
||||
const [appUpdateChecking, setAppUpdateChecking] = useState(false);
|
||||
const runtimeConfigBusyRef = useRef(false);
|
||||
@@ -404,10 +429,176 @@ export function RuntimeConfigDialog({
|
||||
|
||||
useEffect(() => {
|
||||
void readRuntimeConfig();
|
||||
void readClientExtensions();
|
||||
// The dialog reads once on mount; subsequent reads are explicit user actions.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function readClientExtensions() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setClientExtensionsLoadState('error');
|
||||
setClientExtensionsStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
setClientExtensionsLoadState('loading');
|
||||
setClientExtensionsBusy(true);
|
||||
try {
|
||||
const result = await invoke<ClientExtensionItem[]>(
|
||||
'list_client_extensions',
|
||||
);
|
||||
setClientExtensions(result);
|
||||
setClientExtensionsLoadState('ready');
|
||||
setClientExtensionsStatus('');
|
||||
} catch (error) {
|
||||
setClientExtensionsLoadState('error');
|
||||
setClientExtensionsStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setClientExtensionsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function importClientExtension(kind: 'file' | 'directory') {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setClientExtensionsStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
if (clientExtensionsBusy) {
|
||||
return;
|
||||
}
|
||||
setClientExtensionsBusy(true);
|
||||
setClientExtensionsStatus('正在导入');
|
||||
try {
|
||||
const selectedPath =
|
||||
kind === 'file'
|
||||
? await invoke<string | null>('pick_client_extension_file')
|
||||
: await invoke<string | null>('pick_client_extension_directory');
|
||||
if (!selectedPath) {
|
||||
setClientExtensionsStatus('');
|
||||
return;
|
||||
}
|
||||
const result = await invoke<ClientExtensionImportResult>(
|
||||
'import_client_extension',
|
||||
{ sourcePath: selectedPath },
|
||||
);
|
||||
const refreshed = await invoke<ClientExtensionItem[]>(
|
||||
'list_client_extensions',
|
||||
);
|
||||
setClientExtensions(refreshed);
|
||||
setClientExtensionsLoadState('ready');
|
||||
const importedCount = result.imported.length;
|
||||
setClientExtensionsStatus(
|
||||
importedCount > 0
|
||||
? `已导入 ${importedCount} 个扩展${result.renamed ? ',同名项已自动追加编号' : ''}`
|
||||
: '未发现可管理的扩展',
|
||||
);
|
||||
} catch (error) {
|
||||
setClientExtensionsStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setClientExtensionsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function beginRenameClientExtension(item: ClientExtensionItem) {
|
||||
setEditingExtensionId(item.id);
|
||||
setEditingExtensionName(item.name);
|
||||
setClientExtensionsStatus('');
|
||||
}
|
||||
|
||||
function cancelRenameClientExtension() {
|
||||
setEditingExtensionId(null);
|
||||
setEditingExtensionName('');
|
||||
}
|
||||
|
||||
async function saveClientExtensionName(item: ClientExtensionItem) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || clientExtensionsBusy) {
|
||||
return;
|
||||
}
|
||||
setClientExtensionsBusy(true);
|
||||
try {
|
||||
const updated = await invoke<ClientExtensionItem>(
|
||||
'rename_client_extension',
|
||||
{ id: item.id, name: editingExtensionName },
|
||||
);
|
||||
setClientExtensions((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === updated.id ? updated : candidate,
|
||||
),
|
||||
);
|
||||
const requestedName = editingExtensionName.trim();
|
||||
setClientExtensionsStatus(
|
||||
updated.name === requestedName
|
||||
? '扩展名称已更新'
|
||||
: `扩展名称已调整为“${updated.name}”`,
|
||||
);
|
||||
cancelRenameClientExtension();
|
||||
} catch (error) {
|
||||
setClientExtensionsStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setClientExtensionsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setClientExtensionEnabled(
|
||||
item: ClientExtensionItem,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || clientExtensionsBusy || item.extensionType === 'unknown') {
|
||||
return;
|
||||
}
|
||||
setClientExtensionsBusy(true);
|
||||
try {
|
||||
const updated = await invoke<ClientExtensionItem>(
|
||||
'set_client_extension_enabled',
|
||||
{ id: item.id, enabled },
|
||||
);
|
||||
setClientExtensions((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === updated.id ? updated : candidate,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setClientExtensionsStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setClientExtensionsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClientExtension(item: ClientExtensionItem) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || clientExtensionsBusy) {
|
||||
return;
|
||||
}
|
||||
setClientExtensionsBusy(true);
|
||||
try {
|
||||
await invoke('remove_client_extension', { id: item.id });
|
||||
setClientExtensions((current) =>
|
||||
current.filter((candidate) => candidate.id !== item.id),
|
||||
);
|
||||
if (editingExtensionId === item.id) {
|
||||
cancelRenameClientExtension();
|
||||
}
|
||||
setClientExtensionsStatus('扩展已移除');
|
||||
} catch (error) {
|
||||
setClientExtensionsStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setClientExtensionsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRuntimeLlmConfig<K extends keyof GameCreatorAppConfig['llm']>(
|
||||
key: K,
|
||||
value: GameCreatorAppConfig['llm'][K],
|
||||
@@ -711,6 +902,25 @@ export function RuntimeConfigDialog({
|
||||
</div>
|
||||
{activeSection === 'agents' ? (
|
||||
<span>{configuredAgentCount} 个角色已覆盖</span>
|
||||
) : activeSection === 'extensions' ? (
|
||||
<div className="runtime-settings-section-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() => void importClientExtension('file')}
|
||||
>
|
||||
<Upload size={14} aria-hidden="true" />
|
||||
导入文件或 zip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() => void importClientExtension('directory')}
|
||||
>
|
||||
<Upload size={14} aria-hidden="true" />
|
||||
导入目录
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="settings-grid runtime-settings-fields">
|
||||
@@ -1293,6 +1503,159 @@ export function RuntimeConfigDialog({
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
{activeSection === 'extensions' ? (
|
||||
<section
|
||||
className="runtime-settings-section runtime-settings-extensions"
|
||||
aria-label="客户端扩展"
|
||||
>
|
||||
{clientExtensionsStatus &&
|
||||
clientExtensionsLoadState !== 'error' ? (
|
||||
<p className="runtime-settings-inline-status" role="status">
|
||||
{clientExtensionsStatus}
|
||||
</p>
|
||||
) : null}
|
||||
{clientExtensionsLoadState === 'loading' ? (
|
||||
<div className="runtime-settings-empty-state" role="status">
|
||||
<strong>正在加载扩展</strong>
|
||||
<span>正在读取已导入的 Skill 和 MCP。</span>
|
||||
</div>
|
||||
) : clientExtensionsLoadState === 'error' ? (
|
||||
<div className="runtime-settings-empty-state" role="alert">
|
||||
<strong>扩展列表加载失败</strong>
|
||||
<span>
|
||||
{clientExtensionsStatus || '暂时无法读取扩展列表。'}
|
||||
</span>
|
||||
</div>
|
||||
) : clientExtensions.length > 0 ? (
|
||||
<div className="runtime-settings-extension-list">
|
||||
{clientExtensions.map((item) => {
|
||||
const editing = editingExtensionId === item.id;
|
||||
const typeLabel =
|
||||
item.extensionType === 'skill'
|
||||
? 'Skill'
|
||||
: item.extensionType === 'mcp'
|
||||
? 'MCP'
|
||||
: '未识别';
|
||||
const statusLabel =
|
||||
item.status === 'enabled'
|
||||
? '已启用'
|
||||
: item.status === 'disabled'
|
||||
? '已禁用'
|
||||
: item.status === 'startup-failed'
|
||||
? '启动失败'
|
||||
: '当前不可用';
|
||||
return (
|
||||
<article
|
||||
className="runtime-settings-extension-item"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="runtime-settings-extension-main">
|
||||
{editing ? (
|
||||
<input
|
||||
aria-label={`${item.name} 新名称`}
|
||||
autoFocus
|
||||
value={editingExtensionName}
|
||||
onChange={(event) =>
|
||||
setEditingExtensionName(
|
||||
event.currentTarget.value,
|
||||
)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void saveClientExtensionName(item);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
cancelRenameClientExtension();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<strong title={item.name}>{item.name}</strong>
|
||||
)}
|
||||
<span>
|
||||
{typeLabel} · 来自 {item.sourceName}
|
||||
</span>
|
||||
{item.lastError ? (
|
||||
<small title={item.lastError}>
|
||||
{item.lastError}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="runtime-settings-extension-actions">
|
||||
<span>{statusLabel}</span>
|
||||
{editing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() =>
|
||||
void saveClientExtensionName(item)
|
||||
}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={cancelRenameClientExtension}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`重命名 ${item.name}`}
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() =>
|
||||
beginRenameClientExtension(item)
|
||||
}
|
||||
>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{item.extensionType === 'unknown' ? null : (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={item.enabled}
|
||||
aria-label={`${item.name} ${item.enabled ? '禁用' : '启用'}`}
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() =>
|
||||
void setClientExtensionEnabled(
|
||||
item,
|
||||
!item.enabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除 ${item.name}`}
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() => void removeClientExtension(item)}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="runtime-settings-empty-state">
|
||||
<strong>还没有导入扩展</strong>
|
||||
<span>
|
||||
导入 Skill、MCP 配置或标准 Plugin 后会显示在这里。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
{activeSection === 'about' ? (
|
||||
<section
|
||||
className="runtime-settings-about"
|
||||
|
||||
@@ -3703,6 +3703,136 @@ h2 {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.runtime-settings-section-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.runtime-settings-section-actions button,
|
||||
.runtime-settings-extension-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 30px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 8px;
|
||||
background: var(--platform-button-ghost-fill);
|
||||
color: var(--platform-text-base);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-settings-section-actions button:hover,
|
||||
.runtime-settings-extension-actions button:hover {
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
background: var(--platform-button-ghost-fill);
|
||||
}
|
||||
|
||||
.runtime-settings-section-actions button:disabled,
|
||||
.runtime-settings-extension-actions button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.runtime-settings-extensions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.runtime-settings-inline-status {
|
||||
margin: 0;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.runtime-settings-extension-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.runtime-settings-extension-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 12px;
|
||||
background: var(--runtime-settings-subpanel-fill);
|
||||
}
|
||||
|
||||
.runtime-settings-extension-main {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.runtime-settings-extension-main strong,
|
||||
.runtime-settings-extension-main input {
|
||||
min-width: 0;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.runtime-settings-extension-main input {
|
||||
width: min(100%, 300px);
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 7px;
|
||||
background: var(--platform-input-fill);
|
||||
}
|
||||
|
||||
.runtime-settings-extension-main > span,
|
||||
.runtime-settings-extension-main > small,
|
||||
.runtime-settings-extension-actions > span {
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.runtime-settings-extension-main > small {
|
||||
color: var(--platform-danger, #b45309);
|
||||
}
|
||||
|
||||
.runtime-settings-extension-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.runtime-settings-extension-actions > span {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.runtime-settings-empty-state {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 24px 14px;
|
||||
border: 1px dashed var(--platform-subpanel-border);
|
||||
border-radius: 12px;
|
||||
color: var(--platform-text-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.runtime-settings-empty-state strong {
|
||||
color: var(--platform-text-base);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.runtime-settings-empty-state span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.runtime-settings-fields {
|
||||
align-content: start;
|
||||
}
|
||||
@@ -4669,6 +4799,24 @@ iframe.preview-frame {
|
||||
padding: 18px 14px 22px;
|
||||
}
|
||||
|
||||
.runtime-settings-section-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.runtime-settings-section-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.runtime-settings-extension-item {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.runtime-settings-extension-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.runtime-agent-card-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,59 @@ export function registerAgentStatusDerivationTests() {
|
||||
}
|
||||
|
||||
export function registerRuntimeSettingsTests() {
|
||||
it('distinguishes loading the client extension list from an empty list', async () => {
|
||||
let resolveExtensions!: (items: unknown[]) => void;
|
||||
const extensionsRead = new Promise<unknown[]>((resolve) => {
|
||||
resolveExtensions = resolve;
|
||||
});
|
||||
const invoke = vi.fn((command: string) => {
|
||||
if (command === 'read_game_creator_app_config') {
|
||||
return Promise.resolve({
|
||||
path: '/home/test/AppData/game-creator.config.json',
|
||||
config: {
|
||||
agentMode: 'codex_app_server',
|
||||
llm: {
|
||||
apiKey: '',
|
||||
baseUrl: 'https://llm.example.test/v1',
|
||||
model: 'gpt-settings-extensions',
|
||||
apiKind: 'openai_responses',
|
||||
reasoningEffort: 'high',
|
||||
stream: false,
|
||||
webSearchEnabled: false,
|
||||
requestTimeoutMs: 180000,
|
||||
maxRetries: 0,
|
||||
retryBackoffMs: 500,
|
||||
},
|
||||
editorApi: {
|
||||
baseUrl: 'http://127.0.0.1:8082',
|
||||
apiKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (command === 'list_client_extensions') {
|
||||
return extensionsRead;
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||
await screen.findByRole('dialog', { name: '运行时配置' });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^扩展/ }));
|
||||
|
||||
expect(await screen.findByText('正在加载扩展')).not.toBeNull();
|
||||
expect(screen.queryByText('还没有导入扩展')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveExtensions([]);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('还没有导入扩展')).not.toBeNull();
|
||||
expect(screen.queryByText('正在加载扩展')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the client version on the About settings page', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_game_creator_app_config') {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
## AI 游戏创作与 Agent Runtime
|
||||
|
||||
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
|
||||
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
|
||||
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。
|
||||
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
|
||||
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
- 关联文档:相关 PRD、技术文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 2026-08-31 DirectProject 客户端扩展按独立 Skill/MCP 导入
|
||||
|
||||
- 背景:DirectProject 需要使用用户在 AGC 客户端导入的市面原生 Skill、MCP 和 Plugin 内容,但第三方内容不应直接安装到运行时 Codex,也不应要求用户转换为 AGC 自定义格式。
|
||||
- 决策:客户端提供一个全局“扩展”入口,统一接受文件、目录、zip 和标准 Plugin;目录、zip、Plugin 只是导入来源,发现出的每个 Skill 和每个 MCP Server 分别成为独立扩展项,分别列表、重命名、启用、禁用和删除。已识别项导入后默认启用,下次 DirectProject Codex 启动时按原生 Skill root 和 MCP 配置注入。
|
||||
- 命名:客户端列表名称与 Codex 运行时名称使用同一个原生标识,不维护 display/runtime 两套名称;重复或同名项保留为新的独立项并自动追加 `-2`、`-3`。Skill 重命名只修改客户端运行时副本中的有效名称,原始导入内容不修改。
|
||||
- Plugin 边界:Plugin 只作为导入容器提取 Skill/MCP;当前 DirectProject 关闭的 hooks、apps、remote plugin 和完整 Plugin Runtime 不接入。单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。
|
||||
- 信任边界:不审核第三方 Skill 文案、脚本、二进制、MCP tool 或网络行为;导入阶段不执行内容。客户端只做标准结构识别、必要配置解析和 zip staging 路径边界处理,且不向第三方扩展注入 AGC 凭据或内部路径。
|
||||
- 影响范围:AGC 客户端扩展设置 UI、客户端本地扩展存储、DirectProject Codex app-server 启动准备和 pool fingerprint;不新增 HTTP 服务、SpacetimeDB schema、公开 API 或独立 Plugin Runtime。
|
||||
- 当前实现:客户端导入/list、Skill 临时 root 和 MCP 隔离配置注入均已落地。第三方 MCP 只从客户端已启用独立项生成本次隔离 `CODEX_HOME/config.toml`,每项固定非 required;配置错误或 app-server 启动状态失败只更新对应 `last_error`,内置 `agc_tools` 继续由客户端单独注入。客户端已启用 Skill/MCP 的名称、来源路径和内容指纹共同参与 DirectProject app-server pool identity。
|
||||
- 验证方式:分三阶段验收:先验证导入拆分和完整列表,再验证 Skill 运行时发现和重命名,最后验证 MCP 配置合并、Plugin 提取和失败隔离;只增加对应的定向测试、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`、`apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs`。
|
||||
|
||||
## 2026-08-30 批准 GDD 直接进入做游戏链路
|
||||
|
||||
- 背景:立项策划 GDD 批准后需要给用户一个进入做游戏的自然出口,产品决策改为点击按钮后直接开始建造。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user