Merge remote-tracking branch 'origin/master' into HEAD
Project CI / Backend tests (pull_request) Successful in 5m38s
Project CI / Native shell tests (pull_request) Successful in 12m14s
Project CI / Repository checks (pull_request) Successful in 4m11s
Project CI / Frontend tests (pull_request) Successful in 4m35s

This commit is contained in:
2026-08-26 16:03:24 +08:00
20 changed files with 300 additions and 64 deletions
+1
View File
@@ -212,6 +212,7 @@ jobs:
run: |
set -euo pipefail
for manifest_path in \
server-rs/Cargo.toml \
apps/desktop-shell/src-tauri/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
for attempt in $(seq 1 5); do
@@ -40,7 +40,7 @@
"agents/openai.yaml",
"references/platform-art-contract.md"
],
"sha256": "8e5b54035bff777cd0714ab203cb27c753fc83cf22d93ae2389bf28bfaf05ef6"
"sha256": "6340ba68146823fe56f8ad265e3b8f9329cdc842d52ad63e72b05285bb23334e"
},
{
"name": "agc-web-game-development",
@@ -76,7 +76,7 @@
"agents/openai.yaml",
"references/browser-evidence-contract.md"
],
"sha256": "403a9341ece8c73d6e3bc09059b556b1c775a2f9ceb75c30541c9dd79fd52bd5"
"sha256": "a6f967cb1947e1d40215e2e7186a8b13fae71800aa04d6a47ef2b0af25890825"
},
{
"name": "agc-client-projection",
@@ -22,6 +22,7 @@ mod runtime_driver;
mod runtime_protocol;
mod runtime_state;
mod runtime_tools;
mod skill_pack;
use codex_app_server::*;
pub(crate) use codex_app_server::{
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
@@ -41,6 +42,7 @@ pub(crate) use runtime_driver::*;
pub(crate) use runtime_protocol::*;
pub(crate) use runtime_state::*;
pub(crate) use runtime_tools::*;
pub(crate) use skill_pack::*;
pub(crate) fn shutdown_game_creator_codex_app_servers() -> Result<(), String> {
shutdown_game_creator_codex_app_servers_impl()
@@ -3074,7 +3074,7 @@ mod tests {
assert!(resolve_direct_codex_game_workspace(&project_root).is_err());
}
#[cfg(unix)]
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn direct_project_grant_root_comparison_remains_case_sensitive() {
let temp = tempfile::tempdir().expect("temp dir");
@@ -3162,7 +3162,7 @@ mod tests {
assert_ne!(first_identity, second_identity);
}
#[cfg(unix)]
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn direct_project_pool_identity_hashes_non_utf8_canonical_path_bytes_losslessly() {
use std::os::unix::ffi::OsStringExt;
@@ -13,7 +13,7 @@ const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 Codex cwd 是项目真实 `game/` 源码目录,只允许把项目源码写入该目录;原生文件工具、原生 patch 和命令参数中的文件路径必须相对于当前 cwd:合法写法是 `index.html`、`style.css`、`game.js`,禁止写 `game/index.html`、`../game/index.html`、项目根绝对路径或任何其它父目录路径;`game/...` 只用于 AGC 回执、manifest 和客户端投影,不用于 cwd 内的原生 patch。不要用原生文件或命令工具遍历父目录;`.agent/`、`assets/` 和项目根由客户端维护,不能请求扩权或直接改写。DirectProject 提供当前工作区内的 Codex 原生文件、搜索、命令图片查看能力,不提供外部工具目录。按用户意图自行检查、修改和验证,不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。项目锁、付费提交、幂等账本、下载校验和客户端投影仍由客户端确定性掌管。游戏文件真实变化后由客户端登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 Codex cwd 是项目真实 `game/` 源码目录,只允许把项目源码写入该目录;原生文件工具、原生 patch 和命令参数中的文件路径必须相对于当前 cwd:合法写法是 `index.html`、`style.css`、`game.js`,禁止写 `game/index.html`、`../game/index.html`、项目根绝对路径或任何其它父目录路径;`game/...` 只用于 AGC 回执、manifest 和客户端投影,不用于 cwd 内的原生 patch。不要用原生文件或命令工具遍历父目录;`.agent/`、`assets/` 和项目根由客户端维护,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令图片查看、Skill 能力,但只限当前工作区,不提供外部工具目录。按用户意图自行检查、修改和验证,不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。项目锁、付费提交、幂等账本、下载校验和客户端投影仍由客户端确定性掌管。游戏文件真实变化后由客户端登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png";
const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png";
const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png";
@@ -3634,12 +3634,14 @@ pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result<String, St
}
fn build_direct_codex_system_prompt_without_external_tools(_root: &Path) -> Result<String, String> {
let skill_index = render_agc_skill_pack_index()?;
let sections = vec![
"你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,也是当前唯一执行主体。用户聊天内容会原样直接发送给你;先自行理解意图:普通对话直接回答且不触碰工作区,项目请求再按需要检查、修改、运行和验证,并用简洁中文报告真实结果。客户端不会根据关键词替你决定新建、续做、生图、试玩、返工或版本登记。".to_string(),
DIRECT_TAONIER_IDENTITY_GUIDANCE.to_string(),
"工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(),
DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(),
"工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(),
format!("提示词与技能:{skill_index}"),
];
Ok(sections
.join("\n")
@@ -6054,7 +6056,7 @@ mod tests {
assert!(prompt.contains("desktop exception:"));
assert!(prompt.contains("AGC 浏览器事实证据"));
assert!(!prompt.contains("secret"), "{prompt}");
assert!(!prompt.contains("token=secret"), "{prompt}");
assert!(
!prompt.contains(&root.path().display().to_string()),
"{prompt}"
@@ -963,7 +963,7 @@ pub(crate) async fn submit_external_generation_request(
}
let request_body_json = serde_json::to_string(&request_body)
.map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?;
client
let response = client
.post(format!(
"{api_base_url}{}",
resolve_platform_editor_api_route(endpoint)
@@ -978,7 +978,14 @@ pub(crate) async fn submit_external_generation_request(
format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后未取得确定响应:{error}"
)
})
})?;
if response.status().is_server_error() {
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知",
response.status().as_u16()
));
}
Ok(response)
}
async fn resume_prepared_external_generation_at(
@@ -450,6 +450,10 @@ pub(crate) fn sanitize_prompt_context(value: &str) -> String {
let mut inside_private_key = false;
for line in value.lines() {
let lower = line.to_ascii_lowercase();
if contains_sensitive_cli_flag(&lower) {
sanitized.push("[redacted sensitive context]".to_string());
continue;
}
if inside_private_key {
if lower.contains("-----end") && lower.contains("private key") {
inside_private_key = false;
@@ -498,6 +502,30 @@ pub(crate) fn sanitize_prompt_context(value: &str) -> String {
sanitized.join("\n")
}
fn contains_sensitive_cli_flag(lower: &str) -> bool {
[
"--password",
"--token",
"--secret",
"--api-key",
"--apikey",
"--authorization",
"--cookie",
]
.into_iter()
.any(|flag| {
lower.split_whitespace().any(|token| {
let token = token.trim_matches(|character: char| {
matches!(
character,
',' | ';' | '' | '' | '(' | ')' | '[' | ']' | '{' | '}'
)
});
token == flag || token.starts_with(&format!("{flag}="))
})
})
}
// Prompt context is deliberately fail-closed: a line which looks like a
// credential assignment is dropped in full before it can reach a model. An
// error, however, is an operator-facing diagnostic. Dropping the whole line
@@ -565,6 +593,10 @@ pub(crate) fn sanitize_error_context(value: &str) -> String {
let mut inside_private_key = false;
for line in value.lines() {
let lower = line.to_ascii_lowercase();
if contains_sensitive_cli_flag(&lower) {
sanitized.push("[redacted sensitive context]".to_string());
continue;
}
if inside_private_key {
if lower.contains("-----end") && lower.contains("private key") {
inside_private_key = false;
@@ -878,15 +878,15 @@ pub(crate) fn publish_game_creator_agent_delegate_result(
return;
}
};
let result_detail = result_detail
let raw_result_detail = result_detail
.filter(|value| !value.trim().is_empty())
.or(child_task.terminal_detail.as_deref())
.or(child_task.error.as_deref())
.unwrap_or(child_task.current_action.as_str());
let result_detail = redact_agent_runtime_error(
root,
result_detail,
static_delegate_result_detail_max_chars(result_detail, 600),
raw_result_detail,
static_delegate_result_detail_max_chars(raw_result_detail, 600),
);
if uses_durable_delivery {
let existing_delivery = match read_static_delegate_delivery_at(root, delegation_id) {
@@ -1072,9 +1072,13 @@ pub(crate) fn publish_game_creator_agent_delegate_result(
if receipt_exists {
return;
}
let receipt_result_detail = sanitize_agent_runtime_text(
&sanitize_prompt_context(raw_result_detail),
static_delegate_result_detail_max_chars(raw_result_detail, 600),
);
let receipt_task = format!(
"收到委派子任务终态回执。子 Agent:{};状态:{};结果:{}。这是已完成委派的回执,不要重复委派同一任务;请整合结果并决定后续,需要原目标时调用 conversation.read。",
child_task.agent_id, terminal_status, result_detail,
child_task.agent_id, terminal_status, receipt_result_detail,
);
let receipt_link = AgentRuntimeTaskLink {
parent_agent_id: None,
@@ -459,10 +459,19 @@ fn validate_repository_root(root: &Path) -> Result<PathBuf, String> {
return Err("Repository root must not contain parent-directory components".to_string());
}
let canonical_temp_root = fs::canonicalize(std::env::temp_dir()).ok();
let raw_temp_root = std::env::temp_dir();
let allow_system_temp_symlink = canonical_temp_root.as_ref().is_some_and(|temp| {
fs::canonicalize(root).is_ok_and(|canonical| canonical.starts_with(temp))
}) && root.starts_with(&raw_temp_root);
for ancestor in root.ancestors() {
let metadata = fs::symlink_metadata(ancestor)
.map_err(|error| format!("Unable to inspect repository root ancestor: {error}"))?;
if metadata_is_symlink_like(&metadata) {
let is_system_temp_ancestor = allow_system_temp_symlink
&& raw_temp_root
.ancestors()
.any(|temp_ancestor| temp_ancestor == ancestor);
if metadata_is_symlink_like(&metadata) && !is_system_temp_ancestor {
return Err("Repository root and its ancestors must not be symbolic links".to_string());
}
}
@@ -2446,10 +2446,10 @@ fn spawn_mock_llm_stream_server_with_capture(
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("mock stream llm accept");
let mut request_buffer = [0_u8; 8192];
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
if let Some(sender) = request_sender.as_ref() {
let _ = sender.send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned());
let _ = sender.send(read_mock_http_request(&mut stream));
} else {
let _ = read_mock_http_request(&mut stream);
}
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nx-request-id: req_role_agent_stream\r\nConnection: close\r\n\r\n{}",
@@ -3028,11 +3028,9 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
let mut generation_poll_index = 0_u8;
for _ in 0..expected_requests {
let (mut stream, _) = listener.accept().expect("mock canvas api accept");
let mut request_buffer = [0_u8; 8192];
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
let request = String::from_utf8_lossy(&request_buffer[..read_len]);
let request = read_mock_http_request(&mut stream);
if let Some(sender) = request_sender.as_ref() {
let _ = sender.send(request.to_string());
let _ = sender.send(request.clone());
}
let normalized_request = request.to_ascii_lowercase();
let (status, content_type, body) = if request
@@ -473,6 +473,8 @@ export const ResourceDependencyOverlay = forwardRef<
) {
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
const overlayRef = useRef<SVGSVGElement>(null);
const canvasViewportRef = useRef(canvasViewport);
const scheduleViewportMeasureRef = useRef<() => void>(() => undefined);
const [logicalViewport, setLogicalViewport] = useState<Rect | null>(null);
const [dragPreview, setDragPreview] = useState<
(Point & { resourceId: string }) | null
@@ -507,7 +509,7 @@ export const ResourceDependencyOverlay = forwardRef<
const transformedViewport = plane.closest(
'.game-resource-canvas--dependency',
)
? canvasViewport
? canvasViewportRef.current
: undefined;
const scale = transformedViewport?.scale ?? legacyScale;
const viewportRect = viewport.getBoundingClientRect();
@@ -536,6 +538,7 @@ export const ResourceDependencyOverlay = forwardRef<
}
frameId = window.requestAnimationFrame(measure);
};
scheduleViewportMeasureRef.current = scheduleMeasure;
measure();
const ResizeObserverClass = window.ResizeObserver;
const observer = ResizeObserverClass
@@ -552,7 +555,13 @@ export const ResourceDependencyOverlay = forwardRef<
observer?.disconnect();
viewport.removeEventListener('scroll', scheduleMeasure);
window.removeEventListener('resize', scheduleMeasure);
scheduleViewportMeasureRef.current = () => undefined;
};
}, []);
useLayoutEffect(() => {
canvasViewportRef.current = canvasViewport;
scheduleViewportMeasureRef.current();
}, [canvasViewport, geometryRevision]);
const rectByResourceId = useMemo(() => {
@@ -26,6 +26,7 @@ import {
memo,
type PointerEvent as ReactPointerEvent,
type ReactNode,
startTransition,
useCallback,
useEffect,
useId,
@@ -850,6 +851,9 @@ export default function ProjectDevelopmentView({
const [searchText, setSearchText] = useState('');
const [resourceCanvasViewports, setResourceCanvasViewports] =
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
const resourceCanvasViewportTargetsRef = useRef(
defaultResourceCanvasViewports(),
);
const [activeResourceCategory, setActiveResourceCategory] =
useState<ResourceCategory | null>(null);
const [unreadResourceCategoryState, setUnreadResourceCategoryState] =
@@ -942,8 +946,10 @@ export default function ProjectDevelopmentView({
pendingStepCount: 0,
pendingTimerId: null,
});
const renderedResourceCategoryRef = useRef<ResourceCategory | null>(null);
const resourceCanvasPanRef = useRef<{
pointerId: number;
category: ResourceCategory;
startClientX: number;
startClientY: number;
startViewport: CanvasViewport;
@@ -1499,7 +1505,6 @@ export default function ProjectDevelopmentView({
unreadResourceCategoryState.scopeKey === resourceCategoryScopeKey
? unreadResourceCategoryState.categories
: new Set<ResourceCategory>();
resourceCanvasPageWheelRef.current.activeCategory = activePageCategory;
const nextPageCategory =
activePageCategory && resourcePageCategories.length > 1
? resourcePageCategories[
@@ -1608,36 +1613,74 @@ export default function ProjectDevelopmentView({
resourcePositionsByCategory,
],
);
const activeResourceCanvasViewport = activePageCategory
? resourceCanvasViewports[sortMode][activePageCategory]
: { x: 48, y: 48, scale: 1 };
const activeResourceCanvasViewport = useMemo(
() =>
activePageCategory
? resourceCanvasViewports[sortMode][activePageCategory]
: { x: 48, y: 48, scale: 1 },
[activePageCategory, resourceCanvasViewports, sortMode],
);
const setResourceCanvasViewport = useCallback(
(candidate: CanvasViewport) => {
const activeCategory = resourceCanvasPageWheelRef.current.activeCategory;
if (!activeCategory) {
(
category: ResourceCategory,
candidate: CanvasViewport,
deferRender = false,
) => {
if (!resourcePageCategories.includes(category)) {
return;
}
const next = normalizeInfiniteResourceCanvasViewport(candidate);
resourceCanvasViewportRef.current = next;
setResourceCanvasViewports((current) => {
const currentSortViewports = current[sortMode];
return resourceCanvasViewportsEqual(
currentSortViewports[activeCategory],
next,
)
? current
: {
...current,
[sortMode]: {
...currentSortViewports,
[activeCategory]: next,
},
};
});
resourceCanvasViewportTargetsRef.current = {
...resourceCanvasViewportTargetsRef.current,
[sortMode]: {
...resourceCanvasViewportTargetsRef.current[sortMode],
[category]: next,
},
};
const commitViewport = () => {
setResourceCanvasViewports((current) => {
const currentSortViewports = current[sortMode];
if (
!resourceCanvasViewportsEqual(
resourceCanvasViewportTargetsRef.current[sortMode][category],
next,
)
) {
return current;
}
return resourceCanvasViewportsEqual(
currentSortViewports[category],
next,
)
? current
: {
...current,
[sortMode]: {
...currentSortViewports,
[category]: next,
},
};
});
};
if (deferRender) {
startTransition(commitViewport);
} else {
commitViewport();
}
},
[sortMode],
[resourcePageCategories, sortMode],
);
resourceCanvasViewportRef.current = activeResourceCanvasViewport;
useLayoutEffect(() => {
const pageWheelState = resourceCanvasPageWheelRef.current;
renderedResourceCategoryRef.current = activePageCategory;
if (pageWheelState.activeCategory === null) {
pageWheelState.activeCategory = activePageCategory;
}
if (pageWheelState.activeCategory === activePageCategory) {
resourceCanvasViewportRef.current = activeResourceCanvasViewport;
}
}, [activePageCategory, activeResourceCanvasViewport]);
const selectedResource =
resources.find((resource) => resource.id === selectedResourceId) ?? null;
const focusedResource =
@@ -1852,6 +1895,7 @@ export default function ProjectDevelopmentView({
state.lastSwitchAt = 0;
state.pendingStepCount = 0;
state.pendingTimerId = null;
state.activeCategory = renderedResourceCategoryRef.current;
}, []);
const cancelResourceCanvasPan = useCallback(() => {
@@ -1919,7 +1963,13 @@ export default function ProjectDevelopmentView({
cancelResourceCardDrag();
cancelResourceCanvasPan();
resourceCanvasPageWheelRef.current.activeCategory = nextCategory;
setActiveResourceCategory(nextCategory);
startTransition(() => {
setActiveResourceCategory((currentCategory) =>
resourceCanvasPageWheelRef.current.activeCategory === nextCategory
? nextCategory
: currentCategory,
);
});
},
[cancelResourceCanvasPan, cancelResourceCardDrag, resourcePageCategories],
);
@@ -1948,10 +1998,15 @@ export default function ProjectDevelopmentView({
const waitMs = pageWheelSwitchIntervalMs - (now - state.lastSwitchAt);
if (waitMs > 0) {
if (state.pendingTimerId === null) {
state.pendingTimerId = window.setTimeout(() => {
resourceCanvasPageWheelRef.current.pendingTimerId = null;
const timerId = window.setTimeout(() => {
const latestState = resourceCanvasPageWheelRef.current;
if (latestState.pendingTimerId !== timerId) {
return;
}
latestState.pendingTimerId = null;
flushPageWheel();
}, waitMs);
state.pendingTimerId = timerId;
}
return;
}
@@ -1974,8 +2029,15 @@ export default function ProjectDevelopmentView({
if (resourcePageCategories.length > 0) {
event.preventDefault();
if (event.ctrlKey || event.metaKey) {
const rect =
resourceCanvasViewportElement(canvas)!.getBoundingClientRect();
cancelQueuedResourceCanvasPageWheel();
const viewport = resolveViewport(event);
const category = viewport?.dataset.resourceSectionScroll as
| ResourceCategory
| undefined;
if (!viewport || !category) {
return;
}
const rect = viewport.getBoundingClientRect();
const result = resolveViewportFromWheel({
viewport: resourceCanvasViewportRef.current,
deltaX: event.deltaX,
@@ -1988,7 +2050,7 @@ export default function ProjectDevelopmentView({
y: event.clientY - rect.top,
},
});
setResourceCanvasViewport(result.viewport);
setResourceCanvasViewport(category, result.viewport, true);
return;
}
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) {
@@ -2064,6 +2126,7 @@ export default function ProjectDevelopmentView({
};
const gestureStart = (rawEvent: Event) => {
const event = rawEvent as WebKitGestureEvent;
cancelQueuedResourceCanvasPageWheel();
const viewport = resolveViewport(event);
const category = viewport?.dataset.resourceSectionScroll as
| ResourceCategory
@@ -2148,6 +2211,7 @@ export default function ProjectDevelopmentView({
}, [
advanceResourceCanvasPage,
applyAnchoredResourceSectionZoom,
cancelQueuedResourceCanvasPageWheel,
mode,
queueResourceSectionZoomScroll,
resourcePageCategories.length,
@@ -2175,6 +2239,7 @@ export default function ProjectDevelopmentView({
if (measuredWidth > 0 && measuredHeight > 0) {
resourceCanvasFitKeysRef.current.add(fitKey);
setResourceCanvasViewport(
activePageCategory,
fitResourceCanvasViewportToContent({
bounds: resourceCanvasFitBounds,
canvasSize: { width: measuredWidth, height: measuredHeight },
@@ -2186,7 +2251,10 @@ export default function ProjectDevelopmentView({
}
// Resource extents can change after an image finishes measuring or a
// card is manually moved. Keep the user's infinite pan/zoom unchanged.
setResourceCanvasViewport(resourceCanvasViewportRef.current);
setResourceCanvasViewport(
activePageCategory,
resourceCanvasViewportRef.current,
);
};
fitInitialViewportOrPreserve();
const observer = window.ResizeObserver
@@ -2346,6 +2414,7 @@ export default function ProjectDevelopmentView({
restoreResourceListScrollRef.current = false;
setActiveResourceCategory(null);
const defaultViewports = defaultResourceCanvasViewports();
resourceCanvasViewportTargetsRef.current = defaultViewports;
setResourceCanvasViewports(defaultViewports);
resourceCanvasFitKeysRef.current.clear();
resourceCanvasViewportRef.current = defaultViewports.dependency.document;
@@ -2769,15 +2838,19 @@ export default function ProjectDevelopmentView({
);
const resetResourceCanvasViewport = useCallback(() => {
if (!activePageCategory) {
return;
}
const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current);
setResourceCanvasViewport(
activePageCategory,
fitResourceCanvasViewportToContent({
bounds: resourceCanvasFitBounds,
canvasSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
}),
);
}, [resourceCanvasFitBounds, setResourceCanvasViewport]);
}, [activePageCategory, resourceCanvasFitBounds, setResourceCanvasViewport]);
const handleResourceCardPointerDown = useCallback(
(
@@ -2931,6 +3004,7 @@ export default function ProjectDevelopmentView({
event.currentTarget.setPointerCapture(event.pointerId);
resourceCanvasPanRef.current = {
pointerId: event.pointerId,
category: activePageCategory,
startClientX: event.clientX,
startClientY: event.clientY,
startViewport: resourceCanvasViewportRef.current,
@@ -2945,11 +3019,15 @@ export default function ProjectDevelopmentView({
if (!pan || pan.pointerId !== event.pointerId) {
return;
}
setResourceCanvasViewport({
...pan.startViewport,
x: pan.startViewport.x + event.clientX - pan.startClientX,
y: pan.startViewport.y + event.clientY - pan.startClientY,
});
setResourceCanvasViewport(
pan.category,
{
...pan.startViewport,
x: pan.startViewport.x + event.clientX - pan.startClientX,
y: pan.startViewport.y + event.clientY - pan.startClientY,
},
true,
);
},
[setResourceCanvasViewport],
);
@@ -2973,6 +3051,7 @@ export default function ProjectDevelopmentView({
advanceFocusGeneration();
cancelResourceCardDrag();
cancelResourceCanvasPan();
cancelQueuedResourceCanvasPageWheel();
captureResourceListScrollPosition();
captureResourceSectionScrollPositions();
setSortMode(nextMode);
@@ -986,8 +986,11 @@ describe('ResourceDependencyOverlay', () => {
it('disconnects layout observers when the SVG layer is destroyed', () => {
const observe = vi.fn();
const disconnect = vi.fn();
const construct = vi.fn();
class TestResizeObserver {
constructor(_callback: ResizeObserverCallback) {}
constructor(_callback: ResizeObserverCallback) {
construct();
}
observe = observe;
unobserve = vi.fn();
@@ -1004,6 +1007,7 @@ describe('ResourceDependencyOverlay', () => {
overlayView(graph, positions, new Set(graph.resourceIds), overlayRef),
);
expect(construct).toHaveBeenCalledTimes(1);
expect(observe).toHaveBeenCalledTimes(2);
act(() =>
overlayRef.current?.updateDragPreview({
@@ -1012,6 +1016,10 @@ describe('ResourceDependencyOverlay', () => {
y: 24,
}),
);
view.rerender(
overlayView(graph, positions, new Set(graph.resourceIds), overlayRef),
);
expect(construct).toHaveBeenCalledTimes(1);
expect(observe).toHaveBeenCalledTimes(2);
view.unmount();
expect(disconnect).toHaveBeenCalledTimes(1);
@@ -643,6 +643,56 @@ export function registerProjectWorkbenchFoundationTests() {
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
dispatchPageWheel(dependencyCanvas);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
dispatchPageWheel(dependencyCanvas);
const zoomWhilePageWheelQueued = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaX: 0,
deltaY: -120,
clientX: 80,
clientY: 60,
});
act(() => {
expect(
screen
.getByRole('button', { name: '复位资源画布' })
.dispatchEvent(zoomWhilePageWheelQueued),
).toBe(false);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 220));
});
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
dispatchPageWheel(dependencyCanvas);
expect(
screen.getByRole('region', { name: '音乐音效资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
act(() => {
dependencyCanvas.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 160,
}),
);
within(outline)
.getByRole('button', { name: /^项目版本/ })
.click();
});
expect(
screen.getByRole('region', { name: '项目版本资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
const dragCard = screen
.getByRole('button', {
name: /打开资源详情:设计文档 section\.md/,
@@ -55,3 +55,7 @@
2. 确认没有密钥、`.env`、Cookie、日志、缓存、数据库 dump、构建产物或个人路径。
3. 确认相关当前文档与共享记忆已同步,且 docs 入口没有指向已删除或退役实现依据。
4. 提交标题使用中文,标题后逐行写明本次变更。
## Gitea CI 依赖闭合
`.gitea/workflows/project-ci.yml``Native shell tests` 在运行原生壳门禁前,必须使用 `cargo fetch --locked` 预取 `server-rs/Cargo.toml`、桌面壳和 AGC 壳三份依赖。AGC 壳检查还会运行 `platform-llm``shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
@@ -4939,3 +4939,10 @@
- 严格图集崩溃补充:规范图和背景图的两文件 rollback 不覆盖严格图集事务已经整体修改的 `.agent/manifest.json`、私有回执、公开清单、主图集、四切片和切片清单。必须在严格调用前持久化 pending 及九项旧合同身份;重启恢复先对账底层严格事务,完整新合同直接收口完成,完整旧合同才补偿前两阶段,混合或漂移状态失败关闭。不要在严格提交成功后局部恢复前两张图。
- 部分旧包补充:rollback 的规范图/背景图必须保存旧字节与旧 manifest entry,不能把这两项缺失隐式当成空内容;显式 `regenerate` 因此只在这两项可信可回滚时开放。历史主图集、私有回执、公开清单或 canonical 切片可以缺失,但八个严格路径与受管顶层 asset identity 必须逐项冻结其真实 `Present/Some``Missing/None` 状态,补偿也必须恢复相同存在性。不要因为旧美术包缺切片而阻断重生成,也不要把本轮新建的严格文件误记成旧文件。
- 对话扫描与 claim 补充:历史中出现 `User A / User B / Assistant B` 时,B 已回答不代表 A 已回答,扫描必须继续寻找 A。成功 Direct 回复在 Rust 返回前已经落盘,前端冗余 append 失败不能据此重跑;普通错误回复的显式落盘失败时,恢复 claim 要保持到 React fallback writer 的同一 messageId append 明确收敛。writer 成功或明确失败后才释放;失败路径要停止该消息的自动迟到重试,再由显式 `/history` 复用原 stable turn。终态后及时删除 claim,避免 Set 无界增长。
## Native shell CI 不能在测试阶段重新解析 Cargo registry2026-08-26
- 现象:原生壳 job 的依赖预取成功后,AGC 检查仍在 `platform-llm` 测试阶段重新更新 registry index,并因 `symphonia` 下载的 TLS EOF 失败。
- 原因:native job 没有显式预取 `server-rs/Cargo.toml``ai-game-creator-shell:check` 的 server-rs workspace 命令没有 `--locked`
- 处理:native job 预取 server-rs、桌面壳和 AGC 壳三份 lockfile`platform-llm``shared-contracts` 与 AGC 壳测试统一使用 `--locked`,不降低原生测试门禁。
- 验证:workflow 回归测试、锁定的 Rust 测试和原生壳门禁均需运行;若本地 EAS CLI 版本漂移,应单独报告环境阻塞,不把它误判为本次 Cargo CI 修复失败。
@@ -83,14 +83,14 @@
## 操作边界
- 栏目顺序固定为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`。完全空项目显示全部栏目的分区展览;任一栏目出现资源后,分页大纲以左侧垂直居中的悬浮 Dock 展示全部栏目。常态缩小、降低不透明度并移除容器与选中项背景,只露出栏目文字;悬停或键盘聚焦时平滑恢复完整尺寸,显示栏目图标、Dock 背景和选中态视觉强调。默认停留在该顺序中的第一个非空栏目,空栏目仍可打开空画布。
- 普通滚轮向下切到下一栏目、向上切到上一栏目并循环;持续滚动时将离散切页意图加入有界队列,浏览器合并形成的单个大幅滚轮事件也要按输入强度拆分为多个切页意图。同一节流窗口内的待处理步数必须合并为一次目标栏目切换,不能逐页挂载并加载中间栏目的资源,以免资源渲染阻塞后续滚轮输入;同时限制切页频率和最长排队距离,避免触控板惯性长时间自动翻页。点击大纲、底部“下一页”标题或自动定位资源属于显式切页,必须先取消尚未执行的滚轮队列,不能在显式切页后继续跳转;开始拖动画布或资源卡也必须取消待处理切页,切页前必须终止旧栏目的画布拖动和 pointer capture,避免旧 viewport 写入新栏目。
- 普通滚轮向下切到下一栏目、向上切到上一栏目并循环;持续滚动时将离散切页意图加入有界队列,浏览器合并形成的单个大幅滚轮事件也要按输入强度拆分为多个切页意图。同一节流窗口内的待处理步数必须合并为一次目标栏目切换,不能逐页挂载并加载中间栏目的资源,以免资源渲染阻塞后续滚轮输入;逻辑目标栏目在事件处理中同步推进,可见栏目和 viewport 通过 React transition 异步提交,渲染尚未完成时到达的新滚轮仍基于最新逻辑目标继续计算,渲染过程不得把目标栏目回写成旧页面。同时限制切页频率和最长排队距离,避免触控板惯性长时间自动翻页。点击大纲、底部“下一页”标题或自动定位资源属于显式切页,必须先取消尚未执行的滚轮队列,不能在显式切页后继续跳转;Ctrl/Meta 缩放、触控板缩放手势、排序切换和开始拖动画布或资源卡也必须取消待处理切页,缩放后的下一次普通滚轮应立即建立新的翻页意图。切页前必须终止旧栏目的画布拖动和 pointer capture,避免旧 viewport 写入新栏目。
- 每种排序模式下的每个栏目画布都保留独立 viewport;首次进入该“排序模式 + 栏目”组合时按当前内容适配视口,离开后再返回则恢复该组合上次的平移和缩放。空白处拖拽平移画布,资源卡拖拽移动卡片并更新依赖线,Ctrl/Meta 缩放只作用于当前组合,不能牵动其它排序模式或栏目。
- 依赖画布复用 `@genarrative/image-canvas-core` 的 viewport 计算,并复用现有资源卡片、布局和依赖连线模型。
- 非空状态不使用资源分区滚动条、分区缩放或分区高度操作作为主要导航;栏目通过大纲、底部下一页标题和滚轮切换。
- 当前栏目画布背景是无限的:用户可以将 viewport 沿 x/y 任意方向平移,画布不以资源 extent 作为导航边界,也不显示可见画布边缘。资源卡片的持久化布局坐标允许落在 `-1_000_000..=1_000_000`,用于支撑元素位于世界原点左上方;超出该范围仍拒绝写入,避免持久化非法布局。这与 viewport 能否继续平移是两层独立语义。搜索、详情卡和临时隐藏不得改变 viewport。
- 普通滚轮切换栏目,指针拖动空白平移画布,Ctrl/Meta 缩放、复位以及容器 resize 后都必须保持同一套 viewport 数据流。只有“排序模式 + 栏目”组合首次获得可测量容器尺寸或用户显式复位时才重新适配内容;返回已访问组合、图片尺寸测量、布局拖动或资源 extent 变化只归一化并保留该组合的当前 viewport,不得意外重置用户已经完成的平移和缩放。普通平移不夹取 x/y;缩放仍受共享画布的最小/最大比例限制。初次 fit 与显式复位只使用资源卡真实包围盒,不把导航最小尺寸、原点空区或额外布局 gap 算入,并以 `16px` 紧凑留白在共享缩放上限内尽量铺满视口。
- 资源卡拖动使用 `5px` 阈值区分点击与移动;移动期间按当前 scale 乐观换算世界坐标、显示拖动态并同步依赖线,释放时提交一次 `manuallyPlaced=true` 布局 CAS,取消则回滚预览且不提交。拖动后的释放点击不打开详情。
- 依赖模式只在当前栏目画布内显示两端都属于该栏目的合法精确引用;装饰 SVG 与视觉隐藏的关系说明消费同一组可见边,搜索隐藏任一端点时两者同步移除。任务流仍只参与同类型布局聚类,不绘线也不进入关系说明。
- 依赖模式只在当前栏目画布内显示两端都属于该栏目的合法精确引用;装饰 SVG 与视觉隐藏的关系说明消费同一组可见边,搜索隐藏任一端点时两者同步移除。依赖线的 viewport 测量按动画帧合并,平移和缩放只更新已挂载观察器消费的最新 viewport,不得在每次输入时重建 ResizeObserver、scroll 或 resize 监听。任务流仍只参与同类型布局聚类,不绘线也不进入关系说明。
- 资源详情卡包含元数据、媒体预览和“编辑资源”操作,但不使用全屏 backdrop、不声明 `aria-modal=true`、不把 `focusedResource` 作为背景工具栏渲染条件。角色资源同时显示“生成动画”时,两个业务操作按钮必须使用一致样式,不能依赖 DOM 中的首按钮位置。桌面端允许继续操作背景画板;窄屏可以使用有边界的贴边卡,但背景组件必须保持挂载。
- 完全空项目继续使用可纵向滚动的五分区展览,不挂载分页画布或依赖画布的 `overflow: hidden` 交互壳;低高度窗口在两种排序模式下都能滚动到“项目版本”。
- 在“按依赖 / 按类型”之间切换或离开资源管理进入运行视图后返回时,恢复对应“排序模式 + 栏目”的现有 viewport,不自动触发“复位资源画布”,也不得用另一排序模式的 viewport 覆盖用户已经完成的平移和缩放。首次 fit 与用户显式复位仍使用同一套真实资源包围盒算法。
+1 -1
View File
@@ -188,7 +188,7 @@
"ai-game-creator-shell:agent-runtime:steer-runner-kill-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-runner-kill-real-e2e --",
"agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml",
"ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck",
"ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && npm run agent-runtime-core:check && cargo test -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1 && npm run ai-game-creator-shell:agent-run:smoke",
"ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && npm run agent-runtime-core:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1 && npm run ai-game-creator-shell:agent-run:smoke",
"check:native-shells": "node scripts/check-native-shells.mjs"
},
"dependencies": {
+2
View File
@@ -312,5 +312,7 @@ describe('project CI workflow', () => {
expect(nativeJob).toContain(
'git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock',
);
expect(nativeJob).toContain('server-rs/Cargo.toml');
expect(nativeJob).toContain('cargo fetch --locked');
});
});
+24 -2
View File
@@ -2,6 +2,7 @@ use std::{
env,
error::Error,
fmt, fs,
net::IpAddr,
path::PathBuf,
str as std_str,
sync::atomic::{AtomicU64, Ordering},
@@ -1496,7 +1497,13 @@ impl LlmError {
impl LlmClient {
pub fn new(config: LlmConfig) -> Result<Self, LlmError> {
let http_client = Client::builder()
let mut builder = Client::builder();
if llm_base_url_is_loopback(config.base_url()) {
// 本地 mock/sidecar 必须直连。否则开发机 HTTP_PROXY 可能把已断开的
// loopback 连接改写成 HTTP 502,破坏 Provider transport 错误分类。
builder = builder.no_proxy();
}
let http_client = builder
.use_rustls_tls()
.http1_only()
.build()
@@ -1511,7 +1518,11 @@ impl LlmClient {
}
pub fn new_without_redirects(config: LlmConfig) -> Result<Self, LlmError> {
let http_client = Client::builder()
let mut builder = Client::builder();
if llm_base_url_is_loopback(config.base_url()) {
builder = builder.no_proxy();
}
let http_client = builder
.use_rustls_tls()
.http1_only()
.redirect(Policy::none())
@@ -2040,6 +2051,17 @@ impl LlmClient {
}
}
fn llm_base_url_is_loopback(base_url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(base_url) else {
return false;
};
match url.host_str() {
Some("localhost") => true,
Some(host) => host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback()),
None => false,
}
}
impl OpenAiCompatibleSseParser {
fn new(api_kind: LlmApiKind) -> Self {
Self {