合并master并解决资源画布冲突
合并 origin/master 最新提交 保留精修事务、候选图层与失败门户逻辑 同步生成恢复、Direct Codex 清理和资源画布删除交互
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
? '画布可编辑'
|
||||
|
||||
+26
-4
@@ -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"
|
||||
>
|
||||
|
||||
+12
-6
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -60,11 +60,11 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
|
||||
|
||||
### Jenkins 预览 secrets 镜像边界
|
||||
|
||||
Jenkins 分支预览构建固定从宿主 `/data/jenkins/preview-secrets/.env.secrets.local` 读取 secrets。目录由 Jenkins 运行账号所有且权限为 `0700`,文件由同一账号所有且权限为 `0600`;构建入口对缺失、链接、非普通文件、owner 不匹配和过宽权限均失败关闭。不要把真实值写入本 README、仓库示例或 Jenkins 参数。
|
||||
Jenkins 分支预览构建固定从宿主 `/data/jenkins/preview-secrets/.env.local` 与 `/data/jenkins/preview-secrets/.env.secrets.local` 读取运行时配置。目录由 Jenkins 运行账号所有且权限为 `0700`,两个文件由同一账号所有且权限为 `0600`;构建入口对缺失、链接、非普通文件、owner 不匹配和过宽权限均失败关闭。两个文件都包含敏感配置,不要把真实值写入本 README、仓库示例或 Jenkins 参数。
|
||||
|
||||
该文件不复制到源码 checkout 和 Docker build context,而是以 BuildKit `secret` mount 只提供给 `api-runtime` stage。构建会把它安装到 API 运行镜像的 `/srv/genarrative/.env.secrets.local`,owner 为 `genarrative`、权限为 `0400`。Web builder、`nginx-runtime`、SpacetimeDB 和其它运行镜像不得获得该 mount 或目标文件;构建日志和 artifact 也不得回显或保存文件内容。容器的显式运行环境变量优先于该内置文件,可按预览实例覆盖其中的值。
|
||||
两个文件都不复制到源码 checkout 和 Docker build context,而是分别以 BuildKit `secret` mount 只提供给 `api-runtime` stage。构建会把它们安装到 API 运行镜像的 `/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local`,owner 为 `genarrative`、权限为 `0400`。Web builder、`nginx-runtime`、SpacetimeDB 和其它运行镜像不得获得这些 mount 或目标文件;构建日志和 artifact 也不得回显或保存文件内容。容器的显式运行环境变量优先于这两个内置文件,可按预览实例覆盖其中的值。
|
||||
|
||||
修改宿主固定文件后必须重新构建并替换 API 镜像;重启旧容器不会读取宿主新内容。这个镜像不是可公开分发的无密钥产物:镜像持有者可以提取 `/srv/genarrative/.env.secrets.local`。只允许在当前受信任内网 Docker 主机使用,禁止 push 或 `docker save`、artifact 导出到跨信任边界的 registry、主机或存储。
|
||||
修改任一宿主固定文件后必须重新构建并替换 API 与 worker 镜像;重启旧容器不会读取宿主新内容。这个镜像不是可公开分发的无密钥产物:镜像持有者可以提取 `/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local`。只允许在当前受信任内网 Docker 主机使用,禁止 push 或 `docker save`、artifact 导出到跨信任边界的 registry、主机或存储。
|
||||
|
||||
### Gitea CI 预构建 Job 镜像
|
||||
|
||||
|
||||
@@ -24,12 +24,20 @@ RUN mkdir -p /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var
|
||||
chown -R genarrative:genarrative /srv/genarrative /var/lib/genarrative
|
||||
|
||||
ARG GENARRATIVE_PREVIEW_SECRETS_SHA256=
|
||||
ARG GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256=
|
||||
RUN --mount=type=secret,id=genarrative_preview_secrets,required=false \
|
||||
--mount=type=secret,id=genarrative_preview_env_local,required=false \
|
||||
if [ -n "${GENARRATIVE_PREVIEW_SECRETS_SHA256}" ]; then \
|
||||
test -f /run/secrets/genarrative_preview_secrets; \
|
||||
test "$(sha256sum /run/secrets/genarrative_preview_secrets | cut -d ' ' -f 1)" = "${GENARRATIVE_PREVIEW_SECRETS_SHA256}"; \
|
||||
install -o genarrative -g genarrative -m 0400 \
|
||||
/run/secrets/genarrative_preview_secrets /srv/genarrative/.env.secrets.local; \
|
||||
fi; \
|
||||
if [ -n "${GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256}" ]; then \
|
||||
test -f /run/secrets/genarrative_preview_env_local; \
|
||||
test "$(sha256sum /run/secrets/genarrative_preview_env_local | cut -d ' ' -f 1)" = "${GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256}"; \
|
||||
install -o genarrative -g genarrative -m 0400 \
|
||||
/run/secrets/genarrative_preview_env_local /srv/genarrative/.env.local; \
|
||||
fi
|
||||
|
||||
USER genarrative
|
||||
|
||||
@@ -164,12 +164,14 @@
|
||||
3. 回合结束时客户端只确定性复核安全与最低来源边界:可信平台图片、身份与同 Canvas 关系成立,`game/index.html` 存在,游戏源码至少引用一份已登记陶泥儿图片。随后由受限 Chromium 对 desktop/mobile 做真实运行、截图、异常、交互及 Canvas/WebGL 图片渲染观察,并把缺口回灌同一 Codex thread 有界整改。固定四切片、固定文件名和固定 `drawImage` 次数不再阻断完成;最终仍未观察到平台素材进入任一视口核心渲染时才拒绝登记版本。详细契约以第 13 节为准。
|
||||
4. 资源画布增加“游戏代码”分区。HTML、CSS、JavaScript 和其它可执行源码进入该分区;玩法说明、配置和 Agent 文本回执仍归“文档”。已有资源布局若没有代码分区位置,将由现有布局 reconcile 自动补位。
|
||||
5. 产品界面只展示“陶泥儿”“智能创作”等产品术语;Codex app-server 仅保留为内部执行实现和开发诊断名称,不在普通项目聊天、首页创建状态或设置说明中直接暴露。
|
||||
6. 每次直连回合在游戏代码复核与登记成功后,必须在项目写锁内推进一次 durable project revision,并以该 revision 创建首个 `initial-*` 或后续 `agent-*` 正式版本。禁止修改 manifest 后沿用旧 revision;外层工作台会把同 revision 的不同 manifest 判为冲突并拒绝投影,表现为磁盘已有游戏代码和版本、客户端仍显示 0 项。
|
||||
7. direct app-server 禁用 shell / unified exec 时仍必须能可靠修改已有游戏。每轮系统提示词在大段仓库文档之前注入当前 `game/index.html`、`game/style.css`、`game/game.js` 的有界脱敏快照,使 Codex 的原生 file-change 能基于真实旧内容生成补丁;不得退化为猜测变量名的盲补丁。回合前后以三个代码文件的内容指纹判断是否发生真实修改;无修改且 manifest 已同步的回合不得推进 revision 或伪造新项目版本。
|
||||
6. 项目与首页 direct 系统提示词必须把对外产品身份固定为“陶泥儿”。询问身份、名称或能力时以陶泥儿自称,不把 Codex、ChatGPT、OpenAI、模型或通用 AI 助手当作名称;只有用户明确询问底层实现时才可说明内部使用 Codex app-server,并继续保持陶泥儿这一对外身份。direct 缺失 system message 时使用同一陶泥儿兜底,legacy ToolHost 的内部 Codex 技术提示保持独立。
|
||||
7. 每次直连回合在游戏代码复核与登记成功后,必须在项目写锁内推进一次 durable project revision,并以该 revision 创建首个 `initial-*` 或后续 `agent-*` 正式版本。禁止修改 manifest 后沿用旧 revision;外层工作台会把同 revision 的不同 manifest 判为冲突并拒绝投影,表现为磁盘已有游戏代码和版本、客户端仍显示 0 项。
|
||||
8. direct app-server 禁用 shell / unified exec 时仍必须能可靠修改已有游戏。每轮系统提示词在大段仓库文档之前注入当前 `game/index.html`、`game/style.css`、`game/game.js` 的有界脱敏快照,使 Codex 的原生 file-change 能基于真实旧内容生成补丁;不得退化为猜测变量名的盲补丁。回合前后以三个代码文件的内容指纹判断是否发生真实修改;无修改且 manifest 已同步的回合不得推进 revision 或伪造新项目版本。
|
||||
|
||||
### 验收
|
||||
|
||||
- Rust 单测覆盖:可信完整图集缺少固定切片仍可进入 Codex;只有规范图和背景图但历史图集不可安全恢复时不得重复发起付费生成;缺可信平台图片、来源身份不成立、PNG 不可解码或源码完全没有平台素材引用时继续拒绝完成。浏览器证据测试必须区分旁侧 `<img>` 与 Canvas/WebGL 实际渲染,并证明 desktop/mobile 任一视口缺少核心素材观察都会回灌同 thread 整改。
|
||||
- 身份合同测试覆盖项目 prompt、首页 prompt 与 direct 缺省 baseInstructions 均以陶泥儿为对外身份,同时证明 legacy ToolHost 的内部 Codex fallback 未被改写。客户端完整重启后,用同一项目分别询问“你是谁”和“你是不是 Codex”,回复必须以陶泥儿自称;仅在第二问可补充底层执行技术。
|
||||
- 资源投影与 AppSurface 测试覆盖:`game/index.html`、`game/style.css`、`game/game.js` 显示于“游戏代码”,不再显示于“文档”。
|
||||
- 连续两次直连回合必须保留同一批游戏代码资产 ID,project revision 单调推进,并形成 `initial-* -> agent-*` 的父子版本链;外层资源管理在新 revision 到达后显示游戏代码和项目版本,不能停留在生成前快照。
|
||||
- 已有项目修改测试必须证明系统提示词包含有界当前游戏源码、敏感行被过滤,基于该上下文的 file-change 可以命中;Codex 明确未修改文件时,revision 与版本数量保持不变。
|
||||
@@ -310,7 +312,7 @@
|
||||
|
||||
### 14.2 审核索引与五类 Skill
|
||||
|
||||
客户端内置 `agc-skill-pack.v1` 清单。每项只公开名称、用途、触发条件、所需工具、版本和内容 SHA-256;审核文件变化时必须在同次变更重算对应指纹。启动时逐文件复核清单和编译进客户端的内容,任何缺失、额外文件、路径越界或指纹不匹配都失败关闭;路径边界显式拒绝反斜杠、Windows 盘符、UNC、绝对路径和 `..`,不能因测试运行在 Linux 就把 Windows 绝对路径当作普通相对文件名。审核包只包含:
|
||||
客户端内置 `agc-skill-pack.v1` 清单。每项只公开名称、用途、触发条件、所需工具、版本和内容 SHA-256;审核文本统一按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免编辑器产生的混合换行让同一 Git 内容在 Windows 与 Linux 上得到不同结果。审核文件的语义内容变化时必须在同次变更重算对应指纹并提升版本。启动时逐文件复核清单和编译进客户端的内容,任何缺失、额外文件、路径越界、非 UTF-8 内容或规范化后的指纹不匹配都失败关闭;路径边界显式拒绝反斜杠、Windows 盘符、UNC、绝对路径和 `..`,不能因测试运行在 Linux 就把 Windows 绝对路径当作普通相对文件名。审核包只包含:
|
||||
|
||||
1. `agc-project-structure`:项目根、`game/`、`assets/`、`.agent/` 的职责和禁止创建平行项目的约束。
|
||||
2. `taonier-art-assets`:陶泥儿标准美术包、平台来源、警告语义和真实素材使用;`grid-2x2` 与四切片只是推荐路径,不是所有游戏的完成门。
|
||||
@@ -357,3 +359,49 @@ DirectHome 继续禁用 MCP、命令和写入。DirectProject 仍禁用通用 sh
|
||||
- 同一真实项目已由 Codex 把平台背景、规范图棋子和核心图集实际接入 `game/index.html / style.css / game.js`。真实 Chromium 报告 `passed=true`:desktop/mobile 均为 `readyState=complete`、Canvas 非空、无 console error 与 exception;desktop 仅有非致命 `favicon.ico` 404。两张截图确认桌面和手机均完整显示甜点星球三消画面,且结构化运行时证据观察到平台图片进入渲染。
|
||||
- 真实复跑同时暴露并修复两个收尾缺陷:DirectProject 不能沿用普通 LLM 的 180 秒整回合超时,现改为 15 分钟基础空闲窗口、MCP 工具活动期 110 分钟空闲窗口、整个 turn 120 分钟硬上限;DirectHome 与旧 ToolHost 继续保持原超时。系统提示词和浏览器整改回灌同时明确 shell/unified_exec 被安全禁用时应使用已注入的游戏文件快照与结构化证据,不得误报“没有读取工具所以无法验收”,也不得要求 Codex 直接保存 `.agent` 版本。定向回归为 Direct Runtime 35/35、Codex app-server 23/23(另 1 项真实账号测试按设计 ignored)。
|
||||
- 最后一轮改后 GUI 复验在桌面控制被物理 Escape 中止后未继续自动操作;非 UI CLI 又因不继承 GUI 登录态而得到 `usage-limit-exceeded`。因此本节只把已落盘的真实生图、幂等复用和双视口浏览器证据记为已完成,不把改后最终聊天回复或新增项目版本伪报为已验收;下次从当前客户端发送普通项目消息即可复核新的等待窗口与证据回灌文案。
|
||||
|
||||
## 15. Direct Interaction Event v1 合同(2026-08-22)
|
||||
|
||||
### 15.1 现役缺口与目标
|
||||
|
||||
现役 direct 链路虽然在 app-server 内部能接收消息增量,但普通项目聊天仍主要等待 Tauri command 完整返回后才展示助手正文;旧 `game-creator-agent-progress` 只能表达少量阶段,不能作为 direct 回合的正文增量、顺序、终态和隔离合同。本次新增 Direct Interaction Event v1,让用户在终态返回前看到安全活动状态和已产生的用户可见回复,不恢复 Supervisor 或引入第二个 Runtime 真相源。
|
||||
|
||||
### 15.2 事件与字段
|
||||
|
||||
Tauri 事件名固定为 `game-creator-direct-turn-update`,payload 只包含以下字段:
|
||||
|
||||
- `projectPath`:发起回合的项目键,只用于本地路由与隔离,不渲染到用户文案。
|
||||
- `turnId`:本次 direct 提交的稳定回合标识,与项目键共同定位唯一的临时回复。
|
||||
- `sequence`:回合内严格递增的非负整数,用于拒绝重复、迟到和乱序回退。
|
||||
- `status`:只允许 `accepted | running | streaming | finalizing | completed | failed`。
|
||||
- `activity`:只允许 `request-accepted | understanding | project-inspection | file-change | controlled-tool | validation | response-finalization | none` 这些安全类别;它是粗粒度活动标识,不是工具日志。
|
||||
- `accumulatedText`:截至当前序号的完整助手可见正文,前端原位替换临时回复,不将其追加为多条消息。
|
||||
- `updatedAt`:事件产生时间,只用于展示和诊断,不参与顺序判定。
|
||||
|
||||
`activity` 及其对应的展示文案不得泄漏模型 reasoning、工具原始参数、内部路径、Provider、认证信息或未脱敏错误。`accumulatedText` 只能来自助手面向用户的正文增量,不得混入 reasoning、tool call/item、raw arguments、stderr 或内部诊断。
|
||||
|
||||
app-server 的 `turn/plan/updated`、reasoning summary、MCP progress、文件 patch/output、命令 output 和验证类通知只能按通知方法名映射为上述固定 `activity`,不得把通知 params 传入 observer。连续同类高频活动应在进入 turn channel 前有界合并;该合并不得影响 `AgentMessageDelta` 正文和 terminal 终态。
|
||||
|
||||
### 15.3 生命周期与前端门禁
|
||||
|
||||
1. 每次 direct 提交必须先建立 `projectPath + turnId` 的临时回复,生命周期按 `accepted -> running -> streaming -> finalizing -> completed` 前进;没有正文增量时可跳过 `streaming`,任一非终态都可进入 `failed`,终态后不再接受该回合事件。
|
||||
2. 前端只处理 `projectPath` 等于当前项目且 `turnId` 等于当前活动回合的事件。对同一 `projectPath + turnId`,只接受 `sequence` 大于已接收最大值的事件;时间戳更新不能绕过该单调门禁。
|
||||
3. 组件存活期内可保留一个全局 Tauri 事件监听。切换项目、切换活动 `turnId` 或 command 完成、失败时,必须清理对应的临时回复、活动文案和最大 `sequence` 等回合关联状态;组件卸载时再清理该全局监听。迟到事件不得污染新项目或新回合。
|
||||
4. `failed` 必须结束流式态并清理未完成正文,失败展示继续经现有安全错误映射,不把增量文本伪装成已完成回复。
|
||||
5. WorkspaceLauncher 实际项目工作台必须在消息列表内渲染持续可见的同回合过程卡:没有正文时展示当前安全活动,正文 delta 到达后在同一卡内展开累计正文。过程卡不能退化为输入框下方的小号 workspace 状态;用户位于列表底部时活动和正文更新应自动跟随,用户主动上滚后不得强制拉回。
|
||||
|
||||
### 15.4 权威与持久化边界
|
||||
|
||||
`game-creator-direct-turn-update` 是 Tauri 进程内的易失通知,只用于提升当前页面的过程可见性;不将事件本身写入项目对话、manifest 或其它 durable 状态,不用它推导跨进程回合已完成。
|
||||
|
||||
Tauri command 成功返回的 final `String` 是本次回合唯一的终态助手正文和持久化权威。前端收到 command 结果后,用该 `String` 原位收口同一 `turnId` 的临时回复,并且只持久化一条最终 assistant 消息。`completed.accumulatedText` 仍只是临时展示,不得先行或重复持久化,也不得覆盖 command 的 final `String`。
|
||||
|
||||
本合同是 direct 链路的独立交互投影,不复用 legacy `AgentRuntimeResult`,不恢复 Supervisor 的任务、receipt 或消息真相。本次只保证当前 Tauri command 存活期内的流式展示与最终持久化,不宣称已实现 durable reconnect、跨客户端恢复增量或重连后继续同一未完回合。
|
||||
|
||||
### 15.5 验收合同
|
||||
|
||||
- terminal command 返回前,用户消息下方必须持续渲染同回合过程卡;纯工具阶段显示安全活动,真实正文 delta 到达时在同一卡内原位更新临时回复。
|
||||
- 对同一 `projectPath + turnId` 注入重复、倒序和迟到的 `sequence`,页面必须拒绝小于或等于已接收最大值的事件,不得发生正文或状态回退。
|
||||
- command 成功后只展示并持久化一条以 final `String` 为正文的 assistant 消息;临时回复、`completed` 事件和 command result 不得形成多条最终消息。
|
||||
- command 失败、`failed`、项目切换和回合切换均必须清理临时回复、活动状态和序号门禁;组件卸载时必须清理全局监听;旧事件不得出现在新上下文。
|
||||
- 单测、AppSurface 回归与真实客户端验收均要覆盖 WorkspaceLauncher 实际工作台、长工具通知 replay、自动跟随和手动上滚保护;事件和 UI 文案不得出现 reasoning、raw arguments、tool item、stderr、内部路径、Provider、凭据或未脱敏错误。
|
||||
|
||||
@@ -14242,6 +14242,7 @@
|
||||
- Windows 锁文件决策:提升权限进程新建 `.agent/.manifest.json.lock` 时,Windows 可能把 owner 设为 `Administrators`。仅在固定锁路径已取得不共享独占句柄并确认是普通、非 reparse、单链接文件后,才初始化为当前 `TokenUser`;随后再次复核句柄并执行原有 owner/DACL 校验,不放宽既有异常对象的安全规则。
|
||||
- Provider Schema 决策:`agent.route_manifest.missingAssetSlots` 不再广告 OpenAI-compatible 代理拒绝的 `uniqueItems`;Runtime 继续排序去重,Schema 子集门禁新增该关键字,真实 Provider smoke 必须在发布前证明工具目录可被接受。
|
||||
- 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。
|
||||
|
||||
## 2026-08-15 Jenkins 容器预览部署使用独立控制面
|
||||
|
||||
- 决策:多人内网容器预览不把操作表单塞进 Jenkins 页面,也不让 SPA 直接操作 Docker。独立 `preview-deployer` SPA 通过同源 Axum 代理触发固定 `shared/Genarrative-Preview-Deployer` Job;浏览器只持有控制面 HttpOnly 会话,Jenkins service account 和 API Token 只存在服务端环境。
|
||||
@@ -14357,7 +14358,7 @@
|
||||
|
||||
## 2026-08-21 JavaScript 工程统一为 npm workspaces
|
||||
|
||||
- 决策:根、Admin、AGC、Desktop、Mobile、Preview Deployer、三个 `packages/*` 和 Spine validator 统一进入显式 npm workspaces;固定 `packageManager=npm@10.9.7`,CI 镜像显式安装并校验同版 npm。仓库只提交根 `package-lock.json`,安装、CI、Jenkins 和容器缓存都只从根执行一次 `npm ci`。
|
||||
- 决策:根、Admin、AGC、Desktop、Mobile、Preview Deployer、三个 `packages/*` 和 Spine validator 统一进入显式 npm workspaces;固定 `packageManager=npm@10.9.7`,CI 镜像显式安装并校验同版 npm。Jenkins Web Build 不能假定系统 npm 已同步,每个独立 `bash -lc` 都必须 source `scripts/jenkins-prepare-npm-env.sh`,由该入口在 Jenkins 用户的版本隔离目录持久准备 npm `10.9.7`。仓库只提交根 `package-lock.json`,安装、CI、Jenkins 和容器缓存都只从根执行一次 `npm ci`。
|
||||
- 依赖边界:每个 workspace manifest 拥有自身直接依赖,根不再为子 App 重复声明。内部私有包使用匹配版本的普通 semver `0.1.0`,由 npm 自动链接;当前 npm 不接受 `workspace:*`。npm 默认 hoist,因此依赖所有权按 manifest 和 lock 的 workspace entry 检查,不能按统一 `node_modules` 或 lock 全局包条目判断。
|
||||
- 原生边界:根 H5 与 Desktop manifest 继续禁止 Tauri JS guest,AGC workspace 可以声明;统一 lock 出现 AGC guest 是合法聚合结果。Expo 沿用默认 npm monorepo 支持。AGC Cubone bundle、TypeScript、Tauri CLI 与 Windows Codex sidecar 都必须兼容根提升位置,不得依赖子 App 固定 `node_modules` 层级。
|
||||
- 锁与平台:删除 AGC 和 Spine 子 lock;统一根 lock 必须保留 optional、bundled 和跨平台二进制节点。Linux 干净安装不能替代 Windows AGC sidecar、Android Expo/EAS 或可用 macOS/iOS runner 的平台构建证据。
|
||||
@@ -14369,8 +14370,25 @@
|
||||
- 依据:当前完整模块首次 publish / init 的进程 RSS 会超过 `896m`,cgroup 会直接 OOM kill `spacetimedb-standalone`,客户端表现为上传连接提前关闭,后续重试连接拒绝。提高 ping 或 publish 重试次数不能修复内存上限。
|
||||
- 边界:这是本地/预发完整容器的模块实例化门槛,不修改生产服务资源合同;门禁同时锁定基础 Compose 与预览 override 均为 `2g`。
|
||||
|
||||
## 2026-08-22 Jenkins 预览只向 API 运行镜像内置固定 secrets
|
||||
## 2026-08-22 Jenkins 预览只向 API 运行镜像内置固定运行时配置
|
||||
|
||||
- 决策:预览 secrets 权威源固定为 Jenkins 宿主 `/data/jenkins/preview-secrets/.env.secrets.local`;目录 / 文件由 Jenkins 运行账号所有且权限分别为 `0700` / `0600`,缺失、链接、非普通文件、owner 异常或权限过宽时构建失败关闭。
|
||||
- 构建边界:只通过 BuildKit secret mount 把文件提供给 `api-runtime` stage,并安装为 `/srv/genarrative/.env.secrets.local` (`genarrative:genarrative`, `0400`)。文件不进 Git、build context、日志或 artifact,不进入 Web / Nginx、SpacetimeDB 或其它镜像。容器显式运行 env 优先覆盖内置值。
|
||||
- 更新与分发:固定源文件更新后必须重建并替换镜像,只重启容器无效。镜像可读者必然可提取内置 secrets,因此只允许留在当前受信任内网 Docker 主机,禁止 push、`docker save` 或作为 artifact 导出到跨信任边界的 registry、主机或存储。
|
||||
- 决策:预览 `.env.local` 与 secrets 权威源固定为 Jenkins 宿主 `/data/jenkins/preview-secrets/.env.local`、`/data/jenkins/preview-secrets/.env.secrets.local`;目录 / 文件由 Jenkins 运行账号所有且权限分别为 `0700` / `0600`,缺失、链接、非普通文件、owner 异常或权限过宽时构建失败关闭。
|
||||
- 构建边界:只通过两个 BuildKit secret mount 把固定宿主副本提供给 `api-runtime` stage,并安装为 `/srv/genarrative/.env.local`、`/srv/genarrative/.env.secrets.local` (`genarrative:genarrative`, `0400`)。固定宿主副本不进 Git、build context、日志或 artifact,不进入 Web / Nginx、SpacetimeDB 或其它镜像;仓库工作区 `.env.local` 不得替代它们。容器显式运行 env 优先覆盖内置值。
|
||||
- 更新与分发:任一固定源文件更新后必须重建并替换 API 与 worker 镜像,只重启容器无效。镜像可读者必然可提取内置运行时配置,因此只允许留在当前受信任内网 Docker 主机,禁止 push、`docker save` 或作为 artifact 导出到跨信任边界的 registry、主机或存储。
|
||||
|
||||
## 2026-08-22 AGC Tauri 命令调用可达性失败关闭
|
||||
|
||||
- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。
|
||||
- allowlist 边界:前端源码已调用的命令不得继续保留在 explicit native-only allowlist。allowlist 只承载确实由原生窗口或原生侧流程触发、App 源码不直接调用的 handler;源码调用与 allowlist 必须互斥。
|
||||
- 门禁:逐文件使用仓库锁定的 TypeScript AST 解析,设置文件数量、单文件 / 总源码长度、命令长度和调用数量上限。回归测试同时锁定直接、wrapper、对象字段的正例与诱饵 / 动态 / 畸形输入的反例,并证明删除真实 wrapper 调用后 handler 可达性检查失败,不能由错误 allowlist 继续误绿。
|
||||
|
||||
## 2026-08-22 AGC 素材画布生成恢复与提交边界
|
||||
|
||||
- 凭据失败:`configuration-missing` / `authentication-required` 不是永久业务失败。尚无远端副作用时保留原 generation、commit 与 idempotency 身份并按 `context-preparing` / `prepared` 恢复;已有 operation 时只能进入 reconciliation。旧 `failed` 账本仅按这两个错误码和已有副作用证据白名单迁移,确定性业务失败继续终态。
|
||||
- 提交边界:恢复先按项目、草稿和终态预过滤,再解析凭据;上下文准备完成后、首次可计费 POST 前必须重新校验冻结的平台账号会话。草稿一旦 cancelled,不再公开投影、下载后 staging 或提交资产,私有账本保留最后一份 durable 证据。
|
||||
- staging 原子恢复:稳定 staging token 必须同时校验媒体类型、摘要、尺寸与已有文件。图片先落盘或 metadata 先落盘的同身份半提交允许补齐后幂等重放;任一身份或内容冲突失败关闭并保留现场,不覆盖残片。
|
||||
|
||||
## 2026-08-22 AGC 素材画布模态框焦点合同
|
||||
|
||||
- 素材生成确认、离开确认和旧服务身份确认沿用现有独立 modal,不在当前面板下方追加内容。modal 打开后焦点必须进入对话框,并同时隔离工具栏、画布视口、缩放/小地图、状态区和并存操作层,使背景从键盘焦点顺序与 accessibility tree 中退出。
|
||||
- `Tab` / `Shift+Tab` 必须在当前 modal 内双向循环;非异步 pending 状态允许 `Escape` 安全关闭。关闭后优先恢复到原触发器,自动弹出的 modal 则回退到可操作的工具栏入口,不能把焦点遗留在已卸载节点或被隔离背景中。
|
||||
|
||||
@@ -4235,8 +4235,8 @@
|
||||
- 现象:内置 Skill 文件集合没有缺失,原生测试却统一报内容指纹不匹配;另一个测试在 Linux 上把 `C:\\temp\\SKILL.md` 判为安全相对路径,受控资源工具可能继续处理 Windows 盘符或反斜杠遍历形式。
|
||||
- 原因:审核文件定稿后未按最终字节重新生成 manifest SHA-256;同时 `std::path::Path` 只按当前宿主语义解析路径,Linux 不会把 Windows 盘符和反斜杠视为绝对路径或分隔符。
|
||||
- 处理:Skill 文件变化与 manifest 指纹更新必须同次提交,并提升审核包版本;资源引用只接受使用 `/` 的普通相对段,显式拒绝反斜杠、冒号盘符、UNC、绝对路径和父目录段,再查询审核清单。不要先把反斜杠替换成 `/` 后再做安全检查。
|
||||
- 回归补充:即使 Skill 文件本轮没有变化,也不能从旧提交或旧构建结果复制清单指纹;必须对当前工作树最终字节现场重算,并在提交前运行原生 Skill Pack 校验。运行时只报告排序后的首个不匹配项,不能据此假定其余 Skill 已通过。
|
||||
- 验证:逐项按排序后的 `relativePath + NUL + file bytes + NUL` 重算并核对 manifest;Rust 单测同时覆盖 POSIX 绝对路径、`..`、`C:\\...`、`C:/...`、UNC 和反斜杠相对路径,受控 MCP 工具也必须把 Windows 绝对路径投影为 `isError=true`。
|
||||
- 回归补充:即使 Skill 文件本轮没有变化,也不能从旧提交或旧构建结果复制清单指纹;必须对当前工作树按 UTF-8 读取、将 CRLF 规范为 LF 后现场重算,并在提交前运行原生 Skill Pack 校验。Git 的 `eol=lf` 不能阻止编辑器在干净工作树里留下少量混合 CRLF,而 Cargo `include_bytes!` 会读取这些原始字节;因此运行时计算与安装也必须使用同一规范化函数。运行时只报告排序后的首个不匹配项,不能据此假定其余 Skill 已通过。
|
||||
- 验证:逐项按排序后的 `relativePath + NUL + canonical UTF-8 LF bytes + NUL` 重算并核对 manifest;Rust 单测同时覆盖 LF / CRLF 指纹等价、安装结果只含 LF、POSIX 绝对路径、`..`、`C:\\...`、`C:/...`、UNC 和反斜杠相对路径,受控 MCP 工具也必须把 Windows 绝对路径投影为 `isError=true`。
|
||||
|
||||
## Gitea CI 预构建镜像不能只靠 tag 判断内容
|
||||
|
||||
@@ -4864,8 +4864,8 @@
|
||||
|
||||
- 现象:构建时使用 BuildKit secret mount,日志和普通 build context 都没有出现明文,于是误以为最终镜像也能不可提取地保存 secrets,随后将镜像 push 或导出给不同信任域。
|
||||
- 原因:BuildKit secret mount 只避免秘密作为 `ARG` / `COPY` 进入构建上下文和中间指令;一旦 Dockerfile 把 mount 的内容安装到最终 rootfs,任何能读取、保存或运行该镜像的主体都可以提取它。
|
||||
- 处理:预览固定 secrets 只从 Jenkins 宿主受控路径读取,严格校验目录 `0700`、文件 `0600`、owner、普通文件与非链接边界;只将其安装到 `api-runtime:/srv/genarrative/.env.secrets.local` 并设为 `0400`,明确排除 Nginx、Web、artifact 和其它镜像。镜像禁止推送或导出到跨信任边界。
|
||||
- 更新与验证:源文件变更不会改动已存镜像,必须重建并替换容器;不能用重启代替。验收同时扫描 transcript/context/artifact 零泄漏,检查只有 API 最终 rootfs 存在目标文件,并验证容器显式运行 env 优先覆盖内置值。
|
||||
- 处理:预览固定 `.env.local` 与 secrets 只从 Jenkins 宿主受控路径读取,严格校验目录 `0700`、文件 `0600`、owner、普通文件与非链接边界;只将它们安装到 `api-runtime:/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local` 并设为 `0400`,明确排除 Nginx、Web、artifact 和其它镜像。镜像禁止推送或导出到跨信任边界。
|
||||
- 更新与验证:任一源文件变更不会改动已存镜像,必须重建并替换 API 与 worker 容器;不能用重启代替。验收同时扫描 transcript/context/artifact 零泄漏,检查只有 API 与 worker 最终 rootfs 存在目标文件,并验证容器显式运行 env 优先覆盖内置值。
|
||||
|
||||
## SpacetimeDB ping 健康不代表完整模块能在内存上限内实例化(2026-08-22)
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r
|
||||
|
||||
## 预览 secrets 内置
|
||||
|
||||
Jenkins 节点上的预览 secrets 权威来源固定为 `/data/jenkins/preview-secrets/.env.secrets.local`。该文件不进 Git、Docker build context、构建日志或 artifact;构建时只通过 BuildKit `secret` mount 临时提供给 `api-runtime` stage,并在该运行镜像中安装为 `/srv/genarrative/.env.secrets.local`,权限固定为 `0400`。`nginx-runtime`、Web 静态产物、SpacetimeDB 镜像及其它镜像不得包含该文件。
|
||||
Jenkins 节点上的预览运行时配置权威来源固定为 `/data/jenkins/preview-secrets/.env.local` 与 `/data/jenkins/preview-secrets/.env.secrets.local`。这两个固定宿主副本不进 Git、Docker build context、构建日志或 artifact;构建时分别通过 BuildKit `secret` mount 临时提供给 `api-runtime` stage,并在该运行镜像中安装为 `/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local`,权限固定为 `0400`。`nginx-runtime`、Web 静态产物、SpacetimeDB 镜像及其它镜像不得包含这些文件。仓库工作区中的 `.env.local` 不得替代固定宿主副本作为构建输入。
|
||||
|
||||
宿主固定目录应由 Jenkins 运行账号所有且权限为 `0700`,源文件权限为 `0600`;缺失、不是普通文件、owner 不匹配或权限过宽时,预览构建必须失败关闭。源文件变更后必须重新构建并替换预览镜像,只重启容器不会刷新已内置的内容。容器启动时显式注入的运行环境变量优先级高于镜像内的 `.env.secrets.local`,用于按实例覆盖非通用值。
|
||||
宿主固定目录应由 Jenkins 运行账号所有且权限为 `0700`,两个源文件权限均为 `0600`;缺失、不是普通文件、owner 不匹配或权限过宽时,预览构建必须失败关闭。任一源文件变更后必须重新构建并替换 API 与 worker 预览镜像,只重启容器不会刷新已内置的内容。容器启动时显式注入的运行环境变量优先级高于镜像内的 `.env.local` 与 `.env.secrets.local`,用于按实例覆盖非通用值。
|
||||
|
||||
这种方案只隐藏构建传输过程,不能让内置后的 secrets 对镜像持有者保密:能读取、保存或运行 `api-runtime` 镜像的人可以提取该文件。因此该镜像只能留在当前受信任内网 Docker 主机,禁止 push 到公共或跨信任边界的 registry,也禁止通过 `docker save`/构建 artifact 导出传播。需要跨边界分发时必须改用不含 secrets 的镜像与运行时密钥注入。
|
||||
|
||||
@@ -118,7 +118,7 @@ Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短
|
||||
- Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer` 的 `Job/Read`、`Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer`、`Job/Configure` 或 `Job/Delete`。
|
||||
- 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。
|
||||
- Git 查询固定使用本机 Gitea SSH 地址和服务端只读凭据;客户端不能传 remote、SSH 参数或凭据。Git 缓存只写入预览控制服务的受控状态目录,搜索接口需要控制台会话且结果有数量上限。
|
||||
- 预览 secrets 只从固定宿主路径读取,构建前校验 owner、类型和权限;不允许分支、Jenkins 参数或控制面请求改写 secrets 路径、BuildKit secret ID 或镜像内目标路径。
|
||||
- 预览 `.env.local` 与 secrets 只从固定宿主路径读取,构建前校验目录和文件的 owner、类型和权限;不允许分支、Jenkins 参数或控制面请求改写这些路径、BuildKit secret ID 或镜像内目标路径。
|
||||
- Jenkins POST 支持动态 Crumb;API Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。
|
||||
- API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。
|
||||
- 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user