合并master并解决资源画布冲突
Project CI / Repository checks (pull_request) Successful in 4m9s
Project CI / Frontend tests (pull_request) Successful in 4m20s
Project CI / Backend tests (pull_request) Successful in 5m21s
Project CI / Native shell tests (pull_request) Failing after 11m27s

合并 origin/master 最新提交

保留精修事务、候选图层与失败门户逻辑

同步生成恢复、Direct Codex 清理和资源画布删除交互
This commit is contained in:
2026-08-23 14:46:04 +08:00
34 changed files with 2688 additions and 292 deletions
@@ -5,6 +5,8 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import ts from 'typescript';
import {
appIdentifier,
defaultRealSwarmTestTask,
@@ -80,7 +82,10 @@ const appSource = [
readSourceTree(new URL('../src/', import.meta.url), '.ts'),
readSourceTree(new URL('../src/', import.meta.url), '.tsx'),
].join('\n');
const appInvokeSource = appSource;
const appInvokeSources = readSourceFiles(
new URL('../src/', import.meta.url),
new Set(['.ts', '.tsx']),
);
const appEntrypointSource = fs.readFileSync(
new URL('../src/main.tsx', import.meta.url),
'utf8',
@@ -120,26 +125,10 @@ const rustSharedContractSource = fs.readFileSync(
'utf8',
);
const allowedUncalledTauriCommands = [
'archive_failed_local_project_resource_edit',
'archive_failed_local_project_asset_canvas_generation',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'commit_local_project_asset',
'commit_local_project_asset_canvas_candidate',
'confirm_local_project_asset_canvas_generation_service_identity',
'create_local_project_asset_canvas_draft',
'discard_local_project_asset_canvas_draft',
'generate_local_project_asset_canvas_image',
'import_local_project_asset_canvas_images',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_local_project_asset_canvas_draft',
'read_local_project_asset_canvas_media',
'recover_local_project_asset_canvas_transactions',
'recover_local_project_asset_canvas_generations',
'stage_local_project_asset_canvas_image',
'store_local_project_asset_canvas_media',
'update_local_project_asset_canvas_draft',
];
const sourceExtensions = new Set([
'.json',
@@ -189,6 +178,23 @@ function readSourceTree(path, extension) {
return fs.readFileSync(path, 'utf8');
}
function readSourceFiles(path, extensions) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs
.readdirSync(path, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.flatMap((entry) =>
readSourceFiles(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
extensions,
),
);
}
if (!extensions.has(pathnameExtension(path.pathname))) return [];
return [{ fileName: path.pathname, source: fs.readFileSync(path, 'utf8') }];
}
function pathnameExtension(pathname) {
const index = pathname.lastIndexOf('.');
return index === -1 ? '' : pathname.slice(index);
@@ -320,13 +326,94 @@ function assertContractRecordsMatch(label, leftRecords, rightRecords) {
}
}
function parseAppInvokeCommandNames(source) {
return Array.from(
source.matchAll(
/(?:invoke|directInvoke)(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g,
),
([, command]) => command,
const APP_INVOKE_FILE_MAX_COUNT = 4 * 1024;
const APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH = 16 * 1024 * 1024;
const APP_INVOKE_SOURCE_MAX_LENGTH = 2 * 1024 * 1024;
const APP_INVOKE_COMMAND_MAX_LENGTH = 128;
const APP_INVOKE_CALL_MAX_COUNT = 4 * 1024;
const APP_INVOKE_BARE_CALL_NAMES = new Set([
'invoke',
'directInvoke',
'invokeInput',
'invokeAuthenticatedInput',
]);
function parseAppInvokeCommandNames(source, fileName = 'fixture.tsx') {
const sourceByteLength = Buffer.byteLength(source, 'utf8');
if (sourceByteLength > APP_INVOKE_SOURCE_MAX_LENGTH) {
throw new Error(
`AI game creator shell App invoke source exceeds ${APP_INVOKE_SOURCE_MAX_LENGTH} bytes: ${fileName}`,
);
}
const sourceFile = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
true,
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const parseDiagnostic = sourceFile.parseDiagnostics[0];
if (parseDiagnostic !== undefined) {
throw new Error(
`AI game creator shell App invoke source cannot be parsed: ${fileName} (TS${parseDiagnostic.code})`,
);
}
const commands = [];
const visit = (node) => {
if (ts.isCallExpression(node)) {
const expression = node.expression;
const isBareCall =
ts.isIdentifier(expression) &&
APP_INVOKE_BARE_CALL_NAMES.has(expression.text);
const isObjectInvoke =
ts.isPropertyAccessExpression(expression) &&
expression.name.text === 'invoke';
const commandArgument = node.arguments[0];
if (
(isBareCall || isObjectInvoke) &&
commandArgument !== undefined &&
ts.isStringLiteral(commandArgument) &&
commandArgument.text.length <= APP_INVOKE_COMMAND_MAX_LENGTH &&
/^[a-z0-9_]+$/u.test(commandArgument.text)
) {
commands.push(commandArgument.text);
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}: ${fileName}`,
);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return commands;
}
function parseAppInvokeSourceFiles(files) {
if (files.length > APP_INVOKE_FILE_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke files exceed ${APP_INVOKE_FILE_MAX_COUNT}`,
);
}
const totalLength = files.reduce(
(length, file) => length + Buffer.byteLength(file.source, 'utf8'),
0,
);
if (totalLength > APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH) {
throw new Error(
`AI game creator shell App invoke sources exceed ${APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH} bytes`,
);
}
const commands = files.flatMap(({ fileName, source }) =>
parseAppInvokeCommandNames(source, fileName),
);
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}`,
);
}
return commands;
}
function parseTauriHandlerCommandNames(source) {
@@ -357,6 +444,99 @@ function assertCommandNamesSubset(label, leftNames, rightNames) {
}
}
function assertCommandNamesDisjoint(label, leftNames, rightNames) {
const right = new Set(rightNames);
const overlapping = Array.from(new Set(leftNames))
.filter((name) => right.has(name))
.sort((left, rightName) => left.localeCompare(rightName));
if (overlapping.length > 0) {
throw new Error(`${label} overlapping commands: ${overlapping.join(', ')}`);
}
}
function runAppInvokeParserRegressionChecks() {
assert.deepEqual(
parseAppInvokeCommandNames(`
invoke('direct_command', {});
directInvoke<Result>('generic_direct_command', {});
invokeInput < Result > ('input_wrapper_command', {});
invokeAuthenticatedInput<Result>(
'authenticated_input_wrapper_command',
{},
);
input.invoke<Result>('object_field_command', {});
`),
[
'direct_command',
'generic_direct_command',
'input_wrapper_command',
'authenticated_input_wrapper_command',
'object_field_command',
],
);
assert.deepEqual(
parseAppInvokeCommandNames(`
// invoke('line_comment_decoy')
/* invokeInput('block_comment_decoy') */
const quoted = "directInvoke('string_decoy')";
const template = \`input.invoke('template_decoy')\`;
const expression = /invokeAuthenticatedInput\\('regex_decoy'\\)/u;
invokeCommand('unrelated_name');
myinvoke('unrelated_suffix');
invoke(dynamicCommand, {});
invoke('UPPERCASE_COMMAND', {});
`),
[],
);
assert.throws(
() => parseAppInvokeCommandNames("invoke<Result>('malformed_generic', {"),
/source cannot be parsed/u,
);
assert.deepEqual(
parseAppInvokeCommandNames(
`invoke('${'a'.repeat(APP_INVOKE_COMMAND_MAX_LENGTH + 1)}', {})`,
),
[],
);
assert.throws(
() =>
parseAppInvokeCommandNames(' '.repeat(APP_INVOKE_SOURCE_MAX_LENGTH + 1)),
/source exceeds/u,
);
const wrapperInvocations = parseAppInvokeCommandNames(
"invokeInput<Result>('wrapper_reachability_command', {})",
);
assert.doesNotThrow(() =>
assertCommandNamesSubset(
'App invoke parser reachability fixture',
['wrapper_reachability_command'],
wrapperInvocations,
),
);
assert.throws(
() =>
assertCommandNamesDisjoint(
'App invoke parser false allowlist fixture',
wrapperInvocations,
['wrapper_reachability_command'],
),
/overlapping commands: wrapper_reachability_command/u,
);
assert.throws(
() =>
assertCommandNamesSubset(
'App invoke parser removed wrapper fixture',
['wrapper_reachability_command'],
parseAppInvokeCommandNames('const wrapperWasRemoved = true;'),
),
/missing commands: wrapper_reachability_command/u,
);
}
function gitCheckResult({ code = 0, signal = null, stdout = '', stderr = '' }) {
return { code, signal, stdout, stderr };
}
@@ -942,6 +1122,10 @@ assertNoEnvironmentConfigFallbacks([
assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]);
assertNoBlockingNativeFilePicker(tauriRustSource);
runAppInvokeParserRegressionChecks();
const appInvokeCommandNames = parseAppInvokeSourceFiles(appInvokeSources);
assertContractRecordsMatch(
'AI game creator shell command contract',
parseTsCommands(sharedContractSource),
@@ -956,7 +1140,7 @@ assertContractRecordsMatch(
assertCommandNamesSubset(
'AI game creator shell Tauri handler',
parseAppInvokeCommandNames(appInvokeSource),
appInvokeCommandNames,
parseTauriHandlerCommandNames(tauriHandlerSource),
);
@@ -969,10 +1153,7 @@ assertCommandNamesSubset(
assertCommandNamesSubset(
'AI game creator shell App invoke or explicit native-only allowlist',
parseTauriHandlerCommandNames(tauriHandlerSource),
[
...parseAppInvokeCommandNames(appInvokeSource),
...allowedUncalledTauriCommands,
],
[...appInvokeCommandNames, ...allowedUncalledTauriCommands],
);
assertCommandNamesSubset(
@@ -981,6 +1162,12 @@ assertCommandNamesSubset(
parseTauriHandlerCommandNames(tauriHandlerSource),
);
assertCommandNamesDisjoint(
'AI game creator shell App invoke and explicit native-only allowlist',
appInvokeCommandNames,
allowedUncalledTauriCommands,
);
const tauriHandlerCommandNames =
parseTauriHandlerCommandNames(tauriHandlerSource);
if (!tauriHandlerCommandNames.includes('create_automatic_local_game_project')) {
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-22.2",
"version": "2026-08-22.3",
"skills": [
{
"name": "agc-project-structure",
@@ -21,12 +21,16 @@ 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_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration =
std::time::Duration::from_millis(250);
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX: &str =
"codex-app-server-terminal-unknown:";
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str =
"codex-app-server-error:";
const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str =
const CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK: &str =
"You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence.";
const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str =
"You are Taonier (陶泥儿), Genarrative's game-creation assistant. Present yourself to users as 陶泥儿; do not use Codex, ChatGPT, OpenAI, model, or generic AI assistant as your name or public identity. Codex app-server is only an internal execution technology: mention it only when the user explicitly asks about the underlying implementation, while still identifying yourself as 陶泥儿. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence.";
type RpcResult = Result<serde_json::Value, String>;
@@ -305,6 +309,7 @@ impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey {
#[derive(Clone, Debug)]
enum CodexTurnEvent {
AgentMessageDelta(String),
Activity(&'static str),
Item {
completed: bool,
params: serde_json::Value,
@@ -313,6 +318,12 @@ enum CodexTurnEvent {
TransportClosed(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum DirectCodexTurnObservation {
AccumulatedText(String),
Activity(&'static str),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CodexAppServerWorkspaceMode {
/// The legacy AGC ToolHost path: use a throwaway workspace and keep the
@@ -380,6 +391,51 @@ fn update_active_direct_mcp_tool_calls(
}
}
fn direct_codex_safe_activity_for_item(item_type: &str) -> &'static str {
match item_type {
"fileChange" => "file-change",
"commandExecution" => "validation",
"mcpToolCall" => "controlled-tool",
"contextCompaction" | "webSearch" => "project-inspection",
"agentMessage" => "response-finalization",
"userMessage" | "plan" | "reasoning" => "understanding",
_ => "understanding",
}
}
fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static str> {
match method {
"turn/started"
| "turn/plan/updated"
| "item/plan/delta"
| "item/reasoning/summaryTextDelta"
| "item/reasoning/summaryPartAdded"
| "item/reasoning/textDelta" => Some("understanding"),
"item/mcpToolCall/progress" | "serverRequest/resolved" => Some("controlled-tool"),
"item/fileChange/outputDelta" | "item/fileChange/patchUpdated" => Some("file-change"),
"command/exec/outputDelta"
| "process/outputDelta"
| "item/commandExecution/outputDelta"
| "model/verification" => Some("validation"),
_ => None,
}
}
fn should_emit_direct_codex_activity(
last_activity: &mut Option<(&'static str, std::time::Instant)>,
activity: &'static str,
) -> bool {
let now = std::time::Instant::now();
if last_activity.is_some_and(|(previous, observed_at)| {
previous == activity
&& now.saturating_duration_since(observed_at) < DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL
}) {
return false;
}
*last_activity = Some((activity, now));
true
}
fn game_creator_codex_app_server_idle_timeout_ms(
workspace_mode: CodexAppServerWorkspaceMode,
request_timeout_ms: u64,
@@ -1388,7 +1444,7 @@ impl CodexAppServerConnection {
let base_instructions = if self.inner.workspace_mode.uses_direct_conversation() {
direct_codex_base_instructions(&request)
} else {
DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK.to_string()
CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK.to_string()
};
let params = codex_app_server_thread_start_params(
model,
@@ -1464,11 +1520,23 @@ impl CodexAppServerConnection {
}
async fn run_turn(
&self,
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
request: LlmRunRequest,
on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None)
.await
}
async fn run_turn_with_direct_observer(
&self,
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
request: LlmRunRequest,
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
) -> 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?;
@@ -1587,6 +1655,11 @@ impl CodexAppServerConnection {
match event {
Some(CodexTurnEvent::AgentMessageDelta(delta)) => {
streamed_text.push_str(&delta);
if let Some(observer) = direct_observer.as_deref_mut() {
observer(DirectCodexTurnObservation::AccumulatedText(
streamed_text.clone(),
));
}
if let Some(callback) = on_agent_message_delta.as_deref_mut() {
callback(&platform_llm::LlmStreamDelta {
accumulated_text: streamed_text.clone(),
@@ -1595,12 +1668,22 @@ impl CodexAppServerConnection {
});
}
}
Some(CodexTurnEvent::Activity(activity)) => {
if let Some(observer) = direct_observer.as_deref_mut() {
observer(DirectCodexTurnObservation::Activity(activity));
}
}
Some(CodexTurnEvent::Item { completed, params }) => {
if let Some(item) = params.get("item") {
let item_type = item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if let Some(observer) = direct_observer.as_deref_mut() {
observer(DirectCodexTurnObservation::Activity(
direct_codex_safe_activity_for_item(item_type),
));
}
if self.inner.workspace_mode
== CodexAppServerWorkspaceMode::DirectProject
{
@@ -1839,6 +1922,8 @@ async fn read_game_creator_codex_app_server_stdout(
stdout: tokio::process::ChildStdout,
) {
let mut reader = BufReader::new(stdout);
let mut last_direct_activity_by_turn =
HashMap::<String, Option<(&'static str, std::time::Instant)>>::new();
loop {
let mut buffer = match read_bounded_game_creator_codex_app_server_line(&mut reader).await {
Ok(Some(buffer)) => buffer,
@@ -2028,10 +2113,12 @@ async fn read_game_creator_codex_app_server_stdout(
.get("method")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let safe_activity = direct_codex_safe_activity_for_notification(method);
if !matches!(
method,
"item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed"
) {
) && safe_activity.is_none()
{
continue;
}
let params = message
@@ -2050,24 +2137,37 @@ async fn read_game_creator_codex_app_server_stdout(
let Some(turn_id) = turn_id else {
continue;
};
let event = match method {
"item/agentMessage/delta" => {
let Some(delta) = params
.get("delta")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
else {
continue;
};
CodexTurnEvent::AgentMessageDelta(delta.to_string())
if let Some(activity) = safe_activity {
let last_activity = last_direct_activity_by_turn
.entry(turn_id.clone())
.or_default();
if !should_emit_direct_codex_activity(last_activity, activity) {
continue;
}
}
let event = if let Some(activity) = safe_activity {
CodexTurnEvent::Activity(activity)
} else {
match method {
"item/agentMessage/delta" => {
let Some(delta) = params
.get("delta")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
else {
continue;
};
CodexTurnEvent::AgentMessageDelta(delta.to_string())
}
"item/started" | "item/completed" => CodexTurnEvent::Item {
completed: method == "item/completed",
params,
},
_ => CodexTurnEvent::Terminal(params),
}
"item/started" | "item/completed" => CodexTurnEvent::Item {
completed: method == "item/completed",
params,
},
_ => CodexTurnEvent::Terminal(params),
};
let sender = if method == "turn/completed" {
last_direct_activity_by_turn.remove(&turn_id);
inner.turns.lock().await.remove(&turn_id)
} else {
inner.turns.lock().await.get(&turn_id).cloned()
@@ -2334,6 +2434,31 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
) -> Result<String, String> {
direct_game_creator_codex_chat_at_with_optional_observer(root, system_prompt, user_prompt, None)
.await
}
pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send),
) -> Result<String, String> {
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
user_prompt,
Some(observer),
)
.await
}
async fn direct_game_creator_codex_chat_at_with_optional_observer(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
) -> Result<String, String> {
let codex_root = if let Some(path) = root
.to_str()
@@ -2381,7 +2506,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
.await
.map_err(|error| error.to_string())?;
connection
.run_turn(&snapshot, &config.llm, request, None)
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer)
.await
.map(|value| value.text)
.map_err(|error| error.to_string())
@@ -2470,6 +2595,36 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result
mod tests {
use super::*;
#[test]
fn direct_item_activities_are_closed_safe_categories() {
let allowed = [
"understanding",
"project-inspection",
"file-change",
"controlled-tool",
"validation",
"response-finalization",
];
for item_type in [
"fileChange",
"commandExecution",
"mcpToolCall",
"contextCompaction",
"webSearch",
"agentMessage",
"userMessage",
"plan",
"reasoning",
"SECRET_TOOL_/private/project/api_key=leak",
] {
let activity = direct_codex_safe_activity_for_item(item_type);
assert!(allowed.contains(&activity));
assert!(!activity.contains("SECRET_TOOL"));
assert!(!activity.contains("/private/project"));
assert!(!activity.contains("api_key"));
}
}
fn test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
api_key: "fixture-secret".to_string(),
@@ -2525,6 +2680,16 @@ mod tests {
assert!(!direct_codex_user_prompt(&request).contains("AGC 系统规则"));
}
#[test]
fn direct_codex_missing_system_prompt_uses_taonier_identity_only() {
let request = LlmRunRequest::single_turn("", "你是谁");
let instructions = direct_codex_base_instructions(&request);
assert!(instructions.contains("Taonier (陶泥儿)"));
assert!(instructions.contains("still identifying yourself as 陶泥儿"));
assert!(!instructions.starts_with("You are Codex"));
assert!(CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK.starts_with("You are Codex"));
}
#[test]
fn direct_project_uses_bounded_idle_and_active_mcp_windows() {
let request_timeout_ms = 180_000;
@@ -3121,8 +3286,16 @@ printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}'
IFS= read -r turn_start
case "$turn_start" in *'"method":"turn/start"'*'"outputSchema"'*) ;; *) exit 46 ;; esac
printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1","items":[],"status":"inProgress"}}}'
printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"inProgress"}}}'
printf '%s\n' '{"method":"item/mcpToolCall/progress","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"tool-1","message":"SECRET_TOOL /private/project api_key=must-not-leak"}}'
printf '%s\n' '{"method":"item/mcpToolCall/progress","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"tool-1","message":"SECOND_SECRET_PROGRESS"}}'
printf '%s\n' '{"method":"item/fileChange/patchUpdated","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"change-1","patch":"*** SECRET PATCH /private/project"}}'
printf '%s\n' '{"method":"item/commandExecution/outputDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"command-1","delta":"Bearer secret-command-output"}}'
printf '%s\n' '{"method":"turn/plan/updated","params":{"threadId":"thread-1","turnId":"turn-1","explanation":"private reasoning must not leak","plan":[]}}'
printf '%s\n' '{"method":"item/reasoning/summaryTextDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"reasoning-1","delta":"hidden reasoning must not leak"}}'
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"{\"toolCalls\":"}}'
printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"{\"toolCalls\":[{\"name\":\"runtime_tool_file_read\",\"arguments\":\"{\\\"path\\\":\\\"game/index.html\\\"}\"}]}"}}}'
printf '%s\n' '{"method":"item/started","params":{"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","rawParams":"SECRET_TOOL /private/project api_key=must-not-leak"}}}'
printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","rawParams":"SECRET_TOOL /private/project api_key=must-not-leak","text":"{\"toolCalls\":[{\"name\":\"runtime_tool_file_read\",\"arguments\":\"{\\\"path\\\":\\\"game/index.html\\\"}\"}]}"}}}'
printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}'
while IFS= read -r line; do :; done
"#,
@@ -3142,11 +3315,83 @@ while IFS= read -r line; do :; done
let mut streamed = String::new();
let mut on_delta =
|delta: &platform_llm::LlmStreamDelta| streamed.push_str(&delta.delta_text);
let mut observations = Vec::new();
let mut observer = |observation| observations.push(observation);
let response = connection
.run_turn(&test_snapshot(), &llm, tool_request(), Some(&mut on_delta))
.run_turn_with_direct_observer(
&test_snapshot(),
&llm,
tool_request(),
Some(&mut on_delta),
Some(&mut observer),
)
.await
.expect("run fake app-server turn");
drop(observer);
assert_eq!(streamed, "{\"toolCalls\":");
assert_eq!(
observations.first(),
Some(&DirectCodexTurnObservation::Activity("understanding")),
"turn/started must produce safe activity before terminal completion"
);
let delta_index = observations
.iter()
.position(|observation| {
*observation
== DirectCodexTurnObservation::AccumulatedText("{\"toolCalls\":".to_string())
})
.expect("agent message delta observation");
assert!(
observations[..delta_index]
.iter()
.filter(|observation| matches!(
observation,
DirectCodexTurnObservation::Activity(_)
))
.count()
>= 4,
"real long-tool protocol activity must be visible before final answer delta"
);
assert_eq!(
observations
.iter()
.filter(|observation| {
**observation == DirectCodexTurnObservation::Activity("controlled-tool")
})
.count(),
1,
"rapid same-category MCP progress must be coalesced"
);
for expected in ["file-change", "validation"] {
assert!(observations.contains(&DirectCodexTurnObservation::Activity(expected)));
}
assert!(
observations
.iter()
.filter(|observation| {
**observation == DirectCodexTurnObservation::Activity("understanding")
})
.count()
>= 2,
"turn start and later plan activity must both remain visible"
);
assert_eq!(
observations
.iter()
.filter(|observation| {
**observation == DirectCodexTurnObservation::Activity("response-finalization")
})
.count(),
2,
"item/started and item/completed must both emit a safe activity"
);
let observation_debug = format!("{observations:?}");
assert!(!observation_debug.contains("SECRET_TOOL"));
assert!(!observation_debug.contains("SECOND_SECRET_PROGRESS"));
assert!(!observation_debug.contains("/private/project"));
assert!(!observation_debug.contains("api_key"));
assert!(!observation_debug.contains("Bearer"));
assert!(!observation_debug.contains("hidden reasoning"));
assert_eq!(response.response_id.as_deref(), Some("thread-1"));
assert_eq!(response.tool_calls.len(), 1);
assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read");
@@ -10,7 +10,10 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 64 * 1024;
const MAX_DIRECT_HOME_ATTACHMENTS: usize = 8;
const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160;
const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前工作目录是唯一项目根;`game/index.html` 是可运行入口,`game/style.css` 与 `game/game.js` 是标准游戏代码,`assets/` 保存项目媒体,`.agent/` 是客户端维护的持久状态。Codex 可以按用户意图读写游戏源码,但不得手改 `.agent` 中的 manifest、revision、版本、来源身份、生成账本或试玩回执。DirectProject 为无人值守安全边界,shell/unified_exec 和任意网络被禁用;客户端会在系统上下文提供有界的当前游戏文件快照,并通过 `agc_tools` 返回结构化试玩证据。原生文件读取能力未出现时,直接使用这些快照和工具证据,不要声称因此无法验收。需要平台美术或真实浏览器试玩时,只调用审核 Skill 声明的 `agc_tools` 工具;工具不可用或失败时如实报告。文件真实变化后由客户端确定性投影资源和版本,Codex 不直接保存或伪造项目版本。";
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 工程合同:当前工作目录是唯一项目根;`game/index.html` 是可运行入口,`game/style.css` 与 `game/game.js` 是标准游戏代码,`assets/` 保存项目媒体,`.agent/` 是客户端维护的持久状态。你可以按用户意图读写游戏源码,但不得手改 `.agent` 中的 manifest、revision、版本、来源身份、生成账本或试玩回执。DirectProject 为无人值守安全边界,shell/unified_exec 和任意网络被禁用;客户端会在系统上下文提供有界的当前游戏文件快照,并通过 `agc_tools` 返回结构化试玩证据。原生文件读取能力未出现时,直接使用这些快照和工具证据,不要声称因此无法验收。需要平台美术或真实浏览器试玩时,只调用审核 Skill 声明的 `agc_tools` 工具;工具不可用或失败时如实报告。文件真实变化后由客户端确定性投影资源和版本,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";
@@ -1520,13 +1523,14 @@ fn read_markdown_files(root: &Path, relative_dir: &str) -> Vec<(String, String)>
pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result<String, String> {
let skill_index = render_agc_skill_pack_index()?;
let mut sections = vec![
"你是 Genarrative 的唯一执行智能体。用户聊天内容会原样直接发送给你;不要等待 Supervisor、专业 Agent、harness 或宿主规划器。先自行理解用户意图:普通对话(例如问候、日期或项目无关问题)直接正常回答且不触碰工作区;项目请求再按实际需要检查工程、修改工作区、运行验证,并用简洁中文报告真实结果。客户端不会根据关键词替你决定新建、续做、生图、试玩、返工或版本登记。优先使用 Codex 原生文件变更能力直接写入文件;不要用 shell 命令拼接或重定向来创建文件。".to_string(),
"你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,也是当前唯一执行主体和唯一执行智能体。用户聊天内容会原样直接发送给你;不要等待 Supervisor、专业 Agent、harness 或宿主规划器。先自行理解用户意图:普通对话(例如问候、日期或项目无关问题)直接正常回答且不触碰工作区;项目请求再按实际需要检查工程、修改工作区、运行验证,并用简洁中文报告真实结果。客户端不会根据关键词替你决定新建、续做、生图、试玩、返工或版本登记。优先使用内部执行引擎提供的原生文件变更能力直接写入文件;不要用 shell 命令拼接或重定向来创建文件。".to_string(),
DIRECT_TAONIER_IDENTITY_GUIDANCE.to_string(),
"工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(),
"提示词与技能:系统上下文只给出审核 Skill 索引,不预装完整正文。根据当前用户意图选择最少的相关 Skill,并通过 Codex 原生 Skill 机制按需读取其 `SKILL.md` 和一层直接引用。Skill 是执行约束,不是新的 Agent;工具未出现或调用失败时必须如实说明,不能用文字假装获得能力。不要启动 Supervisor、child、harness 或外部编排。".to_string(),
DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(),
skill_index,
];
sections.push("工程摘要:这是一个 Tauri + React AGC 客户端,项目游戏入口位于 `game/index.html`,样式和脚本放在 `game/`,项目状态位于 `.agent/`。普通聊天由当前 Codex 直接完成文件修改和验证;不要创建 Supervisor、专业 Agent、harness 或平行项目。修改后优先检查实际文件和运行结果。".to_string());
sections.push("工程摘要:这是一个 Tauri + React AGC 客户端,项目游戏入口位于 `game/index.html`,样式和脚本放在 `game/`,项目状态位于 `.agent/`。项目请求由你直接完成文件修改和验证;不要创建 Supervisor、专业 Agent、harness 或平行项目。修改后优先检查实际文件和运行结果。".to_string());
for relative in ["game/index.html", "game/style.css", "game/game.js"] {
if let Some(text) =
read_context_file_with_limit(root, relative, MAX_DIRECT_PROJECT_FILE_BYTES)
@@ -1548,6 +1552,7 @@ pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result<String, St
sections.push(format!("\n--- prompt: {label} ---\n{text}"));
}
sections.push("\n工程执行要求:优先复用现有结构;修改后运行与改动相关的测试、typecheck、编码检查和 git diff --check。需要生成游戏时,直接在当前工作区完成代码、资源、运行和验证闭环;不要创建新的 Supervisor/harness。".to_string());
sections.push(DIRECT_TAONIER_IDENTITY_GUIDANCE.to_string());
Ok(sections
.join("\n")
.chars()
@@ -1592,7 +1597,7 @@ pub(crate) fn build_direct_codex_system_prompt_with_creation_type(
/// general question cannot become an accidental game-generation request.
pub(crate) fn build_direct_codex_home_system_prompt() -> String {
[
"你是陶泥儿。用户的这条首页消息正文会原样直接发送给你;如有附件,正文后会单独附带仅含文件名、媒体类型和大小的附件说明。你是唯一的对话执行主体,不要等待或启动 Supervisor、专业 Agent、harness 或宿主规划器。",
"你是陶泥儿,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型或通用 AI 助手当作自己的名称或对外身份。只有用户明确询问底层实现时才可如实说明内部使用 Codex app-server,同时仍以陶泥儿自称。用户的这条首页消息正文会原样直接发送给你;如有附件,正文后会单独附带仅含文件名、媒体类型和大小的附件说明。你是唯一的对话执行主体,不要等待或启动 Supervisor、专业 Agent、harness 或宿主规划器。",
"当前没有打开任何用户项目。普通对话(例如问候、日期、知识问答)请直接正常回答。不要创建、读取或修改项目文件,不要生成素材,不要启动预览、试玩、发布、版本登记或任何付费外部动作。",
"如果用户明确希望开始创作游戏,且需求已经足以开始,请把回复的第一行严格写为 [[AGC_CREATE_PROJECT]],随后用简洁中文说明将创建项目并继续创作。这个标记是唯一允许的受限创建项目请求;绝不能为普通问答、素材查看、知识问答或含糊想法输出它。不要自行创建目录。只有用户已在项目工作台中打开工作区后,才能在该项目对话中执行文件修改或游戏验证。",
"不要输出或请求 API Key、Token、Cookie、auth.json、.env、用户路径或内部实现细节。遇到当前无项目无法执行的请求,请如实说明边界和下一步。",
@@ -1729,6 +1734,21 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
root: &Path,
prompt: &str,
creation_type: Option<&str>,
) -> Result<String, String> {
run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
prompt,
creation_type,
None,
)
.await
}
async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root: &Path,
prompt: &str,
creation_type: Option<&str>,
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
) -> Result<String, String> {
if !root.is_absolute() || !root.is_dir() {
return Err("当前项目目录不存在或不是绝对路径".to_string());
@@ -1741,9 +1761,23 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
}
direct_creation_type_system_context(creation_type)?;
emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复");
match run_direct_game_creator_turn_inner(root, prompt, creation_type).await {
Ok(reply) => Ok(reply),
Err(failure) => Err(record_direct_codex_turn_failure(root, failure)),
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).await {
Ok(reply) => {
if let Some(emitter) = turn_emitter {
emitter.emit("completed", Some("none"), Some(reply.clone()));
}
Ok(reply)
}
Err(failure) => {
let error = record_direct_codex_turn_failure(root, failure);
if let Some(emitter) = turn_emitter {
emitter.emit("failed", Some("none"), None);
}
Err(error)
}
}
}
@@ -1751,16 +1785,68 @@ async fn run_direct_game_creator_turn_inner(
root: &Path,
prompt: &str,
creation_type: Option<&str>,
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
) -> Result<String, DirectCodexTurnFailure> {
run_direct_game_creator_turn_with_creation_type(
root,
prompt,
creation_type,
|system_prompt, user_prompt| async move {
direct_game_creator_codex_chat_at(root, system_prompt, user_prompt).await
},
)
.await
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
if let Some(emitter) = turn_emitter {
emitter.emit("running", Some("understanding"), None);
}
let previous_output_fingerprint = direct_codex_output_fingerprint(root);
let system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type)
.map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
let reply = if let Some(emitter) = turn_emitter {
let emitter = emitter.clone();
let mut has_streamed = false;
let mut latest_accumulated_text = None;
let mut observer = move |observation: DirectCodexTurnObservation| match observation {
DirectCodexTurnObservation::AccumulatedText(accumulated_text) => {
has_streamed = true;
latest_accumulated_text = Some(accumulated_text.clone());
emitter.emit("streaming", None, Some(accumulated_text));
}
DirectCodexTurnObservation::Activity(activity) => {
emitter.emit(
if has_streamed { "streaming" } else { "running" },
Some(activity),
latest_accumulated_text.clone(),
);
}
};
direct_game_creator_codex_chat_at_with_observer(
root,
system_prompt,
prompt.to_string(),
&mut observer,
)
.await
} else {
direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()).await
}
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
if let Some(emitter) = turn_emitter {
emitter.emit(
"finalizing",
Some("response-finalization"),
Some(reply.clone()),
);
}
if direct_codex_output_fingerprint(root) != previous_output_fingerprint {
emit_direct_game_creator_progress(
root,
"project.sync",
"检测到游戏文件更新,正在同步客户端资源",
);
if let Some(emitter) = turn_emitter {
emitter.emit("finalizing", Some("file-change"), Some(reply.clone()));
}
sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint))
.map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error)
})?;
}
Ok(reply)
}
/// Default product path: one user message becomes one turn on the same
@@ -2011,16 +2097,41 @@ 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 Ok(uuid::Uuid::new_v4().simple().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>,
) -> Result<String, String> {
run_direct_game_creator_turn_at_with_creation_type(
Path::new(project_path.trim()),
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id);
run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&prompt,
creation_type.as_deref(),
Some(&turn_emitter),
)
.await
}
@@ -2037,11 +2148,55 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
mod tests {
use super::*;
#[test]
fn client_turn_id_is_strictly_normalized_and_bounded() {
assert_eq!(
normalize_direct_client_turn_id(Some(" Abc123-def ")).expect("valid id"),
"Abc123-def"
);
assert!(normalize_direct_client_turn_id(Some("short")).is_err());
assert!(normalize_direct_client_turn_id(Some("abcdef/path")).is_err());
assert!(normalize_direct_client_turn_id(Some(&"a".repeat(161))).is_err());
let generated = normalize_direct_client_turn_id(None).expect("generated id");
assert_eq!(generated.len(), 32);
assert!(generated.bytes().all(|byte| byte.is_ascii_hexdigit()));
}
#[test]
fn direct_turn_update_payload_is_the_exact_camel_case_contract() {
let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent {
project_path: "/project".to_string(),
turn_id: "abcdef-123456".to_string(),
sequence: 7,
status: "streaming".to_string(),
activity: None,
accumulated_text: Some("partial".to_string()),
updated_at: 42,
})
.expect("serialize direct update");
assert_eq!(
value,
serde_json::json!({
"projectPath": "/project",
"turnId": "abcdef-123456",
"sequence": 7,
"status": "streaming",
"activity": null,
"accumulatedText": "partial",
"updatedAt": 42,
})
);
}
#[test]
fn system_prompt_is_bounded_and_declares_direct_runtime() {
let prompt =
build_direct_codex_system_prompt(Path::new(".")).expect("build direct system prompt");
assert!(prompt.contains("唯一执行智能体"));
assert!(prompt.contains("你是陶泥儿"));
assert!(prompt.contains("对外身份合同"));
assert!(prompt.contains("不要把 Codex"));
assert!(prompt.contains("仍以陶泥儿自称"));
assert!(!prompt.contains("你是 Codex"));
assert!(prompt.contains("不要等待 Supervisor"));
assert!(prompt.contains("提示词与技能"));
}
@@ -2115,6 +2270,10 @@ mod tests {
fn home_prompt_has_no_project_or_side_effect_path_and_declares_the_only_creation_marker() {
let prompt = build_direct_codex_home_system_prompt();
assert!(prompt.contains("你是陶泥儿"));
assert!(prompt.contains("不要把 Codex"));
assert!(prompt.contains("仍以陶泥儿自称"));
assert!(!prompt.contains("你是 Codex"));
assert!(prompt.contains("当前没有打开任何用户项目"));
assert!(prompt.contains("不要创建、读取或修改项目文件"));
assert!(prompt.contains("不要生成素材"));
@@ -28,6 +28,73 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag
);
}
#[derive(Clone)]
pub(crate) struct DirectGameCreatorTurnUpdateEmitter {
project_path: String,
turn_id: String,
sequence: Arc<AtomicU64>,
}
impl DirectGameCreatorTurnUpdateEmitter {
pub(crate) fn new(root: &Path, turn_id: String) -> Self {
Self {
project_path: root.to_string_lossy().into_owned(),
turn_id,
sequence: Arc::new(AtomicU64::new(0)),
}
}
pub(crate) fn emit(
&self,
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
) {
let status_is_allowed = matches!(
status,
"accepted" | "running" | "streaming" | "finalizing" | "completed" | "failed"
);
let activity_is_allowed = activity.is_none_or(|activity| {
matches!(
activity,
"request-accepted"
| "understanding"
| "project-inspection"
| "file-change"
| "controlled-tool"
| "validation"
| "response-finalization"
| "none"
)
});
debug_assert!(status_is_allowed && activity_is_allowed);
if !status_is_allowed || !activity_is_allowed {
return;
}
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1;
let updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64;
let _ = app.emit(
"game-creator-direct-turn-update",
GameCreatorDirectTurnUpdateEvent {
project_path: self.project_path.clone(),
turn_id: self.turn_id.clone(),
sequence,
status: status.to_string(),
activity: activity.map(str::to_string),
accumulated_text,
updated_at,
},
);
}
}
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
app: tauri::AppHandle,
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
@@ -1,5 +1,6 @@
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::path::{Component, Path};
@@ -122,6 +123,29 @@ fn bundled_skill_file(path: &str) -> Option<&'static [u8]> {
.find_map(|(candidate, bytes)| (*candidate == path).then_some(*bytes))
}
fn canonical_skill_text_bytes<'a>(path: &str, bytes: &'a [u8]) -> Result<Cow<'a, [u8]>, String> {
let text = std::str::from_utf8(bytes)
.map_err(|_| format!("内置 AGC Skill 审核文件 {path} 不是 UTF-8 文本"))?;
if !text.contains("\r\n") {
return Ok(Cow::Borrowed(bytes));
}
Ok(Cow::Owned(text.replace("\r\n", "\n").into_bytes()))
}
fn update_skill_content_digest(
digest: &mut Sha256,
relative: &str,
bundled_path: &str,
bytes: &[u8],
) -> Result<(), String> {
let canonical_bytes = canonical_skill_text_bytes(bundled_path, bytes)?;
digest.update(relative.as_bytes());
digest.update([0]);
digest.update(canonical_bytes.as_ref());
digest.update([0]);
Ok(())
}
fn skill_content_fingerprint(entry: &AgcSkillManifestEntry) -> Result<String, String> {
let mut files = entry.files.clone();
files.sort();
@@ -133,10 +157,7 @@ fn skill_content_fingerprint(entry: &AgcSkillManifestEntry) -> Result<String, St
let bundled_path = format!("{}/{}", entry.name, relative.replace('\\', "/"));
let bytes = bundled_skill_file(&bundled_path)
.ok_or_else(|| format!("AGC Skill {} 缺少审核文件 {relative}", entry.name))?;
digest.update(relative.as_bytes());
digest.update([0]);
digest.update(bytes);
digest.update([0]);
update_skill_content_digest(&mut digest, &relative, &bundled_path, bytes)?;
}
Ok(format!("{:x}", digest.finalize()))
}
@@ -194,7 +215,8 @@ fn validated_skill_pack_manifest() -> Result<AgcSkillPackManifest, String> {
pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
validated_skill_pack_manifest()?;
Ok(format!("{:x}", Sha256::digest(AGC_SKILL_PACK_MANIFEST)))
let canonical_manifest = canonical_skill_text_bytes("manifest.json", AGC_SKILL_PACK_MANIFEST)?;
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
}
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
@@ -260,12 +282,13 @@ pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result<String,
let bundled_path = format!("{}/{}", entry.name, relative.replace('\\', "/"));
let bytes = bundled_skill_file(&bundled_path)
.ok_or_else(|| format!("内置 AGC Skill 缺少审核文件 {bundled_path}"))?;
let canonical_bytes = canonical_skill_text_bytes(&bundled_path, bytes)?;
let target = skills_root.join(&entry.name).join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("创建隔离 AGC Skill 子目录失败:{error}"))?;
}
std::fs::write(&target, bytes)
std::fs::write(&target, canonical_bytes.as_ref())
.map_err(|error| format!("安装隔离 AGC Skill 文件失败:{error}"))?;
}
}
@@ -299,6 +322,26 @@ mod tests {
}
}
#[test]
fn skill_content_digest_is_stable_across_lf_and_crlf() {
fn digest(bytes: &[u8]) -> String {
let mut digest = Sha256::new();
update_skill_content_digest(
&mut digest,
"agents/openai.yaml",
"agc-project-structure/agents/openai.yaml",
bytes,
)
.expect("hash reviewed skill text");
format!("{:x}", digest.finalize())
}
assert_eq!(
digest(b"interface:\n display_name: AGC\n short_description: test\n"),
digest(b"interface:\r\n display_name: AGC\n short_description: test\r\n")
);
}
#[test]
fn skill_pack_installs_under_isolated_home_without_full_body_in_index() {
let home = tempfile::tempdir().expect("temporary home");
@@ -308,6 +351,14 @@ mod tests {
.path()
.join(".agents/skills/taonier-art-assets/SKILL.md")
.is_file());
let installed_agent_metadata = std::fs::read(
home.path()
.join(".agents/skills/agc-project-structure/agents/openai.yaml"),
)
.expect("read installed agent metadata");
assert!(!installed_agent_metadata
.windows(2)
.any(|pair| pair == b"\r\n"));
let index = render_agc_skill_pack_index().expect("render index");
assert!(index.contains("taonier-art-assets"));
assert!(index.contains("agc_tools.taonier_prepare_game_art"));
@@ -723,6 +723,18 @@ struct GameCreatorAgentProgressEvent {
message: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorDirectTurnUpdateEvent {
project_path: String,
turn_id: String,
sequence: u64,
status: String,
activity: Option<String>,
accumulated_text: Option<String>,
updated_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfigStatus {
@@ -2074,6 +2074,12 @@ fn stage_asset_canvas_image_with_token_at(
let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?;
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
if matches!(
draft.status,
AssetCanvasDraftStatus::Cancelled | AssetCanvasDraftStatus::Committed
) {
return Err("素材画布草稿已取消或提交,不能继续暂存图片".to_string());
}
if draft.revision != input.expected_draft_revision {
return Ok(StageAssetCanvasImageResult {
status: "conflict".to_string(),
@@ -2096,48 +2102,15 @@ fn stage_asset_canvas_image_with_token_at(
}
None => new_asset_canvas_token()?,
};
if stable_token.is_some() {
match read_staged_image_locked(root, &token) {
Ok((metadata, existing_bytes)) => {
if metadata.project_id != manifest.project_id
|| metadata.draft_id != input.draft_id
|| metadata.draft_revision != draft.revision
|| metadata.media_type != media_type
|| existing_bytes != input.bytes
{
return Err("稳定 staging token 已绑定到不同图片".to_string());
}
return Ok(StageAssetCanvasImageResult {
status: "staged".to_string(),
staged_image_token: Some(token),
draft_id: input.draft_id.clone(),
draft_revision: draft.revision,
media_type: Some(metadata.media_type),
sha256: Some(metadata.sha256),
byte_length: Some(metadata.byte_length),
pixel_width: Some(metadata.pixel_width),
pixel_height: Some(metadata.pixel_height),
expires_at: Some(metadata.expires_at),
draft: None,
});
}
Err(error) if !error.contains("不存在") => return Err(error),
Err(_) => {}
}
}
let extension = media_extension(&media_type)?;
let image_relative = format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{extension}");
install_new_asset_canvas_file(
&resolve_local_project_path(root, &image_relative)?,
&input.bytes,
"素材画布 staging 图片",
)?;
let image_path = resolve_local_project_path(root, &image_relative)?;
let expires_at = asset_canvas_now()
.saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS)
.min(ASSET_CANVAS_MAX_SAFE_INTEGER);
let metadata = AssetCanvasStagedImage {
let expected_metadata = AssetCanvasStagedImage {
schema_version: "game-creator-asset-canvas-staging.v1".to_string(),
project_id: manifest.project_id,
project_id: manifest.project_id.clone(),
draft_id: input.draft_id.clone(),
draft_revision: draft.revision,
staged_image_token: token.clone(),
@@ -2148,6 +2121,84 @@ fn stage_asset_canvas_image_with_token_at(
pixel_height: height,
expires_at,
};
if stable_token.is_some() {
let existing_metadata = read_staged_image_metadata_locked(root, &token)?;
if existing_metadata.as_ref().is_some_and(|metadata| {
metadata.project_id != expected_metadata.project_id
|| metadata.draft_id != expected_metadata.draft_id
|| metadata.draft_revision != expected_metadata.draft_revision
|| metadata.media_type != expected_metadata.media_type
|| metadata.sha256 != expected_metadata.sha256
|| metadata.byte_length != expected_metadata.byte_length
|| metadata.pixel_width != expected_metadata.pixel_width
|| metadata.pixel_height != expected_metadata.pixel_height
}) {
return Err("稳定 staging token 已绑定到不同图片".to_string());
}
for candidate_extension in ["png", "jpg", "webp"] {
if candidate_extension == extension {
continue;
}
let candidate = resolve_local_project_path(
root,
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{candidate_extension}"),
)?;
match fs::symlink_metadata(candidate) {
Ok(_) => return Err("稳定 staging token 已绑定到不同图片".to_string()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return Err("读取素材画布 staging 图片失败".to_string()),
}
}
let existing_image = match fs::symlink_metadata(&image_path) {
Ok(_) => Some(open_and_validate_image_file(
&image_path,
&media_type,
Some(&expected_metadata.sha256),
)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => return Err("读取素材画布 staging 图片失败".to_string()),
};
if existing_image
.as_ref()
.is_some_and(|(bytes, existing_width, existing_height)| {
bytes != &input.bytes || *existing_width != width || *existing_height != height
})
{
return Err("稳定 staging token 已绑定到不同图片".to_string());
}
if existing_image.is_none() {
install_new_asset_canvas_file(&image_path, &input.bytes, "素材画布 staging 图片")?;
}
let metadata = existing_metadata.unwrap_or(expected_metadata);
if read_staged_image_metadata_locked(root, &token)?.is_none() {
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"),
"素材画布 staging 元数据",
&metadata,
16 * 1024,
)?;
}
let (metadata, existing_bytes) = read_staged_image_locked(root, &token)?;
if existing_bytes != input.bytes {
return Err("稳定 staging token 已绑定到不同图片".to_string());
}
return Ok(StageAssetCanvasImageResult {
status: "staged".to_string(),
staged_image_token: Some(token),
draft_id: input.draft_id.clone(),
draft_revision: draft.revision,
media_type: Some(metadata.media_type),
sha256: Some(metadata.sha256),
byte_length: Some(metadata.byte_length),
pixel_width: Some(metadata.pixel_width),
pixel_height: Some(metadata.pixel_height),
expires_at: Some(metadata.expires_at),
draft: None,
});
}
install_new_asset_canvas_file(&image_path, &input.bytes, "素材画布 staging 图片")?;
let metadata = expected_metadata;
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"),
@@ -2867,18 +2918,8 @@ fn read_staged_image_locked(
token: &str,
) -> Result<(AssetCanvasStagedImage, Vec<u8>), String> {
validate_plain_component(token, "stagedImageToken", 128)?;
let metadata = read_agent_runtime_json_sidecar_with_max_bytes::<AssetCanvasStagedImage>(
root,
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"),
"素材画布 staging 元数据",
16 * 1024,
)?
.ok_or_else(|| "素材画布 staging 元数据不存在".to_string())?;
if metadata.staged_image_token != token
|| metadata.schema_version != "game-creator-asset-canvas-staging.v1"
{
return Err("素材画布 staging 身份无效".to_string());
}
let metadata = read_staged_image_metadata_locked(root, token)?
.ok_or_else(|| "素材画布 staging 元数据不存在".to_string())?;
let extension = media_extension(&metadata.media_type)?;
let path = resolve_local_project_path(
root,
@@ -2895,6 +2936,27 @@ fn read_staged_image_locked(
Ok((metadata, bytes))
}
fn read_staged_image_metadata_locked(
root: &Path,
token: &str,
) -> Result<Option<AssetCanvasStagedImage>, String> {
validate_plain_component(token, "stagedImageToken", 128)?;
let metadata = read_agent_runtime_json_sidecar_with_max_bytes::<AssetCanvasStagedImage>(
root,
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"),
"素材画布 staging 元数据",
16 * 1024,
)?;
if let Some(metadata) = metadata.as_ref() {
if metadata.staged_image_token != token
|| metadata.schema_version != "game-creator-asset-canvas-staging.v1"
{
return Err("素材画布 staging 身份无效".to_string());
}
}
Ok(metadata)
}
fn committed_result_from_ledger(
root: &Path,
ledger: &AssetCanvasCommitLedger,
File diff suppressed because it is too large Load Diff
@@ -323,6 +323,151 @@ fn stage_image(fixture: &Fixture, draft: &AssetCanvasDraft) -> StageAssetCanvasI
.expect("stage image")
}
#[test]
fn stable_staging_token_repairs_image_first_and_metadata_first_partial_installs() {
for image_first in [true, false] {
let fixture = initialize_fixture();
let token = Uuid::new_v4().to_string();
let staging_directory = fixture
.root()
.join(format!("{ASSET_CANVAS_ROOT}/staging/{token}"));
fs::create_dir_all(&staging_directory).expect("create partial staging directory");
let expires_at = asset_canvas_now()
.saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS)
.min(ASSET_CANVAS_MAX_SAFE_INTEGER);
if image_first {
fs::write(staging_directory.join("image.png"), &fixture.png)
.expect("simulate image-first crash");
} else {
let metadata = AssetCanvasStagedImage {
schema_version: "game-creator-asset-canvas-staging.v1".to_string(),
project_id: PROJECT_ID.to_string(),
draft_id: fixture.draft.draft_id.clone(),
draft_revision: fixture.draft.revision,
staged_image_token: token.clone(),
media_type: "image/png".to_string(),
sha256: asset_canvas_sha256(&fixture.png),
byte_length: fixture.png.len() as u64,
pixel_width: 4,
pixel_height: 3,
expires_at,
};
write_agent_runtime_json_sidecar_with_max_bytes(
fixture.root(),
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"),
"素材画布 staging 元数据",
&metadata,
16 * 1024,
)
.expect("simulate metadata-first crash");
}
let repaired = stage_asset_canvas_image_with_token_at(
fixture.root(),
&StageAssetCanvasImageInput {
project_path: project_path(fixture.root()),
expected_project_id: PROJECT_ID.to_string(),
draft_id: fixture.draft.draft_id.clone(),
expected_draft_revision: fixture.draft.revision,
media_type: "image/png".to_string(),
bytes: fixture.png.clone(),
},
Some(&token),
)
.expect("repair partial stable staging install");
assert_eq!(repaired.status, "staged");
assert_eq!(repaired.staged_image_token.as_deref(), Some(token.as_str()));
let (metadata, bytes) =
read_staged_image_locked(fixture.root(), &token).expect("read repaired staging");
assert_eq!(metadata.project_id, PROJECT_ID);
assert_eq!(metadata.draft_id, fixture.draft.draft_id);
assert_eq!(bytes, fixture.png);
let replay = stage_asset_canvas_image_with_token_at(
fixture.root(),
&StageAssetCanvasImageInput {
project_path: project_path(fixture.root()),
expected_project_id: PROJECT_ID.to_string(),
draft_id: fixture.draft.draft_id.clone(),
expected_draft_revision: fixture.draft.revision,
media_type: "image/png".to_string(),
bytes: fixture.png.clone(),
},
Some(&token),
)
.expect("replay repaired stable staging install");
assert_eq!(replay.staged_image_token, repaired.staged_image_token);
}
}
#[test]
fn stable_staging_token_rejects_conflicting_partial_install() {
let fixture = initialize_fixture();
let token = Uuid::new_v4().to_string();
let staging_directory = fixture
.root()
.join(format!("{ASSET_CANVAS_ROOT}/staging/{token}"));
fs::create_dir_all(&staging_directory).expect("create conflicting staging directory");
fs::write(staging_directory.join("image.png"), &fixture.png)
.expect("write conflicting image-first residue");
let different_png = png_bytes([220, 31, 54, 255]);
let error = stage_asset_canvas_image_with_token_at(
fixture.root(),
&StageAssetCanvasImageInput {
project_path: project_path(fixture.root()),
expected_project_id: PROJECT_ID.to_string(),
draft_id: fixture.draft.draft_id.clone(),
expected_draft_revision: fixture.draft.revision,
media_type: "image/png".to_string(),
bytes: different_png,
},
Some(&token),
)
.expect_err("conflicting half-installed stable token must fail closed");
assert!(error.contains("摘要不匹配") || error.contains("绑定到不同图片"));
assert!(read_staged_image_metadata_locked(fixture.root(), &token)
.expect("read missing conflicting metadata")
.is_none());
assert_eq!(
fs::read(staging_directory.join("image.png")).expect("read preserved residue"),
fixture.png
);
}
#[test]
fn cancelled_draft_rejects_staging_before_writing_token() {
let fixture = initialize_fixture();
discard_asset_canvas_draft_at(
fixture.root(),
&DiscardAssetCanvasDraftInput {
project_path: project_path(fixture.root()),
expected_project_id: PROJECT_ID.to_string(),
draft_id: fixture.draft.draft_id.clone(),
expected_draft_revision: fixture.draft.revision,
},
)
.expect("cancel staging fixture draft");
let token = Uuid::new_v4().to_string();
let error = stage_asset_canvas_image_with_token_at(
fixture.root(),
&StageAssetCanvasImageInput {
project_path: project_path(fixture.root()),
expected_project_id: PROJECT_ID.to_string(),
draft_id: fixture.draft.draft_id.clone(),
expected_draft_revision: fixture.draft.revision,
media_type: "image/png".to_string(),
bytes: fixture.png.clone(),
},
Some(&token),
)
.expect_err("cancelled draft must reject stable staging");
assert!(error.contains("草稿已取消或提交"));
assert!(!fixture
.root()
.join(format!("{ASSET_CANVAS_ROOT}/staging/{token}"))
.exists());
}
fn commit_input(
fixture: &Fixture,
draft: &AssetCanvasDraft,
+240 -43
View File
@@ -51,6 +51,7 @@ import type {
ChatMessage,
GameCreatorAgentRuntimeUpdateEvent,
GameCreatorChatAgentReply,
GameCreatorDirectTurnUpdateEvent,
GameCreatorLlmConfigStatus,
GameCreatorManifestInvalidatedEvent,
GameCreatorRoleAgentChatStreamEvent,
@@ -258,6 +259,37 @@ const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY =
const DIRECT_CODEX_PRODUCT_RUNTIME = true;
const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:';
function directCodexActivityText(activity: string | null | undefined) {
switch (activity) {
case 'request-accepted':
return '已接收需求';
case 'understanding':
return '正在理解需求';
case 'project-inspection':
return '正在检查项目';
case 'file-change':
return '正在修改项目文件';
case 'controlled-tool':
return '正在执行受控工具';
case 'validation':
return '正在验证结果';
case 'response-finalization':
return '正在整理回复';
case 'none':
default:
return '陶泥儿正在处理';
}
}
const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([
'accepted',
'running',
'streaming',
'finalizing',
'completed',
'failed',
]);
function directCodexConversationMessageId(
turnId: string,
role: ChatMessage['role'],
@@ -747,6 +779,20 @@ export function App({
);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
const [directCodexProgress, setDirectCodexProgress] = useState('');
const [directCodexProgressUpdatedAt, setDirectCodexProgressUpdatedAt] =
useState<number | null>(null);
const [directCodexTransientReply, setDirectCodexTransientReply] =
useState('');
const [
directCodexTransientReplyUpdatedAt,
setDirectCodexTransientReplyUpdatedAt,
] = useState<number | null>(null);
const activeDirectCodexTurnRef = useRef<{
projectPath: string;
turnId: string;
lastSequence: number;
receivedDirectUpdate: boolean;
} | null>(null);
const directCodexConversationTurnSequenceRef = useRef(0);
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
string | null
@@ -766,6 +812,28 @@ export function App({
}
return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`;
}
function resetDirectCodexTurn() {
activeDirectCodexTurnRef.current = null;
setDirectCodexProgress('');
setDirectCodexProgressUpdatedAt(null);
setDirectCodexTransientReply('');
setDirectCodexTransientReplyUpdatedAt(null);
}
function clearDirectCodexTransientReply(projectPath: string, turnId: string) {
const activeTurn = activeDirectCodexTurnRef.current;
if (
activeTurn?.projectPath !== projectPath ||
activeTurn.turnId !== turnId
) {
return false;
}
activeDirectCodexTurnRef.current = null;
setDirectCodexTransientReply('');
setDirectCodexTransientReplyUpdatedAt(null);
return true;
}
const [projectSupervisorRuntime, setProjectSupervisorRuntime] =
useState<AgentRuntimeState | null>(null);
const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] =
@@ -1444,6 +1512,76 @@ export function App({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameChatOnly, initialProjectPath, projectSupervisorOnly]);
useEffect(() => {
if (!directCodexProductRuntime) {
return;
}
const listen = window.__TAURI__?.event?.listen;
if (!listen) {
return;
}
let cleanup: (() => void) | null = null;
let disposed = false;
void listen<GameCreatorDirectTurnUpdateEvent>(
'game-creator-direct-turn-update',
(event) => {
const payload = event.payload;
const activeTurn = activeDirectCodexTurnRef.current;
if (
!activeTurn ||
payload.projectPath !== localProjectPathRef.current ||
payload.projectPath !== activeTurn.projectPath ||
payload.turnId !== activeTurn.turnId ||
!Number.isSafeInteger(payload.sequence) ||
payload.sequence < 0 ||
!DIRECT_CODEX_TURN_UPDATE_STATUSES.has(payload.status) ||
payload.sequence <= activeTurn.lastSequence
) {
return;
}
activeTurn.lastSequence = payload.sequence;
activeTurn.receivedDirectUpdate = true;
const updatedAt =
Number.isFinite(payload.updatedAt) && payload.updatedAt > 0
? payload.updatedAt
: Date.now();
if (payload.status === 'failed') {
activeDirectCodexTurnRef.current = null;
setDirectCodexProgress('处理失败,正在同步错误');
setDirectCodexProgressUpdatedAt(updatedAt);
setDirectCodexTransientReply('');
setDirectCodexTransientReplyUpdatedAt(null);
return;
}
if (payload.status === 'completed') {
setDirectCodexProgress('回复已生成,正在提交');
setDirectCodexProgressUpdatedAt(updatedAt);
} else if (payload.activity != null) {
setDirectCodexProgress(directCodexActivityText(payload.activity));
setDirectCodexProgressUpdatedAt(updatedAt);
}
if (typeof payload.accumulatedText === 'string') {
setDirectCodexTransientReply(payload.accumulatedText);
setDirectCodexTransientReplyUpdatedAt(updatedAt);
}
},
)
.then((unlisten) => {
if (disposed) {
unlisten();
return;
}
cleanup = unlisten;
})
.catch(() => {
// The existing safe progress event remains the activity fallback.
});
return () => {
disposed = true;
cleanup?.();
};
}, [directCodexProductRuntime]);
useEffect(() => {
if (projectSupervisorOnly && !directCodexProductRuntime) {
return;
@@ -1459,7 +1597,16 @@ export function App({
return;
}
if (directCodexProductRuntime) {
const activeTurn = activeDirectCodexTurnRef.current;
if (
!activeTurn ||
activeTurn.projectPath !== event.payload.projectPath ||
activeTurn.receivedDirectUpdate
) {
return;
}
setDirectCodexProgress(event.payload.message);
setDirectCodexProgressUpdatedAt(Date.now());
return;
}
setMessages((current) => [
@@ -2132,7 +2279,9 @@ export function App({
useLayoutEffect(() => {
if (
(!supervisorChatOnly && !gameChatOnly) ||
(!supervisorChatOnly &&
!gameChatOnly &&
!(projectSupervisorOnly && directCodexProductRuntime)) ||
!supervisorChatShouldFollowLatestRef.current
) {
return;
@@ -2146,7 +2295,13 @@ export function App({
projectSupervisorResponseStream?.sequence,
projectSupervisorRuntime?.updatedAt,
projectSupervisorRuntimeError,
directCodexProgress,
directCodexProgressUpdatedAt,
directCodexTransientReply,
directCodexTransientReplyUpdatedAt,
directCodexProductRuntime,
gameChatOnly,
projectSupervisorOnly,
supervisorChatOnly,
]);
@@ -3259,6 +3414,7 @@ export function App({
const projectScopeVersion = projectScopeVersionRef.current + 1;
projectScopeVersionRef.current = projectScopeVersion;
resetProjectSupervisorState();
resetDirectCodexTurn();
if (
gameChatAutoPreviewAuthorizationRef.current?.projectPath !==
nextProjectPath
@@ -5937,20 +6093,17 @@ export function App({
const directInvoke = resolveTauriInvoke();
const directProjectPath = resolveChatProjectPath(localProject);
if (directProjectPath && directInvoke) {
const directAssistantMessageId = directConversationTurnId
? directCodexConversationMessageId(
directConversationTurnId,
'assistant',
)
: undefined;
const clientTurnId =
directConversationTurnId ?? createDirectCodexConversationTurnId();
const directAssistantMessageId = directCodexConversationMessageId(
clientTurnId,
'assistant',
);
const appendDirectUserMessageIfMissing = (
current: ChatMessage[],
): ChatMessage[] => {
if (!directConversationTurnId) {
return current;
}
const directUserMessageId = directCodexConversationMessageId(
directConversationTurnId,
clientTurnId,
'user',
);
return current.some(
@@ -5968,17 +6121,50 @@ export function App({
},
];
};
const appendDirectAssistantMessage = (
current: ChatMessage[],
text: string,
): ChatMessage[] => {
const nextMessage: ChatMessage = {
role: 'assistant',
text,
runtimeOwned: true,
messageId: directAssistantMessageId,
updatedAt: Date.now(),
};
const withUser = appendDirectUserMessageIfMissing(current);
const existingIndex = withUser.findIndex(
(message) => message.messageId === directAssistantMessageId,
);
if (existingIndex < 0) {
return [...withUser, nextMessage];
}
return withUser.map((message, index) =>
index === existingIndex ? nextMessage : message,
);
};
activeDirectCodexTurnRef.current = {
projectPath: directProjectPath,
turnId: clientTurnId,
lastSequence: -1,
receivedDirectUpdate: false,
};
setChatAgentBusy(true);
setDirectCodexProgress('已发送消息,正在等待陶泥儿回复');
setDirectCodexProgressUpdatedAt(Date.now());
setDirectCodexTransientReply('');
setDirectCodexTransientReplyUpdatedAt(null);
setProjectSupervisorRuntimeError('');
try {
const directTurnInput: {
projectPath: string;
prompt: string;
clientTurnId: string;
creationType?: HomeCreationType;
} = {
projectPath: directProjectPath,
prompt,
clientTurnId,
};
if (creationType) {
directTurnInput.creationType = creationType;
@@ -5988,19 +6174,12 @@ export function App({
directTurnInput,
);
if (localProjectPathRef.current === directProjectPath) {
setMessages((current) => [
...appendDirectUserMessageIfMissing(current),
{
role: 'assistant',
text: reply,
runtimeOwned: true,
...(directAssistantMessageId
? { messageId: directAssistantMessageId }
: {}),
updatedAt: Date.now(),
},
]);
setDirectCodexProgress('陶泥儿已回复,正在刷新项目状态');
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
setMessages((current) =>
appendDirectAssistantMessage(current, reply),
);
setDirectCodexProgress('正在刷新项目状态');
setDirectCodexProgressUpdatedAt(Date.now());
await refreshDirectProjectManifest(directProjectPath);
}
} catch (error) {
@@ -6012,19 +6191,11 @@ export function App({
true,
);
if (localProjectPathRef.current === directProjectPath) {
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
setProjectSupervisorRuntimeError(visibleMessage);
setMessages((current) => [
...appendDirectUserMessageIfMissing(current),
{
role: 'assistant',
text: visibleMessage,
runtimeOwned: true,
...(directAssistantMessageId
? { messageId: directAssistantMessageId }
: {}),
updatedAt: Date.now(),
},
]);
setMessages((current) =>
appendDirectAssistantMessage(current, visibleMessage),
);
}
} finally {
try {
@@ -6034,6 +6205,14 @@ export function App({
} finally {
setChatAgentBusy(false);
setDirectCodexProgress('');
const activeTurn = activeDirectCodexTurnRef.current;
if (
!activeTurn ||
(activeTurn.projectPath === directProjectPath &&
activeTurn.turnId === clientTurnId)
) {
resetDirectCodexTurn();
}
}
}
return;
@@ -11290,7 +11469,7 @@ export function App({
if (!prompt || chatAgentBusy) {
return;
}
if (supervisorChatOnly || gameChatOnly) {
if (supervisorChatOnly || gameChatOnly || directCodexProductRuntime) {
supervisorChatShouldFollowLatestRef.current = true;
}
const directConversationTurnId = directCodexProductRuntime
@@ -11368,12 +11547,20 @@ export function App({
allowAdvancedExternalEditorConfig ? false : runtimeConfigOpen
}
runtimeError={projectSupervisorRuntimeError}
directActivity={directCodexProductRuntime ? directCodexProgress : ''}
directActivityUpdatedAt={
directCodexProductRuntime ? directCodexProgressUpdatedAt : null
}
transientReply={
directCodexProductRuntime
? directCodexProgress
? directCodexTransientReply
: projectSupervisorTransientReply
}
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
transientReplyUpdatedAt={
directCodexProductRuntime
? directCodexTransientReplyUpdatedAt
: projectSupervisorResponseStream?.updatedAt
}
hasConversationControls={
directCodexProductRuntime
? false
@@ -11427,12 +11614,20 @@ export function App({
runtime={projectSupervisorRuntime}
runtimeConfigOpen={runtimeConfigOpen}
runtimeError={projectSupervisorRuntimeError}
directActivity={directCodexProductRuntime ? directCodexProgress : ''}
directActivityUpdatedAt={
directCodexProductRuntime ? directCodexProgressUpdatedAt : null
}
transientReply={
directCodexProductRuntime
? directCodexProgress
? directCodexTransientReply
: projectSupervisorTransientReply
}
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
transientReplyUpdatedAt={
directCodexProductRuntime
? directCodexTransientReplyUpdatedAt
: projectSupervisorResponseStream?.updatedAt
}
hasConversationControls={projectSupervisorHasConversationControls}
hiddenConversationCount={hiddenConversationCount}
needsUserInput={projectSupervisorNeedsUserInput}
@@ -11448,7 +11643,9 @@ export function App({
<ProjectSupervisorView
chatInput={chatInput}
directCodex={directCodexProductRuntime}
directActivity={directCodexProductRuntime ? directCodexProgress : ''}
hiddenConversationCount={hiddenConversationCount}
messagesRef={supervisorChatMessagesRef}
needsUserInput={
directCodexProductRuntime ? false : projectSupervisorNeedsUserInput
}
@@ -11457,7 +11654,7 @@ export function App({
onChatInputChange={setChatInput}
onConfirmConfirmation={confirmUiCommand}
onConfirmPendingCommand={() => void handlePendingCommandConfirm()}
onScroll={handleConversationScroll}
onScroll={handleSupervisorChatScroll}
onShowEarlierMessages={showEarlierConversationMessages}
onSubmit={handleProjectSupervisorOnlySubmit}
pendingConfirmation={
@@ -11467,7 +11664,7 @@ export function App({
projectPath={localProject?.projectPath ?? projectPath}
transientReply={
directCodexProductRuntime
? directCodexProgress
? directCodexTransientReply
: projectSupervisorTransientReply
}
visibleMessages={visibleMessages}
@@ -806,6 +806,34 @@ export interface AgentProgressEvent {
message: string;
}
export type GameCreatorDirectTurnUpdateStatus =
| 'accepted'
| 'running'
| 'streaming'
| 'finalizing'
| 'completed'
| 'failed';
export type GameCreatorDirectTurnActivity =
| 'request-accepted'
| 'understanding'
| 'project-inspection'
| 'file-change'
| 'controlled-tool'
| 'validation'
| 'response-finalization'
| 'none';
export interface GameCreatorDirectTurnUpdateEvent {
projectPath: string;
turnId: string;
sequence: number;
status: GameCreatorDirectTurnUpdateStatus;
activity?: GameCreatorDirectTurnActivity | null;
accumulatedText?: string | null;
updatedAt: number;
}
export interface AgentRunControlResult {
runId: string;
status: string;
@@ -68,6 +68,7 @@ import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -345,6 +346,25 @@ function runtimeGenerationTaskFromRecord(
};
}
const MODAL_FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
function modalFocusableElements(dialog: HTMLElement) {
return Array.from(
dialog.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR),
).filter(
(element) =>
element.getAttribute('aria-hidden') !== 'true' &&
!element.closest<HTMLElement>('[inert]'),
);
}
type DragState =
| {
kind: 'pan';
@@ -777,6 +797,10 @@ export function AssetCanvasSurface({
const quickEditOpenRef = useRef(quickEditOpen);
const generationStopButtonRef = useRef<HTMLButtonElement | null>(null);
const modalInitialFocusRef = useRef<HTMLButtonElement | null>(null);
const modalDialogRef = useRef<HTMLElement | null>(null);
const modalReturnFocusRef = useRef<HTMLElement | null>(null);
const modalFallbackFocusRef = useRef<HTMLButtonElement | null>(null);
const modalWasOpenRef = useRef(false);
const generationDialogRef = useRef(generationDialog);
const exitDialogOpenRef = useRef(exitDialogOpen);
const serviceIdentityDialogOpenRef = useRef(serviceIdentityDialogOpen);
@@ -1871,6 +1895,30 @@ export function AssetCanvasSurface({
[captureHistory, markDirty],
);
const deleteSelected = useCallback(() => {
const layerIds = selectedLayerIds.filter((layerId) =>
layersRef.current.some((layer) => layer.id === layerId),
);
if (
lifecycleRef.current.kind !== 'canvas.editing' ||
backgroundInteractionLockedRef.current ||
layerIds.length === 0
) {
return;
}
captureHistory({
type: 'delete-image',
count: layerIds.length,
layerIds,
});
setLayers(
(current) =>
removeCanvasLayers(current, layerIds) as RuntimeCanvasLayer[],
);
setSelectedLayerIds([]);
markDirty();
}, [captureHistory, markDirty, selectedLayerIds]);
const saveAsset = useCallback(() => {
if (
lifecycleRef.current.kind !== 'canvas.editing' ||
@@ -2727,12 +2775,87 @@ export function AssetCanvasSurface({
}
}, [generationInteractionLocked]);
useEffect(() => {
if (modalOpen) {
modalInitialFocusRef.current?.focus();
const dismissActiveModal = useCallback(() => {
if (generationDialogRef.current !== null) {
closeGenerationDialog();
return;
}
if (serviceIdentityDialogOpenRef.current) {
if (!serviceIdentityPending) {
setServiceIdentityDialogOpen(false);
}
return;
}
if (exitDialogOpenRef.current && !exitActionPending) {
setExitDialogOpen(false);
}
}, [closeGenerationDialog, exitActionPending, serviceIdentityPending]);
useLayoutEffect(() => {
if (!modalOpen) {
if (!modalWasOpenRef.current) return;
modalWasOpenRef.current = false;
const returnFocus = modalReturnFocusRef.current;
modalReturnFocusRef.current = null;
const focusTarget =
returnFocus?.isConnected === true
? returnFocus
: modalFallbackFocusRef.current;
focusTarget?.focus();
return;
}
if (!modalWasOpenRef.current) {
const activeElement = document.activeElement;
modalReturnFocusRef.current =
activeElement instanceof HTMLElement &&
activeElement !== document.body &&
activeElement !== document.documentElement
? activeElement
: null;
modalWasOpenRef.current = true;
}
(modalInitialFocusRef.current ?? modalDialogRef.current)?.focus();
}, [generationDialog, modalOpen, serviceIdentityDialogOpen]);
useEffect(() => {
if (!modalOpen) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
dismissActiveModal();
return;
}
if (event.key !== 'Tab') return;
const dialog = modalDialogRef.current;
if (!dialog) return;
const focusable = modalFocusableElements(dialog);
if (!focusable.length) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeElement = document.activeElement;
if (
event.shiftKey &&
(activeElement === first || !dialog.contains(activeElement))
) {
event.preventDefault();
last?.focus();
} else if (
!event.shiftKey &&
(activeElement === last || !dialog.contains(activeElement))
) {
event.preventDefault();
first?.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [dismissActiveModal, modalOpen]);
const failurePresentation =
lifecycle.kind === 'canvas.failed'
? assetCanvasFailurePresentation(lifecycle)
@@ -2768,6 +2891,7 @@ export function AssetCanvasSurface({
<header
className="asset-canvas-surface__toolbar-shell"
inert={backgroundInteractionLocked || undefined}
aria-hidden={modalOpen || undefined}
>
<CanvasToolbar
label="素材画布工具栏"
@@ -2832,6 +2956,13 @@ export function AssetCanvasSurface({
onClick={redo}
disabled={!canRedo || backgroundInteractionLocked}
/>
<CanvasChromeButton
label="删除"
title="删除选中图层"
icon={<Trash2 size={15} aria-hidden="true" />}
onClick={deleteSelected}
disabled={!selectedLayerIds.length || backgroundInteractionLocked}
/>
</CanvasToolbarGroup>
{stableScope.intent === 'create' ||
activeServiceIdentityConfirmation ? (
@@ -2840,6 +2971,7 @@ export function AssetCanvasSurface({
<CanvasToolbarGroup>
{stableScope.intent === 'create' ? (
<CanvasChromeButton
ref={modalFallbackFocusRef}
label="AI 生成图片"
icon={<Sparkles size={15} aria-hidden="true" />}
onClick={() => openGenerationDialog()}
@@ -2929,8 +3061,10 @@ export function AssetCanvasSurface({
role="presentation"
>
<section
ref={modalDialogRef}
className="asset-canvas-surface__generation-dialog"
role="dialog"
tabIndex={-1}
aria-modal="true"
aria-labelledby="asset-canvas-generation-title"
>
@@ -3099,8 +3233,10 @@ export function AssetCanvasSurface({
role="presentation"
>
<section
ref={modalDialogRef}
className="asset-canvas-surface__generation-dialog"
role="dialog"
tabIndex={-1}
aria-modal="true"
aria-labelledby="asset-canvas-service-identity-title"
>
@@ -3160,8 +3296,10 @@ export function AssetCanvasSurface({
role="presentation"
>
<section
ref={modalDialogRef}
className="asset-canvas-surface__generation-dialog"
role="dialog"
tabIndex={-1}
aria-modal="true"
aria-labelledby="asset-canvas-exit-title"
>
@@ -3206,6 +3344,8 @@ export function AssetCanvasSurface({
{lifecycle.kind === 'canvas.generating' ? (
<section
className="asset-canvas-surface__operation-overlay"
inert={modalOpen || undefined}
aria-hidden={modalOpen || undefined}
aria-live="polite"
aria-label="图片生成进度"
>
@@ -3349,6 +3489,7 @@ export function AssetCanvasSurface({
backgroundColor={backgroundColor}
isInteractionPaused={backgroundInteractionLocked}
inert={backgroundInteractionLocked || undefined}
aria-hidden={modalOpen || undefined}
isPanning={dragRef.current?.kind === 'pan'}
onPointerDown={(event) => {
if (backgroundInteractionLockedRef.current) return;
@@ -3959,7 +4100,12 @@ export function AssetCanvasSurface({
) : null}
</aside>
<footer className="asset-canvas-surface__status" role="status">
<footer
className="asset-canvas-surface__status"
role="status"
inert={modalOpen || undefined}
aria-hidden={modalOpen || undefined}
>
<span>
{lifecycle.kind === 'canvas.editing'
? '画布可编辑'
@@ -3,6 +3,7 @@ import type {
ComponentProps,
Dispatch,
FormEventHandler,
RefObject,
SetStateAction,
UIEventHandler,
} from 'react';
@@ -33,7 +34,9 @@ type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
type ProjectSupervisorViewProps = RuntimePanelProps & {
chatInput: string;
directCodex?: boolean;
directActivity?: string;
hiddenConversationCount: number;
messagesRef: RefObject<HTMLDivElement | null>;
needsUserInput: boolean;
onCancelConfirmation: () => void;
onCancelPendingCommand: () => void;
@@ -56,7 +59,9 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
export function ProjectSupervisorView({
chatInput,
directCodex = false,
directActivity = '',
hiddenConversationCount,
messagesRef,
needsUserInput,
onCancelConfirmation,
onCancelPendingCommand,
@@ -83,6 +88,7 @@ export function ProjectSupervisorView({
>
<div className="project-supervisor-conversation">
<div
ref={messagesRef}
className="message-list project-supervisor-message-list"
aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'}
onScroll={onScroll}
@@ -107,12 +113,28 @@ export function ProjectSupervisorView({
)}
</p>
))}
{transientReply ? (
{directCodex &&
(runtimePanelProps.controlBusy || Boolean(transientReply)) ? (
<section
className="project-supervisor-process-card"
aria-label="陶泥儿执行过程"
aria-live="polite"
aria-atomic="false"
role="status"
data-runtime-owned="true"
>
<header>
<span aria-hidden="true" />
<strong>{directActivity || '陶泥儿正在处理'}</strong>
</header>
{transientReply ? (
<p aria-label="陶泥儿实时回复">{transientReply}</p>
) : null}
</section>
) : transientReply ? (
<p
className="message message--assistant"
aria-label={
directCodex ? '陶泥儿实时回复' : '项目总控 Agent 实时回复'
}
aria-label="项目总控 Agent 实时回复"
aria-live="polite"
data-runtime-owned="true"
>
@@ -903,6 +903,8 @@ type SupervisorChatOnlyViewProps = {
runtime: AgentRuntimeState | null;
runtimeConfigOpen: boolean;
runtimeError: string;
directActivity?: string;
directActivityUpdatedAt?: number | null;
transientReply: string;
transientReplyUpdatedAt?: number | null;
hasConversationControls: boolean;
@@ -948,6 +950,8 @@ export function SupervisorChatOnlyView({
runtime,
runtimeConfigOpen,
runtimeError,
directActivity = '',
directActivityUpdatedAt = null,
transientReply,
transientReplyUpdatedAt = null,
hasConversationControls,
@@ -1059,11 +1063,13 @@ export function SupervisorChatOnlyView({
0,
)
: 0;
const latestActivityAt = runtime
? [runtime, ...collaboratingRuntimes]
.flatMap(gameChatRuntimeActivityTimes)
.reduce((latest, candidate) => Math.max(latest, candidate), 0)
: 0;
const latestActivityAt = directCodex
? (directActivityUpdatedAt ?? transientReplyUpdatedAt ?? 0)
: runtime
? [runtime, ...collaboratingRuntimes]
.flatMap(gameChatRuntimeActivityTimes)
.reduce((latest, candidate) => Math.max(latest, candidate), 0)
: 0;
const activeCollaboratingRuntimes = collaboratingRuntimes.filter(
(candidate) => !isAgentRuntimeTerminalState(candidate),
);
@@ -1358,7 +1364,7 @@ export function SupervisorChatOnlyView({
{supervisorProgress?.currentWork ||
(projectReady
? directCodex
? '直接与陶泥儿对话'
? directActivity || '直接与陶泥儿对话'
: '等待新的运行事件'
: '请选择项目目录')}
</span>
+75 -13
View File
@@ -1597,7 +1597,8 @@ textarea {
.launcher-development-canvas {
display: grid;
min-height: 300px;
background: linear-gradient(#f3f4f6 1px, transparent 1px),
background:
linear-gradient(#f3f4f6 1px, transparent 1px),
linear-gradient(90deg, #f3f4f6 1px, transparent 1px), #fff;
background-size: 24px 24px;
place-items: center;
@@ -2434,7 +2435,8 @@ textarea {
min-width: 0;
min-height: 0;
overflow: hidden;
background: linear-gradient(45deg, #101827 25%, transparent 25%),
background:
linear-gradient(45deg, #101827 25%, transparent 25%),
linear-gradient(-45deg, #101827 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #101827 75%),
linear-gradient(-45deg, transparent 75%, #101827 75%), #0b1220;
@@ -2596,6 +2598,65 @@ textarea {
background: #fff;
}
.project-supervisor-process-card {
display: grid;
gap: 9px;
margin-top: 12px;
padding: 12px 14px;
border: 1px solid #b9d8c5;
border-radius: 8px;
background: #f1f8f3;
color: #1f3d2a;
}
.project-supervisor-process-card > header {
display: flex;
align-items: center;
gap: 8px;
}
.project-supervisor-process-card > header > span {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
background: #2f855a;
box-shadow: 0 0 0 4px rgb(47 133 90 / 14%);
animation: project-supervisor-process-pulse 1.2s ease-in-out infinite;
}
@keyframes project-supervisor-process-pulse {
50% {
opacity: 0.42;
transform: scale(0.76);
}
}
@media (prefers-reduced-motion: reduce) {
.project-supervisor-process-card > header > span {
animation: none;
}
}
.project-supervisor-process-card p {
margin: 0;
color: #263142;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.game-workbench-chat .project-supervisor-process-card {
border-color: var(--platform-surface-border);
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
.game-workbench-chat .project-supervisor-process-card p {
color: var(--platform-text-base);
}
.project-supervisor-surface .agent-runtime-status {
margin: 0;
}
@@ -2950,24 +3011,28 @@ textarea {
}
.launcher-card-collage span:nth-child(1) {
background: radial-gradient(circle at 45% 38%, #6b5c43 0 18%, transparent 19%),
background:
radial-gradient(circle at 45% 38%, #6b5c43 0 18%, transparent 19%),
radial-gradient(circle at 62% 54%, #2f2a23 0 25%, transparent 26%),
linear-gradient(135deg, #91a17e, #2c3327);
}
.launcher-card-collage span:nth-child(2) {
background: radial-gradient(circle at 58% 45%, #b28b5a 0 16%, transparent 17%),
background:
radial-gradient(circle at 58% 45%, #b28b5a 0 16%, transparent 17%),
radial-gradient(circle at 48% 55%, #302114 0 24%, transparent 25%),
linear-gradient(135deg, #d9e8ef, #9fb0a0);
}
.launcher-card-collage span:nth-child(3) {
background: radial-gradient(circle at 50% 54%, #eac9c0 0 22%, transparent 23%),
background:
radial-gradient(circle at 50% 54%, #eac9c0 0 22%, transparent 23%),
linear-gradient(135deg, #f7d9d5, #dab6aa);
}
.launcher-card-collage span:nth-child(4) {
background: radial-gradient(circle at 56% 50%, #32271b 0 22%, transparent 23%),
background:
radial-gradient(circle at 56% 50%, #32271b 0 22%, transparent 23%),
linear-gradient(135deg, #8e6c4e, #2b211b);
}
@@ -2991,12 +3056,8 @@ textarea {
.launcher-card-pattern > div {
display: grid;
grid-template-columns: repeat(2, 1fr);
background: linear-gradient(
45deg,
transparent 46%,
#fff 46% 54%,
transparent 54%
),
background:
linear-gradient(45deg, transparent 46%, #fff 46% 54%, transparent 54%),
linear-gradient(135deg, #55405a, #e4b2a7);
}
@@ -5403,7 +5464,8 @@ iframe.preview-frame {
.game-resource-card-visual,
.game-resource-card[data-preview-kind='media-image'] .game-resource-card-visual,
.game-resource-card[data-preview-kind='video'] .game-resource-card-visual {
background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%),
background:
linear-gradient(45deg, #f1ebe7 25%, transparent 25%),
linear-gradient(-45deg, #f1ebe7 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f1ebe7 75%),
linear-gradient(-45deg, transparent 75%, #f1ebe7 75%), #faf7f5;
@@ -1364,6 +1364,7 @@ export function registerHomeProjectCreationTests() {
projectPath: automaticProjectPath,
prompt: '你好,今天多少号',
creationType: 'art',
clientTurnId: expect.any(String),
});
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_home_direct_codex',
@@ -1453,6 +1454,7 @@ export function registerHomeProjectCreationTests() {
projectPath: automaticProjectPath,
prompt: '按这个角色做游戏',
creationType: 'game',
clientTurnId: expect.any(String),
});
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_home_direct_codex',
@@ -1691,6 +1693,7 @@ export function registerHomeProjectCreationTests() {
{
projectPath,
prompt: '继续修改已有项目',
clientTurnId: expect.any(String),
},
);
});
@@ -4495,6 +4495,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
projectPath,
prompt: '从旧任务状态继续生成,但使用 direct Codex',
clientTurnId: expect.any(String),
});
expect(invoke).not.toHaveBeenCalledWith(
'cancel_game_creator_agent_runtime_task',
@@ -9314,6 +9315,28 @@ export function registerProjectSupervisorSurfaceTests() {
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
let directTurnUpdateHandler:
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
const listen = vi.fn(
async (
eventName: string,
handler: (event: { payload: Record<string, unknown> }) => void,
) => {
if (eventName === 'game-creator-direct-turn-update') {
directTurnUpdateHandler = handler;
return () => {
if (directTurnUpdateHandler === handler) {
directTurnUpdateHandler = null;
}
};
}
return supervisorHarness.listen(
eventName,
handler as Parameters<typeof supervisorHarness.listen>[1],
);
},
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
@@ -9338,7 +9361,7 @@ export function registerProjectSupervisorSurfaceTests() {
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
event: { listen },
};
renderLauncherProjectsAt('/?launcher');
@@ -9375,11 +9398,16 @@ export function registerProjectSupervisorSurfaceTests() {
([command]) => command === 'read_project_permission_policy',
).length;
const directReply = createDeferred<string>();
const firstDirectReply = createDeferred<string>();
const secondDirectReply = createDeferred<string>();
let directReplyCount = 0;
invoke.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'chat_with_game_creator_direct_codex') {
return directReply.promise;
directReplyCount += 1;
return directReplyCount === 1
? firstDirectReply.promise
: secondDirectReply.promise;
}
return supervisorHarness.invoke(command, args);
},
@@ -9393,17 +9421,152 @@ export function registerProjectSupervisorSurfaceTests() {
);
await waitFor(() =>
expect(
within(supervisorSurface).getByText('已发送消息,正在等待陶泥儿回复'),
within(
within(supervisorSurface).getByLabelText('陶泥儿执行过程'),
).getByText('已发送消息,正在等待陶泥儿回复'),
).not.toBeNull(),
);
await act(async () => {
directReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解');
const directMessageList =
within(supervisorSurface).getByLabelText('陶泥儿消息');
Object.defineProperties(directMessageList, {
clientHeight: { configurable: true, value: 180 },
scrollHeight: { configurable: true, value: 640 },
scrollTop: { configurable: true, value: 0, writable: true },
});
const waitingProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(
await within(supervisorSurface).findByText(
'DIRECT_REPLY:先完成正式客户端玩法拆解',
),
within(waitingProcessCard).getByText('已发送消息,正在等待陶泥儿回复'),
).not.toBeNull();
expect(
within(waitingProcessCard).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
const firstDirectCall = invoke.mock.calls.find(
([command]) => command === 'chat_with_game_creator_direct_codex',
);
const firstTurnId = String(
(firstDirectCall?.[1] as Record<string, unknown> | undefined)
?.clientTurnId ?? '',
);
expect(firstTurnId).not.toBe('');
expect(firstDirectCall?.[1]).toEqual({
projectPath,
prompt: '先完成正式客户端玩法拆解',
clientTurnId: firstTurnId,
});
await act(async () => {
supervisorHarness.emitProgress('direct', '正在准备安全阶段');
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 0,
status: 'accepted',
activity: 'understanding',
accumulatedText: 'DIRECT_STREAM:先完成',
updatedAt: 1000,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 2,
status: 'streaming',
activity: 'controlled-tool',
accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解',
updatedAt: 2000,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 1,
status: 'streaming',
activity: 'file-change',
accumulatedText: '乱序事件不能回退正文',
updatedAt: 1500,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: 'old-direct-turn',
sequence: 99,
status: 'streaming',
activity: 'validation',
accumulatedText: '旧回合不能覆盖正文',
updatedAt: 3000,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath: '/tmp/wrong-direct-project',
turnId: firstTurnId,
sequence: 100,
status: 'streaming',
activity: 'response-finalization',
accumulatedText: '错误项目不能覆盖正文',
updatedAt: 4000,
},
});
supervisorHarness.emitProgress('direct', '旧 fallback 不能覆盖精确事件');
});
const streamingProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(
within(streamingProcessCard).getByText('正在执行受控工具'),
).not.toBeNull();
expect(
within(streamingProcessCard).getByLabelText('陶泥儿实时回复').textContent,
).toContain('DIRECT_STREAM:先完成正式客户端玩法拆解');
expect(directMessageList.scrollTop).toBe(640);
expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull();
expect(
within(supervisorSurface).queryByText('旧 fallback 不能覆盖精确事件'),
).toBeNull();
directMessageList.scrollTop = 100;
fireEvent.scroll(directMessageList);
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 3,
status: 'streaming',
activity: 'validation',
accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解',
updatedAt: 4500,
},
});
});
expect(directMessageList.scrollTop).toBe(100);
expect(
within(streamingProcessCard).getByText('正在验证结果'),
).not.toBeNull();
await act(async () => {
firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解');
});
await waitFor(() => {
expect(
within(supervisorSurface).getAllByText(
'DIRECT_REPLY:先完成正式客户端玩法拆解',
),
).toHaveLength(1);
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
expect(
within(supervisorSurface).queryByText(
'DIRECT_STREAM:先完成正式客户端玩法拆解',
),
).toBeNull();
expect(
within(directMessageList).queryByLabelText('陶泥儿执行过程'),
).toBeNull();
});
await waitFor(() => {
expect(
(
@@ -9426,9 +9589,80 @@ export function registerProjectSupervisorSurfaceTests() {
{
projectPath,
prompt: '补充:优先复用现有素材',
clientTurnId: expect.any(String),
},
);
});
const directCalls = invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
);
const secondTurnId = String(
(directCalls[1]?.[1] as Record<string, unknown> | undefined)
?.clientTurnId ?? '',
);
expect(secondTurnId).not.toBe(firstTurnId);
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: secondTurnId,
sequence: 0,
status: 'streaming',
activity: 'not-a-public-activity',
accumulatedText: '即将失败的临时正文',
updatedAt: 5000,
},
});
});
const failedTurnProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(
within(failedTurnProcessCard).getByText('陶泥儿正在处理'),
).not.toBeNull();
expect(
within(failedTurnProcessCard).getByLabelText('陶泥儿实时回复')
.textContent,
).toContain('即将失败的临时正文');
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: secondTurnId,
sequence: 1,
status: 'failed',
activity: 'none',
accumulatedText: '失败事件不得保留这段正文',
updatedAt: 5100,
},
});
});
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
expect(
within(directMessageList).getByLabelText('陶泥儿执行过程'),
).not.toBeNull();
expect(
within(supervisorSurface).getByText('处理失败,正在同步错误'),
).not.toBeNull();
await act(async () => {
secondDirectReply.reject(new Error('模拟 direct 失败'));
});
await waitFor(() => {
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
expect(
within(directMessageList).queryByLabelText('陶泥儿执行过程'),
).toBeNull();
expect(
(
within(supervisorSurface).getByRole('button', {
name: '发送',
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
@@ -9711,6 +9945,7 @@ export function registerProjectSupervisorSurfaceTests() {
{
projectPath,
prompt: '修改玩家移动脚本',
clientTurnId: expect.any(String),
},
);
});
@@ -738,11 +738,17 @@ function renderSurface(
};
}
function expectAssetCanvasBackgroundLocked() {
function expectAssetCanvasBackgroundLocked(
hiddenFromAccessibilityTree = false,
) {
const toolbarShell = document.querySelector(
'.asset-canvas-surface__toolbar-shell',
);
const viewport = document.querySelector('.asset-canvas-surface__viewport');
const viewportTools = document.querySelector(
'.asset-canvas-surface__viewport-tools',
);
const status = document.querySelector('.asset-canvas-surface__status');
const generate = screen.getByRole('button', {
name: 'AI 生成图片',
hidden: true,
@@ -754,6 +760,14 @@ function expectAssetCanvasBackgroundLocked() {
expect(toolbarShell?.hasAttribute('inert')).toBe(true);
expect(viewport?.hasAttribute('inert')).toBe(true);
expect(viewportTools?.hasAttribute('inert')).toBe(true);
if (hiddenFromAccessibilityTree) {
expect(toolbarShell?.getAttribute('aria-hidden')).toBe('true');
expect(viewport?.getAttribute('aria-hidden')).toBe('true');
expect(viewportTools?.getAttribute('aria-hidden')).toBe('true');
expect(status?.hasAttribute('inert')).toBe(true);
expect(status?.getAttribute('aria-hidden')).toBe('true');
}
expect(generate.disabled).toBe(true);
expect(commit.disabled).toBe(true);
}
@@ -863,7 +877,10 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
(screen.getByRole('button', { name: '重做' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(
(screen.getByRole('button', { name: '删除' }) as HTMLButtonElement)
.disabled,
).toBe(true);
fireEvent.click(generate);
expect(generate.getAttribute('aria-expanded')).toBe('true');
@@ -1159,34 +1176,70 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
).toBeTruthy();
});
it('任一独立 modal 打开时都隔离背景焦点并阻断生成与提交端口', async () => {
const user = userEvent.setup();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
const view = renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
const generateTrigger = screen.getByRole('button', {
name: 'AI 生成图片',
});
generateTrigger.focus();
await user.click(generateTrigger);
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectAssetCanvasBackgroundLocked(true);
expectLockedBackgroundCannotCallPaidPorts(memory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '关闭图片生成面板' }),
),
);
fireEvent.click(screen.getByRole('button', { name: '关闭图片生成面板' }));
await user.type(
screen.getByRole('textbox', { name: '图片提示词' }),
'角色立绘',
);
await user.click(screen.getByRole('button', { name: '继续确认' }));
expect(screen.getByRole('dialog', { name: '确认图片生成' })).toBeTruthy();
const generationFirst = screen.getByRole('button', {
name: '关闭图片生成面板',
});
const generationLast = screen.getByRole('button', { name: '确认并生成' });
await waitFor(() => expect(document.activeElement).toBe(generationFirst));
generationLast.focus();
await user.tab();
expect(document.activeElement).toBe(generationFirst);
await user.tab({ shift: true });
expect(document.activeElement).toBe(generationLast);
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '确认图片生成' })).toBeNull(),
);
expect(document.activeElement).toBe(generateTrigger);
fireEvent.click(screen.getByRole('button', { name: '选择图层 第一层' }));
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
const exitTrigger = screen.getByRole('button', { name: '取消并返回' });
exitTrigger.focus();
await user.click(exitTrigger);
expect(screen.getByRole('dialog', { name: '返回资源总览' })).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectAssetCanvasBackgroundLocked(true);
expectLockedBackgroundCannotCallPaidPorts(memory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '继续编辑' }),
),
);
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
const exitFirst = screen.getByRole('button', { name: '放弃草稿' });
const exitLast = screen.getByRole('button', { name: '保留草稿并退出' });
exitLast.focus();
await user.tab();
expect(document.activeElement).toBe(exitFirst);
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '返回资源总览' })).toBeNull(),
);
expect(document.activeElement).toBe(exitTrigger);
view.unmount();
const identityMemory = memoryHost({
@@ -1204,13 +1257,31 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(
await screen.findByRole('dialog', { name: '确认旧生成任务服务' }),
).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectAssetCanvasBackgroundLocked(true);
expectLockedBackgroundCannotCallPaidPorts(identityMemory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '暂不恢复旧任务' }),
),
);
const serviceFirst = screen.getByRole('button', {
name: '暂不恢复旧任务',
});
const serviceLast = screen.getByRole('button', {
name: '确认当前服务并恢复原任务',
});
serviceLast.focus();
await user.tab();
expect(document.activeElement).toBe(serviceFirst);
await user.keyboard('{Escape}');
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '确认旧生成任务服务' }),
).toBeNull(),
);
expect(document.activeElement).toBe(
screen.getByRole('button', { name: 'AI 生成图片' }),
);
});
it('原 generation 在后台恢复时草稿立即可编辑且恢复任务仍继续', async () => {