让运行视窗按内容自适应
为本地预览注入只读尺寸桥并校验 iframe 消息来源 按可用区域等比缩放完整游戏画面并移除横纵滚动条 补充前端与 Rust 回归测试并同步运行视窗文档
This commit is contained in:
@@ -89,6 +89,53 @@ const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024;
|
||||
const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100;
|
||||
const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024;
|
||||
const PREVIEW_FIT_BRIDGE_PATH: &str = "/__genarrative/local-preview-fit.js";
|
||||
const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => {
|
||||
const messageType = 'genarrative.local-preview-size.v1';
|
||||
let ready = document.readyState === 'complete';
|
||||
let frame = 0;
|
||||
const publish = () => {
|
||||
frame = 0;
|
||||
if (!ready || window.parent === window) return;
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
window.parent.postMessage({
|
||||
type: messageType,
|
||||
contentWidth: Math.ceil(Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0)),
|
||||
contentHeight: Math.ceil(Math.max(root?.scrollHeight || 0, body?.scrollHeight || 0)),
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
}, '*');
|
||||
};
|
||||
const schedule = () => {
|
||||
if (frame === 0) frame = window.requestAnimationFrame(publish);
|
||||
};
|
||||
const activate = () => {
|
||||
ready = true;
|
||||
const style = document.createElement('style');
|
||||
style.dataset.genarrativePreviewFit = 'true';
|
||||
style.textContent = 'html,body{scrollbar-width:none}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none}';
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
schedule();
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
const resizeObserver = new ResizeObserver(schedule);
|
||||
resizeObserver.observe(document.documentElement);
|
||||
if (document.body) resizeObserver.observe(document.body);
|
||||
}
|
||||
const mutationObserver = new MutationObserver(schedule);
|
||||
mutationObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
window.addEventListener('resize', schedule, { passive: true });
|
||||
};
|
||||
if (ready) activate();
|
||||
else window.addEventListener('load', activate, { once: true });
|
||||
})();"#;
|
||||
const PREVIEW_FIT_BRIDGE_TAG: &str =
|
||||
r#"<script src="/__genarrative/local-preview-fit.js"></script>"#;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum PreviewListenerAcceptDisposition {
|
||||
@@ -560,6 +607,20 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str)
|
||||
);
|
||||
}
|
||||
|
||||
if url_path.split('?').next() == Some(PREVIEW_FIT_BRIDGE_PATH) {
|
||||
let body = if is_head {
|
||||
Vec::new()
|
||||
} else {
|
||||
PREVIEW_FIT_BRIDGE_SCRIPT.as_bytes().to_vec()
|
||||
};
|
||||
return http_response(
|
||||
"200 OK",
|
||||
"text/javascript; charset=utf-8",
|
||||
&body,
|
||||
PREVIEW_FIT_BRIDGE_SCRIPT.len(),
|
||||
);
|
||||
}
|
||||
|
||||
let file_path = match resolve_preview_path(root, url_path) {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
@@ -574,11 +635,36 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str)
|
||||
return http_response("404 Not Found", "text/plain", body, b"not found".len());
|
||||
}
|
||||
};
|
||||
let body = if content_type(&file_path).starts_with("text/html") {
|
||||
inject_preview_fit_bridge(body)
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let content_length = body.len();
|
||||
let body = if is_head { Vec::new() } else { body };
|
||||
http_response("200 OK", content_type(&file_path), &body, content_length)
|
||||
}
|
||||
|
||||
fn inject_preview_fit_bridge(body: Vec<u8>) -> Vec<u8> {
|
||||
let html = match String::from_utf8(body) {
|
||||
Ok(html) => html,
|
||||
Err(error) => return error.into_bytes(),
|
||||
};
|
||||
if html.contains(PREVIEW_FIT_BRIDGE_PATH) {
|
||||
return html.into_bytes();
|
||||
}
|
||||
let lowercase = html.to_ascii_lowercase();
|
||||
let insertion = lowercase
|
||||
.rfind("</body>")
|
||||
.or_else(|| lowercase.rfind("</html>"))
|
||||
.unwrap_or(html.len());
|
||||
let mut output = String::with_capacity(html.len() + PREVIEW_FIT_BRIDGE_TAG.len());
|
||||
output.push_str(&html[..insertion]);
|
||||
output.push_str(PREVIEW_FIT_BRIDGE_TAG);
|
||||
output.push_str(&html[insertion..]);
|
||||
output.into_bytes()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result<PathBuf, String> {
|
||||
let path = url_path.split('?').next().unwrap_or("/");
|
||||
let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?;
|
||||
|
||||
@@ -3440,6 +3440,28 @@ fn local_preview_server_serves_game_index() {
|
||||
|
||||
assert!(response.contains("200 OK"), "{response}");
|
||||
assert!(response.contains("还没有生成游戏"));
|
||||
assert!(
|
||||
response.contains("/__genarrative/local-preview-fit.js"),
|
||||
"{response}"
|
||||
);
|
||||
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
|
||||
stream
|
||||
.write_all(b"GET /__genarrative/local-preview-fit.js HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
.expect("fit bridge request");
|
||||
let mut response = String::new();
|
||||
stream
|
||||
.read_to_string(&mut response)
|
||||
.expect("fit bridge response");
|
||||
assert!(response.contains("200 OK"), "{response}");
|
||||
assert!(
|
||||
response.contains("Content-Type: text/javascript; charset=utf-8"),
|
||||
"{response}"
|
||||
);
|
||||
assert!(
|
||||
response.contains("genarrative.local-preview-size.v1"),
|
||||
"{response}"
|
||||
);
|
||||
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
|
||||
stream
|
||||
|
||||
+157
-8
@@ -1,4 +1,22 @@
|
||||
/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */
|
||||
/* eslint-disable react-refresh/only-export-components -- The URL and fit helpers are exported with their small rendering adapter for focused tests. */
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export const LOCAL_GAME_PREVIEW_SIZE_MESSAGE =
|
||||
'genarrative.local-preview-size.v1';
|
||||
|
||||
export type LocalGamePreviewContentSize = {
|
||||
contentWidth: number;
|
||||
contentHeight: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
};
|
||||
|
||||
export type LocalGamePreviewFitLayout = {
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
export type LocalGamePreviewLike = {
|
||||
status?: string | null;
|
||||
@@ -25,6 +43,59 @@ export function resolveEmbeddedPreviewUrl(
|
||||
}
|
||||
}
|
||||
|
||||
function positiveFiniteDimension(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.min(value, 100_000)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function parseLocalGamePreviewContentSize(
|
||||
value: unknown,
|
||||
): LocalGamePreviewContentSize | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.type !== LOCAL_GAME_PREVIEW_SIZE_MESSAGE) return null;
|
||||
const contentWidth = positiveFiniteDimension(candidate.contentWidth);
|
||||
const contentHeight = positiveFiniteDimension(candidate.contentHeight);
|
||||
const viewportWidth = positiveFiniteDimension(candidate.viewportWidth);
|
||||
const viewportHeight = positiveFiniteDimension(candidate.viewportHeight);
|
||||
if (
|
||||
contentWidth === null ||
|
||||
contentHeight === null ||
|
||||
viewportWidth === null ||
|
||||
viewportHeight === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { contentWidth, contentHeight, viewportWidth, viewportHeight };
|
||||
}
|
||||
|
||||
export function resolveLocalGamePreviewFitLayout(
|
||||
container: { width: number; height: number },
|
||||
content: LocalGamePreviewContentSize | null,
|
||||
): LocalGamePreviewFitLayout {
|
||||
const containerWidth = Math.max(1, container.width);
|
||||
const containerHeight = Math.max(1, container.height);
|
||||
if (!content) {
|
||||
return { width: containerWidth, height: containerHeight, scale: 1 };
|
||||
}
|
||||
const width = Math.max(
|
||||
containerWidth,
|
||||
content.viewportWidth,
|
||||
content.contentWidth,
|
||||
);
|
||||
const height = Math.max(
|
||||
containerHeight,
|
||||
content.viewportHeight,
|
||||
content.contentHeight,
|
||||
);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
scale: Math.min(1, containerWidth / width, containerHeight / height),
|
||||
};
|
||||
}
|
||||
|
||||
export function LocalGamePreviewFrame({
|
||||
preview,
|
||||
title,
|
||||
@@ -35,16 +106,94 @@ export function LocalGamePreviewFrame({
|
||||
className?: string;
|
||||
}) {
|
||||
const embeddedUrl = resolveEmbeddedPreviewUrl(preview);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const measuredContainerSizeRef = useRef({ width: 1, height: 1 });
|
||||
const [containerSize, setContainerSize] = useState({ width: 1, height: 1 });
|
||||
const [contentSize, setContentSize] =
|
||||
useState<LocalGamePreviewContentSize | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const update = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const next = {
|
||||
width: Math.max(1, rect.width),
|
||||
height: Math.max(1, rect.height),
|
||||
};
|
||||
const current = measuredContainerSizeRef.current;
|
||||
if (
|
||||
Math.abs(current.width - next.width) < 0.5 &&
|
||||
Math.abs(current.height - next.height) < 0.5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
measuredContainerSizeRef.current = next;
|
||||
setContainerSize(next);
|
||||
setContentSize(null);
|
||||
};
|
||||
update();
|
||||
if (typeof window.ResizeObserver === 'function') {
|
||||
const observer = new window.ResizeObserver(update);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [embeddedUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setContentSize(null);
|
||||
}, [embeddedUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embeddedUrl) return;
|
||||
const expectedOrigin = new URL(embeddedUrl).origin;
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (
|
||||
event.origin !== expectedOrigin ||
|
||||
event.source !== iframeRef.current?.contentWindow
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const next = parseLocalGamePreviewContentSize(event.data);
|
||||
if (!next) return;
|
||||
setContentSize((current) => {
|
||||
if (!current) return next;
|
||||
const reportsContainerViewport =
|
||||
Math.abs(next.viewportWidth - containerSize.width) < 1 &&
|
||||
Math.abs(next.viewportHeight - containerSize.height) < 1;
|
||||
return reportsContainerViewport ? next : current;
|
||||
});
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [containerSize.height, containerSize.width, embeddedUrl]);
|
||||
|
||||
const fit = useMemo(
|
||||
() => resolveLocalGamePreviewFitLayout(containerSize, contentSize),
|
||||
[containerSize, contentSize],
|
||||
);
|
||||
if (!embeddedUrl) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
className={className}
|
||||
title={title}
|
||||
src={embeddedUrl}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"
|
||||
allow="autoplay; fullscreen; gamepad"
|
||||
/>
|
||||
<div ref={containerRef} className="local-game-preview-frame">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={className}
|
||||
title={title}
|
||||
src={embeddedUrl}
|
||||
scrolling="no"
|
||||
style={{
|
||||
width: `${fit.width}px`,
|
||||
height: `${fit.height}px`,
|
||||
transform: `translate(-50%, -50%) scale(${fit.scale})`,
|
||||
}}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"
|
||||
allow="autoplay; fullscreen; gamepad"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1879,12 +1879,26 @@ textarea {
|
||||
background: #05070b;
|
||||
}
|
||||
|
||||
.game-chat-preview iframe {
|
||||
display: block;
|
||||
.local-game-preview-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #05070b;
|
||||
}
|
||||
|
||||
.local-game-preview-frame iframe {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: block;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.game-chat-conversation {
|
||||
@@ -6037,13 +6051,7 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-run-preview iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.game-run-preview-empty {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
|
||||
parseLocalGamePreviewContentSize,
|
||||
resolveLocalGamePreviewFitLayout,
|
||||
} from '../src/features/project-workspace/LocalGamePreviewFrame';
|
||||
|
||||
describe('local game preview viewport fitting', () => {
|
||||
it('keeps a game at native size when its content fits', () => {
|
||||
expect(
|
||||
resolveLocalGamePreviewFitLayout(
|
||||
{ width: 1200, height: 700 },
|
||||
{
|
||||
contentWidth: 1200,
|
||||
contentHeight: 700,
|
||||
viewportWidth: 1200,
|
||||
viewportHeight: 700,
|
||||
},
|
||||
),
|
||||
).toEqual({ width: 1200, height: 700, scale: 1 });
|
||||
});
|
||||
|
||||
it('scales the complete game document into the available viewport', () => {
|
||||
expect(
|
||||
resolveLocalGamePreviewFitLayout(
|
||||
{ width: 1200, height: 700 },
|
||||
{
|
||||
contentWidth: 1200,
|
||||
contentHeight: 1000,
|
||||
viewportWidth: 1200,
|
||||
viewportHeight: 700,
|
||||
},
|
||||
),
|
||||
).toEqual({ width: 1200, height: 1000, scale: 0.7 });
|
||||
});
|
||||
|
||||
it('rejects malformed or unrelated cross-frame messages', () => {
|
||||
expect(
|
||||
parseLocalGamePreviewContentSize({
|
||||
type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
|
||||
contentWidth: 1200,
|
||||
contentHeight: Number.NaN,
|
||||
viewportWidth: 1200,
|
||||
viewportHeight: 700,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseLocalGamePreviewContentSize({
|
||||
type: 'unrelated',
|
||||
contentWidth: 1200,
|
||||
contentHeight: 700,
|
||||
viewportWidth: 1200,
|
||||
viewportHeight: 700,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,10 @@
|
||||
- 原生读取必须在命令入口、权限 / 登记复核后、打开文件后、每个固定上限读取块之间、签名 / 图片结构校验前、漂移复核前和 base64 编码前检查取消。取消的排队任务不得打开文件,取消的在途任务不得生成 data URL 或 Blob URL;全局 permit 只能在对应原生任务结束后释放。重复 `requestId` 失败关闭,取消未知或已完成 scope 幂等成功;成功、失败和取消均必须清理活动 request / scope registry。为防止近期 request ID 重放和已取消 scope 复活,原生管理器继续保留有界 tombstone:seen request 最多 `8192` 项;非活动 cancelled scope 的保留预算为 `1024` 项,仍有请求的已取消 scope 必须临时钉住,最后一个请求结束后立即重新淘汰到预算内。明确取消只静默收口旧 scope,当前 scope 的真实 transient / permanent 失败语义不变。
|
||||
- 安全读取失败、图片解码失败或视频无可解码画面时,卡片展示稳定类型占位,不挂载破图,不降级为项目外 URL 或裸路径读取。滚动可见性不得自动重试已失败预取;读取漂移、文件替换和通用暂时失败标记为 transient,用户再次打开详情或点击播放时可以显式重试。超尺寸、损坏、类型不支持和不安全 SVG 等永久失败继续缓存且不得用“关闭后重试”误导用户。
|
||||
|
||||
### 3.4 数值微调
|
||||
### 3.4 运行视窗与数值微调
|
||||
|
||||
- 运行视窗必须占满中央工作区为游戏保留的可用区域。loopback 预览页通过客户端本地 preview server 注入的只读尺寸桥上报文档实际宽高;宿主只接受当前 iframe、当前 loopback origin 的固定版本消息,并将完整游戏文档等比缩放、居中放入视窗。
|
||||
- 窗口或中央区域尺寸变化后必须重新测量和适配;内容已经放得下时保持 `1:1`,不得无故放大。游戏文档宽高超过视窗时缩小整体画面,不显示 iframe 横向或纵向滚动条,也不得用单纯裁切替代完整展示。尺寸桥只观察布局并上报宽高,不读取项目正文、不修改 manifest、游戏文件或运行业务状态。
|
||||
|
||||
- 数值修改立即写入当前项目的编辑态配置。
|
||||
- 当前已拉起的体验预览和测试切片不热更新;必须重新拉起后才能消费新值。
|
||||
|
||||
@@ -14355,3 +14355,9 @@
|
||||
|
||||
- 素材生成确认、离开确认和旧服务身份确认沿用现有独立 modal,不在当前面板下方追加内容。modal 打开后焦点必须进入对话框,并同时隔离工具栏、画布视口、缩放/小地图、状态区和并存操作层,使背景从键盘焦点顺序与 accessibility tree 中退出。
|
||||
- `Tab` / `Shift+Tab` 必须在当前 modal 内双向循环;非异步 pending 状态允许 `Escape` 安全关闭。关闭后优先恢复到原触发器,自动弹出的 modal 则回退到可操作的工具栏入口,不能把焦点遗留在已卸载节点或被隔离背景中。
|
||||
|
||||
## 2026-08-23 AI 游戏运行视窗按预览文档实际尺寸自适应
|
||||
|
||||
- 背景:项目开发工作台的中央运行视窗尺寸小于部分生成游戏的页面布局高度时,滚动条来自 loopback iframe 内部;宿主只隐藏 overflow 会直接裁掉标题、Canvas 或控制区,不能满足完整试玩。
|
||||
- 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥,页面完成加载后只上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;容器 resize 后重新以原生视口测量。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。
|
||||
- 验证:前端纯函数覆盖无需缩放、纵向超高缩放和非法消息;Rust preview server 测试锁定 HTML bridge 注入、固定脚本端点与响应类型;再以桌面最小窗口和更高窗口人工确认完整画面、无纵向滚动条且指针 / 键盘交互仍可用。
|
||||
|
||||
@@ -526,7 +526,7 @@ game-project/
|
||||
- 中间主视窗提供 `resource-overview / asset-canvas / resource-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生。静态图片继续进入 refine 素材创作无限画布,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流;底层 create 合同仅保留兼容。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本,脚本只在页面完整加载后观察 `documentElement / body` 的布局宽高并以固定版本 `postMessage` 上报;宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放。容器 resize 后回到原生视口重新测量;放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
|
||||
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
|
||||
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
|
||||
- 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。
|
||||
|
||||
Reference in New Issue
Block a user