feat: AGC 聊天支持素材与运行画面结构化引用

- 新增通用素材引用编辑器与结构化引用协议

- 三个聊天入口接入 @ 候选和素材选择面板

- 资源卡新增 @引用 入口

- 运行画面新增区域点选与 runtime-region 引用

- Rust 按 manifest 二次校验并生成安全投影

- 补充前端与 Rust 定向测试
This commit is contained in:
2026-09-08 16:19:45 +08:00
parent 672d93015c
commit b7a659cdfe
17 changed files with 2480 additions and 60 deletions
@@ -16,6 +16,7 @@ mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_project_history;
mod direct_project_turn_history;
mod direct_codex_references;
mod direct_runtime;
mod direct_tool_bridge;
mod direct_tools_mcp;
@@ -42,6 +43,7 @@ pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_codex_references::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tools_mcp::*;
@@ -0,0 +1,326 @@
use super::*;
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160;
const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80;
const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240;
const DIRECT_CODEX_REFERENCE_HEADER: &str =
"[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]";
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub(crate) enum DirectCodexTurnReference {
#[serde(rename = "resource")]
Resource(DirectCodexResourceReference),
#[serde(rename = "runtime-region")]
RuntimeRegion(DirectCodexRuntimeRegionReference),
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexResourceReference {
pub(crate) resource_id: String,
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) source: Option<String>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexRuntimeRegionReference {
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) run_id: Option<String>,
#[serde(default)]
pub(crate) version_id: Option<String>,
#[serde(default)]
pub(crate) element_tag: Option<String>,
#[serde(default)]
pub(crate) element_role: Option<String>,
#[serde(default)]
pub(crate) text: Option<String>,
#[serde(default)]
pub(crate) width: Option<f64>,
#[serde(default)]
pub(crate) height: Option<f64>,
#[serde(default)]
pub(crate) resource_ids: Vec<String>,
}
fn sanitize_reference_text(value: &str, max_chars: usize) -> Option<String> {
let sanitized = value
.trim()
.chars()
.filter(|character| !character.is_control())
.take(max_chars)
.collect::<String>();
if sanitized.is_empty() {
None
} else {
Some(sanitized)
}
}
fn sanitize_reference_label(value: Option<&str>) -> Option<String> {
value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS))
}
fn sanitize_reference_source(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_element(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_dimension(value: Option<f64>) -> Option<u32> {
value
.filter(|value| value.is_finite() && *value > 0.0)
.map(|value| value.round().clamp(1.0, 100_000.0) as u32)
}
fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String {
let basename = asset
.local_path
.rsplit(['/', '\\'])
.next()
.unwrap_or(&asset.id);
let without_extension = basename
.rsplit_once('.')
.map(|(name, _)| name)
.unwrap_or(basename)
.trim();
if without_extension.is_empty() {
asset.id.clone()
} else {
without_extension
.chars()
.filter(|character| !character.is_control())
.take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)
.collect()
}
}
fn validate_resource_reference_id(value: &str) -> Result<String, String> {
let resource_id = value.trim();
if resource_id.is_empty()
|| resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS
|| resource_id.chars().any(char::is_control)
{
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
}
Ok(resource_id.to_string())
}
fn render_resource_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexResourceReference,
) -> Result<String, String> {
let resource_id = validate_resource_reference_id(&reference.resource_id)?;
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let local_path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| asset_display_label(asset));
let source = sanitize_reference_source(reference.source.as_deref())
.unwrap_or_else(|| "unknown".to_string());
Ok(format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
))
}
fn render_runtime_region_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexRuntimeRegionReference,
) -> Result<String, String> {
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| "运行画面区域".to_string());
let run_id = sanitize_reference_source(reference.run_id.as_deref());
let version_id = sanitize_reference_source(reference.version_id.as_deref());
let element_tag = sanitize_reference_element(reference.element_tag.as_deref());
let element_role = sanitize_reference_element(reference.element_role.as_deref());
let text = reference
.text
.as_deref()
.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS));
let width = sanitize_reference_dimension(reference.width);
let height = sanitize_reference_dimension(reference.height);
let mut related_resource_ids = Vec::new();
for resource_id in &reference.resource_ids {
let resource_id = validate_resource_reference_id(resource_id)?;
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
return Err("运行画面引用的素材已变化,请重新点选".to_string());
}
related_resource_ids.push(resource_id);
}
let mut parts = vec![format!("名称:{label}")];
if let Some(run_id) = run_id {
parts.push(format!("运行标识:{run_id}"));
}
if let Some(version_id) = version_id {
parts.push(format!("版本标识:{version_id}"));
}
if let Some(element_tag) = element_tag {
parts.push(format!("元素:{element_tag}"));
}
if let Some(element_role) = element_role {
parts.push(format!("角色:{element_role}"));
}
if let Some(text) = text {
parts.push(format!("文本摘要:{text}"));
}
if let (Some(width), Some(height)) = (width, height) {
parts.push(format!("尺寸:{width}x{height}"));
}
if !related_resource_ids.is_empty() {
parts.push(format!("关联素材 ID{}", related_resource_ids.join(",")));
}
Ok(format!("- 运行画面区域:{}", parts.join("")))
}
pub(crate) fn render_direct_codex_references_section(
root: &Path,
references: &[DirectCodexTurnReference],
) -> Result<Option<String>, String> {
if references.is_empty() {
return Ok(None);
}
if references.len() > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
let manifest = read_manifest_for_project(root)?;
let mut lines = Vec::with_capacity(references.len());
for reference in references {
lines.push(match reference {
DirectCodexTurnReference::Resource(reference) => {
render_resource_reference_line(&manifest, reference)?
}
DirectCodexTurnReference::RuntimeRegion(reference) => {
render_runtime_region_reference_line(&manifest, reference)?
}
});
}
Ok(Some(
std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string())
.chain(lines)
.collect::<Vec<_>>()
.join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_project() -> tempfile::TempDir {
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
std::fs::create_dir_all(root.join(".agent")).expect("create agent dir");
let mut manifest = new_game_creation_app_manifest("project-1", "测试项目");
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-hero".to_string(),
kind: "character".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/hero.png".to_string(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
image_sequence_frames: None,
image_sequence_duration_ms: None,
});
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
directory
}
#[test]
fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("素材 IDasset-hero"));
assert!(section.contains("名称:主角"));
assert!(section.contains("项目路径:assets/hero.png"));
assert!(!section.contains("C:\\secret.png"));
}
#[test]
fn deleted_resource_fails_closed() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#,
)
.expect("reference json");
let error = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect_err("missing resource");
assert_eq!(error, "引用的素材已不存在,请移除后重新选择");
}
#[test]
fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"<button onclick=secret>"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("运行画面区域:名称:开始按钮"));
assert!(section.contains("文本摘要:开始游戏"));
assert!(section.contains("尺寸:120x40"));
assert!(section.contains("关联素材 IDasset-hero"));
assert!(!section.contains("onclick"));
assert!(!section.contains("secret"));
}
}
@@ -4414,6 +4414,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
references: Option<Vec<DirectCodexTurnReference>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -4428,16 +4429,33 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let user_prompt = match render_direct_codex_user_prompt(
&prompt,
attachments.as_deref().unwrap_or_default(),
) {
let attachments = attachments.unwrap_or_default();
let references = references.unwrap_or_default();
let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) {
Ok(prompt) => prompt,
Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(),
Err(error) => {
audit.finish(false);
return Err(error);
}
};
if let Some(reference_section) = match render_direct_codex_references_section(root, &references)
{
Ok(section) => section,
Err(error) => {
audit.finish(false);
return Err(error);
}
} {
if !user_prompt.trim().is_empty() {
user_prompt.push_str("\n\n");
}
user_prompt.push_str(&reference_section);
}
if user_prompt.trim().is_empty() {
audit.finish(false);
return Err("聊天内容不能为空".to_string());
}
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
@@ -488,6 +488,113 @@ const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => {
if (!document.hidden) schedule();
}, 500);
};
const inspectMessageType = 'genarrative.local-preview-inspect.v1';
const inspectState = { enabled: false, overlay: null, current: null };
const inspectOverlay = () => {
if (inspectState.overlay) return inspectState.overlay;
const overlay = document.createElement('div');
overlay.dataset.genarrativePreviewInspect = 'true';
overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #f97316;background:rgba(249,115,22,0.14);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.85);';
(document.body || document.documentElement).appendChild(overlay);
inspectState.overlay = overlay;
return overlay;
};
const clearInspectOverlay = () => {
inspectState.overlay?.remove();
inspectState.overlay = null;
inspectState.current = null;
};
const inspectElementSummary = (element) => {
const rect = element.getBoundingClientRect();
const tag = element.tagName ? element.tagName.toLowerCase() : 'div';
const role = (element.getAttribute && element.getAttribute('role')) || '';
const text = (element.innerText || element.textContent || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
const resourceIds = [];
if (element.getAttribute) {
for (const name of ['data-resource-id', 'data-asset-id', 'data-genarrative-resource-id']) {
const value = element.getAttribute(name);
if (value && !resourceIds.includes(value)) resourceIds.push(value);
}
}
const media = element.querySelector ? element.querySelector('img,video,audio') : null;
const rawSource =
(element.getAttribute && element.getAttribute('src')) ||
(media && media.getAttribute && media.getAttribute('src')) ||
'';
let sourcePath = '';
if (rawSource) {
try {
sourcePath = new URL(rawSource, window.location.href).pathname;
} catch {
sourcePath = '';
}
}
return {
label: text || tag,
elementTag: tag,
elementRole: role.slice(0, 80),
text,
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height)),
resourceIds,
sourcePath,
};
};
const postInspect = (action, selection) => {
window.parent.postMessage({ type: inspectMessageType, action, selection }, '*');
};
const stopInspect = (cancelled) => {
if (!inspectState.enabled) return;
inspectState.enabled = false;
document.removeEventListener('mousemove', handleInspectMove, true);
document.removeEventListener('click', handleInspectClick, true);
document.removeEventListener('keydown', handleInspectKeyDown, true);
clearInspectOverlay();
postInspect(cancelled ? 'cancelled' : 'disabled');
};
const handleInspectMove = (event) => {
if (!inspectState.enabled) return;
const target = event.target;
if (!(target instanceof Element)) return;
inspectState.current = target;
const rect = target.getBoundingClientRect();
const overlay = inspectOverlay();
overlay.style.left = `${rect.left}px`;
overlay.style.top = `${rect.top}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
};
const handleInspectClick = (event) => {
if (!inspectState.enabled) return;
event.preventDefault();
event.stopPropagation();
const target = event.target;
if (target instanceof Element) postInspect('selected', inspectElementSummary(target));
stopInspect(false);
};
const handleInspectKeyDown = (event) => {
if (!inspectState.enabled || event.key !== 'Escape') return;
event.preventDefault();
stopInspect(true);
};
const startInspect = () => {
if (inspectState.enabled) return;
inspectState.enabled = true;
document.addEventListener('mousemove', handleInspectMove, true);
document.addEventListener('click', handleInspectClick, true);
document.addEventListener('keydown', handleInspectKeyDown, true);
postInspect('enabled');
};
window.addEventListener('message', (event) => {
if (event.source !== window.parent) return;
const data = event.data;
if (!data || data.type !== inspectMessageType) return;
if (data.action === 'enable') startInspect();
else if (data.action === 'disable') stopInspect(false);
});
if (ready) activate();
else window.addEventListener('load', activate, { once: true });
})();"#;
+64 -7
View File
@@ -239,6 +239,15 @@ import {
import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands';
import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView';
import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane';
import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput';
import type {
ChatComposerDraft,
ChatReference,
} from './features/project-workspace/resourceReferences';
import {
RESOURCE_REFERENCE_INSERT_EVENT,
type ResourceReferenceInsertEventDetail,
} from './features/project-workspace/resourceReferences';
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
import {
@@ -490,6 +499,7 @@ type ExecuteChatAgentReplyInput = {
creationType?: HomeCreationType | null;
attachments?: DirectCodexTurnAttachment[];
directPolicyChecked?: boolean;
references?: ChatReference[];
};
export function App({
@@ -591,6 +601,8 @@ export function App({
? readSupervisorChatDraft(initialProjectPath)
: '',
);
const [chatReferences, setChatReferences] = useState<ChatReference[]>([]);
const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
const [directCodexProgress, setDirectCodexProgress] = useState('');
const [directCodexStatus, setDirectCodexStatus] = useState<
@@ -850,7 +862,7 @@ export function App({
}, [hydratePlanGddState, localProject?.projectPath]);
const [projectSupervisorExpectedRunId, setProjectSupervisorExpectedRunId] =
useState<string | null>(null);
const chatInputRef = useRef<HTMLInputElement | null>(null);
const chatInputRef = useRef<HTMLDivElement | null>(null);
const supervisorChatMessagesRef = useRef<HTMLDivElement | null>(null);
const supervisorChatShouldFollowLatestRef = useRef(true);
const [assetStatus, setAssetStatus] = useState('未上传');
@@ -2928,6 +2940,7 @@ export function App({
if (supervisorChatOnly) {
setChatInput(readSupervisorChatDraft(openedProject.projectPath));
}
setChatReferences([]);
setManifest(openedProject.manifest);
setProjectFiles([]);
setProjectCheckpoints([]);
@@ -3167,9 +3180,34 @@ export function App({
function prepareChatCommandDraft(commandDraft: string) {
setChatInput(commandDraft);
setChatReferences([]);
window.setTimeout(() => chatInputRef.current?.focus(), 0);
}
function handleChatComposerChange(draft: ChatComposerDraft) {
setChatInput(draft.text);
setChatReferences(draft.references);
}
useEffect(() => {
const handleResourceReferenceInsert = (event: Event) => {
const detail = (event as CustomEvent<ResourceReferenceInsertEventDetail>)
.detail;
if (!detail?.reference) return;
chatComposerRef.current?.insertReferences([detail.reference]);
chatComposerRef.current?.focus();
};
window.addEventListener(
RESOURCE_REFERENCE_INSERT_EVENT,
handleResourceReferenceInsert,
);
return () =>
window.removeEventListener(
RESOURCE_REFERENCE_INSERT_EVENT,
handleResourceReferenceInsert,
);
}, []);
function prepareProjectAssetRegisterDraft(localPath: string) {
prepareChatCommandDraft(projectFileActionDrafts(localPath).assetCommand);
}
@@ -4175,15 +4213,17 @@ export function App({
async function handleChatSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const prompt = chatInput.trim();
const references = chatReferences;
if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) {
setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题');
return;
}
if (!prompt || chatAgentBusy) {
if ((!prompt && references.length === 0) || chatAgentBusy) {
return;
}
setChatInput('');
setChatReferences([]);
setMessages((current) => [
...current,
{
@@ -5271,7 +5311,7 @@ export function App({
return;
}
void executeChatAgentReply({ prompt });
void executeChatAgentReply({ prompt, references });
}
async function executeLlmConfigStatus() {
@@ -5418,6 +5458,7 @@ export function App({
creationType,
attachments,
directPolicyChecked = false,
references,
}: ExecuteChatAgentReplyInput) {
// Product default: send the conversation directly to Codex app-server.
// The legacy Supervisor/harness path remains below for rollback and tests.
@@ -5560,6 +5601,7 @@ export function App({
clientTurnId: string;
creationType?: HomeCreationType;
attachments?: DirectCodexTurnAttachment[];
references?: ChatReference[];
} = {
projectPath: directProjectPath,
prompt,
@@ -5571,6 +5613,9 @@ export function App({
if (attachments?.length) {
directTurnInput.attachments = attachments;
}
if (references?.length) {
directTurnInput.references = references;
}
const reply = await directInvoke<string>(
'chat_with_game_creator_direct_codex',
directTurnInput,
@@ -10819,6 +10864,7 @@ export function App({
) {
event.preventDefault();
const prompt = chatInput.trim();
const references = chatReferences;
if (
!directCodexProductRuntime &&
supervisorChatOnly &&
@@ -10834,7 +10880,7 @@ export function App({
setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题');
return;
}
if (!prompt || chatAgentBusy) {
if ((!prompt && references.length === 0) || chatAgentBusy) {
return;
}
if (directCodexProductRuntime && prompt === '/history') {
@@ -10843,6 +10889,7 @@ export function App({
return;
}
setChatInput('');
setChatReferences([]);
void loadProjectConversation(nextProjectPath, false, 'replace');
return;
}
@@ -10853,6 +10900,7 @@ export function App({
? createDirectCodexConversationTurnId()
: undefined;
setChatInput('');
setChatReferences([]);
setMessages((current) => [
...current,
{
@@ -10873,6 +10921,7 @@ export function App({
void executeChatAgentReply({
prompt,
clientTurnId: directConversationTurnId,
references,
});
}
@@ -10889,9 +10938,11 @@ export function App({
<SupervisorChatOnlyView
chatAgentBusy={chatAgentBusy}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef}
messagesRef={supervisorChatMessagesRef}
onCancelConfirmation={cancelUiCommandConfirmation}
onChatInputChange={setChatInput}
onChatInputChange={handleChatComposerChange}
onCloseRuntimeConfig={() => setRuntimeConfigOpen(false)}
onConfirmConfirmation={confirmUiCommand}
onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)}
@@ -10917,6 +10968,7 @@ export function App({
hiddenConversationCount={hiddenConversationCount}
needsUserInput={projectSupervisorNeedsUserInput}
visibleMessages={visibleMessages}
visibleMainProjectAssets={visibleMainProjectAssets}
workspaceStatus={workspaceStatus}
expectedRunId={projectSupervisorExpectedRunId}
/>
@@ -10927,6 +10979,8 @@ export function App({
return (
<ProjectSupervisorView
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef}
directCodex={directCodexProductRuntime}
directStatus={directCodexProductRuntime ? directCodexStatus : null}
directProcessDetail={
@@ -10940,7 +10994,7 @@ export function App({
}
onCancelConfirmation={cancelUiCommandConfirmation}
onCancelPendingCommand={handlePendingCommandCancel}
onChatInputChange={setChatInput}
onChatInputChange={handleChatComposerChange}
onConfirmConfirmation={confirmUiCommand}
onConfirmPendingCommand={() => void handlePendingCommandConfirm()}
onScroll={handleSupervisorChatScroll}
@@ -10957,6 +11011,7 @@ export function App({
: projectSupervisorTransientReply
}
visibleMessages={visibleMessages}
visibleMainProjectAssets={visibleMainProjectAssets}
visibleProfessionalAgentCards={visibleProfessionalAgentCards}
showProfessionalCollaboration={
!directCodexProductRuntime && orchestrationMode === 'professional-dag'
@@ -11005,6 +11060,8 @@ export function App({
cancelUiCommandConfirmation={cancelUiCommandConfirmation}
chatAgentBusy={chatAgentBusy}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef}
chatInputRef={chatInputRef}
confirmProjectCreateInNonEmptyFolder={
confirmProjectCreateInNonEmptyFolder
@@ -11078,7 +11135,7 @@ export function App({
queueRunLocalShortcut={queueRunLocalShortcut}
queueStaticSmokeShortcut={queueStaticSmokeShortcut}
scheduleReadyAgentTasks={scheduleReadyAgentTasks}
setChatInput={setChatInput}
onChatComposerChange={handleChatComposerChange}
showChatHelp={showChatHelp}
showEarlierConversationMessages={showEarlierConversationMessages}
visibleMainProjectAssets={visibleMainProjectAssets}
@@ -1,9 +1,29 @@
/* 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';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export const LOCAL_GAME_PREVIEW_SIZE_MESSAGE =
'genarrative.local-preview-size.v1';
export const LOCAL_GAME_PREVIEW_INSPECT_MESSAGE =
'genarrative.local-preview-inspect.v1';
export type LocalGamePreviewInspectSelection = {
label: string;
elementTag?: string;
elementRole?: string;
text?: string;
width?: number;
height?: number;
resourceIds: string[];
sourcePath?: string;
};
type LocalGamePreviewInspectMessage =
| {
action: 'selected';
selection: LocalGamePreviewInspectSelection;
}
| { action: 'enabled' | 'disabled' | 'cancelled' };
export type LocalGamePreviewContentSize = {
contentWidth: number;
@@ -54,6 +74,77 @@ function positiveFiniteDimension(value: unknown) {
: null;
}
function sanitizeInspectText(value: unknown, maxChars: number) {
if (typeof value !== 'string') return undefined;
const sanitized = value
.trim()
.replace(/\s+/gu, ' ')
.split('')
.filter((character) => character.charCodeAt(0) >= 32)
.join('')
.slice(0, maxChars);
return sanitized || undefined;
}
function sanitizeInspectIdentifier(value: unknown, maxChars: number) {
const sanitized = sanitizeInspectText(value, maxChars);
if (!sanitized) return undefined;
return /^[A-Za-z0-9._-]+$/u.test(sanitized) ? sanitized : undefined;
}
function sanitizeInspectSourcePath(value: unknown) {
const sanitized = sanitizeInspectText(value, 512);
if (!sanitized) return undefined;
try {
return new URL(sanitized, 'http://local.invalid').pathname.slice(0, 512);
} catch {
return undefined;
}
}
export function parseLocalGamePreviewInspectMessage(
value: unknown,
): LocalGamePreviewInspectMessage | null {
if (!value || typeof value !== 'object') return null;
const candidate = value as Record<string, unknown>;
if (candidate.type !== LOCAL_GAME_PREVIEW_INSPECT_MESSAGE) return null;
const action = candidate.action;
if (action === 'enabled' || action === 'disabled' || action === 'cancelled') {
return { action };
}
if (
action !== 'selected' ||
!candidate.selection ||
typeof candidate.selection !== 'object'
) {
return null;
}
const selection = candidate.selection as Record<string, unknown>;
const label =
sanitizeInspectText(selection.label, 120) ??
sanitizeInspectIdentifier(selection.elementTag, 80);
if (!label) return null;
const resourceIds = Array.isArray(selection.resourceIds)
? selection.resourceIds
.map((value) => sanitizeInspectIdentifier(value, 200))
.filter((value): value is string => Boolean(value))
.slice(0, 32)
: [];
return {
action: 'selected',
selection: {
label,
elementTag: sanitizeInspectIdentifier(selection.elementTag, 80),
elementRole: sanitizeInspectIdentifier(selection.elementRole, 80),
text: sanitizeInspectText(selection.text, 120),
width: positiveFiniteDimension(selection.width) ?? undefined,
height: positiveFiniteDimension(selection.height) ?? undefined,
resourceIds,
sourcePath: sanitizeInspectSourcePath(selection.sourcePath),
},
};
}
export function parseLocalGamePreviewContentSize(
value: unknown,
): LocalGamePreviewContentSize | null {
@@ -150,10 +241,16 @@ export function LocalGamePreviewFrame({
preview,
title,
className,
inspectMode = false,
onInspectSelection,
onInspectExit,
}: {
preview: LocalGamePreviewLike | null | undefined;
title: string;
className?: string;
inspectMode?: boolean;
onInspectSelection?: (selection: LocalGamePreviewInspectSelection) => void;
onInspectExit?: () => void;
}) {
const embeddedUrl = resolveEmbeddedPreviewUrl(preview);
const containerRef = useRef<HTMLDivElement>(null);
@@ -166,6 +263,34 @@ export function LocalGamePreviewFrame({
const [containerSize, setContainerSize] = useState({ width: 1, height: 1 });
const [contentSize, setContentSize] =
useState<LocalGamePreviewContentSize | null>(null);
const postInspectAction = useCallback(
(action: 'enable' | 'disable') => {
if (!embeddedUrl) return;
const iframeWindow = iframeRef.current?.contentWindow;
if (!iframeWindow) return;
iframeWindow.postMessage(
{ type: LOCAL_GAME_PREVIEW_INSPECT_MESSAGE, action },
new URL(embeddedUrl).origin,
);
},
[embeddedUrl],
);
useEffect(() => {
postInspectAction(inspectMode ? 'enable' : 'disable');
}, [inspectMode, postInspectAction]);
useEffect(() => {
if (!inspectMode) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
postInspectAction('disable');
onInspectExit?.();
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [inspectMode, onInspectExit, postInspectAction]);
useEffect(() => {
const container = containerRef.current;
@@ -215,7 +340,20 @@ export function LocalGamePreviewFrame({
return;
}
const next = parseLocalGamePreviewContentSize(event.data);
if (!next) return;
if (!next) {
const inspect = parseLocalGamePreviewInspectMessage(event.data);
if (inspect) {
if (inspect.action === 'selected') {
onInspectSelection?.(inspect.selection);
} else if (
inspect.action === 'cancelled' ||
inspect.action === 'disabled'
) {
onInspectExit?.();
}
}
return;
}
const current = contentSizeRef.current;
const nativeViewport = measuredContainerSizeRef.current;
const appliedViewport = resolveLocalGamePreviewFitLayout(
@@ -244,7 +382,7 @@ export function LocalGamePreviewFrame({
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [embeddedUrl]);
}, [embeddedUrl, onInspectExit, onInspectSelection]);
const fit = useMemo(
() => resolveLocalGamePreviewFitLayout(containerSize, contentSize),
@@ -268,6 +406,7 @@ export function LocalGamePreviewFrame({
}}
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"
allow="autoplay; fullscreen; gamepad"
onLoad={() => postInspectAction(inspectMode ? 'enable' : 'disable')}
/>
</div>
);
@@ -1,10 +1,9 @@
import { ArrowUp, Loader2 } from 'lucide-react';
import type {
ComponentProps,
Dispatch,
FormEventHandler,
Ref,
RefObject,
SetStateAction,
UIEventHandler,
} from 'react';
import { useEffect, useRef, useState } from 'react';
@@ -40,6 +39,11 @@ import {
import { isPlanningLaneRuntime } from './planningLane';
import { PlanningLaneRuntimeStrip } from './PlanningLaneRuntimeStrip';
import { resolvePendingCommandProjectPath } from './projectCommandPolicy';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
@@ -64,6 +68,8 @@ function directStatusTitle(status: string | null | undefined) {
type ProjectSupervisorViewProps = RuntimePanelProps & {
chatInput: string;
chatReferences: ChatReference[];
composerRef?: Ref<ResourceReferenceInputHandle>;
directCodex?: boolean;
directStatus?: GameCreatorDirectTurnUpdateStatus | null;
directProcessDetail?: string;
@@ -73,7 +79,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
needsUserInput: boolean;
onCancelConfirmation: () => void;
onCancelPendingCommand: () => void;
onChatInputChange: Dispatch<SetStateAction<string>>;
onChatInputChange: (draft: ChatComposerDraft) => void;
onConfirmConfirmation: () => void;
onConfirmPendingCommand: () => void;
onScroll: UIEventHandler<HTMLDivElement>;
@@ -97,10 +103,13 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
comment: string | null,
) => Promise<void>;
onMakeGameFromApprovedGdd?: () => Promise<void>;
visibleMainProjectAssets?: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
};
export function ProjectSupervisorView({
chatInput,
chatReferences,
composerRef,
directCodex = false,
directStatus = null,
directProcessDetail = '',
@@ -131,6 +140,7 @@ export function ProjectSupervisorView({
onPlanGddRefresh,
onPlanGddDecision,
onMakeGameFromApprovedGdd,
visibleMainProjectAssets = [],
...runtimePanelProps
}: ProjectSupervisorViewProps) {
const [expandedProcessKey, setExpandedProcessKey] = useState<string | null>(
@@ -350,29 +360,23 @@ export function ProjectSupervisorView({
void validateModel();
}}
>
<textarea
aria-label={directCodex ? '陶泥儿对话内容' : '项目需求'}
<ResourceReferenceInput
ref={composerRef}
ariaLabel={directCodex ? '陶泥儿对话内容' : '项目需求'}
assets={visibleMainProjectAssets}
projectPath={projectPath}
disabled={
runtimePanelProps.controlBusy || needsUserInput || modelValidating
}
rows={3}
value={chatInput}
references={chatReferences}
placeholder={
directCodex
? '告诉陶泥儿接下来要做什么'
: '告诉项目总控接下来要做什么'
? '告诉陶泥儿接下来要做什么,或输入 @ 选择资源'
: '告诉项目总控接下来要做什么,或输入 @ 选择资源'
}
onChange={(event) => onChatInputChange(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
onChange={onChatInputChange}
/>
{directCodex ? (
<ConversationModelSelect
@@ -1,9 +1,8 @@
import type {
ChangeEventHandler,
Dispatch,
FormEventHandler,
Ref,
RefObject,
SetStateAction,
UIEventHandler,
} from 'react';
import { Fragment } from 'react';
@@ -51,6 +50,11 @@ import {
pendingCommandTitle,
} from './pendingCommandPresentation';
import { resolvePendingCommandProjectPath } from './projectCommandPolicy';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
type ProjectWorkspaceChatPaneProps = {
agentRunStatus: string;
@@ -60,7 +64,9 @@ type ProjectWorkspaceChatPaneProps = {
cancelUiCommandConfirmation: () => void;
chatAgentBusy: boolean;
chatInput: string;
chatInputRef: RefObject<HTMLInputElement | null>;
chatReferences: ChatReference[];
composerRef?: Ref<ResourceReferenceInputHandle>;
chatInputRef: RefObject<HTMLDivElement | null>;
confirmProjectCreateInNonEmptyFolder: () => void;
confirmUiCommand: () => Promise<void>;
currentProjectTitle: string;
@@ -186,7 +192,7 @@ type ProjectWorkspaceChatPaneProps = {
queueRunLocalShortcut: () => void;
queueStaticSmokeShortcut: () => void;
scheduleReadyAgentTasks: (skipPolicyConfirm?: boolean) => Promise<void>;
setChatInput: Dispatch<SetStateAction<string>>;
onChatComposerChange: (draft: ChatComposerDraft) => void;
showChatHelp: () => void;
showEarlierConversationMessages: () => void;
visibleMainProjectAssets: GameCreationAppAssetManifestEntry[];
@@ -204,6 +210,8 @@ export function ProjectWorkspaceChatPane({
cancelUiCommandConfirmation,
chatAgentBusy,
chatInput,
chatReferences,
composerRef,
chatInputRef,
confirmProjectCreateInNonEmptyFolder,
confirmUiCommand,
@@ -269,7 +277,7 @@ export function ProjectWorkspaceChatPane({
queueRunLocalShortcut,
queueStaticSmokeShortcut,
scheduleReadyAgentTasks,
setChatInput,
onChatComposerChange,
showChatHelp,
showEarlierConversationMessages,
visibleMainProjectAssets,
@@ -933,13 +941,18 @@ export function ProjectWorkspaceChatPane({
onChange={handleAssetUpload}
/>
</label>
<input
ref={chatInputRef}
aria-label="创作想法"
<ResourceReferenceInput
ref={composerRef}
inputRef={chatInputRef}
ariaLabel="创作想法"
assets={visibleMainProjectAssets}
projectPath={projectPath}
disabled={chatAgentBusy || projectSupervisorNeedsUserInput}
multiline={false}
value={chatInput}
placeholder="例如:像素风横版动作小游戏"
onChange={(event) => setChatInput(event.currentTarget.value)}
references={chatReferences}
placeholder="例如:像素风横版动作小游戏,或输入 @ 选择资源"
onChange={onChatComposerChange}
/>
<button
type="submit"
@@ -0,0 +1,48 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getNodeByKey, type NodeKey } from 'lexical';
import { X } from 'lucide-react';
import type { ChatReference } from './resourceReferences';
export function ResourceReferenceChip({
reference,
nodeKey,
}: {
reference: ChatReference;
nodeKey: NodeKey;
}) {
const [editor] = useLexicalComposerContext();
return (
<span
className="resource-reference-chip"
data-resource-reference-id={
reference.type === 'resource' ? reference.resourceId : undefined
}
data-runtime-region-reference={
reference.type === 'runtime-region' ? 'true' : undefined
}
contentEditable={false}
title={
reference.type === 'resource'
? `${reference.label} · ${reference.kind}`
: `${reference.label} · 运行区域`
}
>
<span aria-hidden="true">@</span>
<span className="resource-reference-chip-label">{reference.label}</span>
<button
type="button"
aria-label={`移除引用 ${reference.label}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
node?.remove();
});
}}
>
<X size={11} aria-hidden="true" />
</button>
</span>
);
}
@@ -0,0 +1,91 @@
import {
$applyNodeReplacement,
DecoratorNode,
type EditorConfig,
type LexicalNode,
type NodeKey,
type SerializedLexicalNode,
type Spread,
} from 'lexical';
import type { ReactNode } from 'react';
import { ResourceReferenceChip } from './ResourceReferenceChip';
import type { ChatReference } from './resourceReferences';
export type SerializedResourceReferenceNode = Spread<
{
reference: ChatReference;
type: 'resource-reference';
version: 1;
},
SerializedLexicalNode
>;
export class ResourceReferenceNode extends DecoratorNode<ReactNode> {
__reference: ChatReference;
static getType() {
return 'resource-reference';
}
static clone(node: ResourceReferenceNode) {
return new ResourceReferenceNode(node.__reference, node.__key);
}
static importJSON(serializedNode: SerializedResourceReferenceNode) {
return $createResourceReferenceNode(serializedNode.reference);
}
constructor(reference: ChatReference, key?: NodeKey) {
super(key);
this.__reference = reference;
}
exportJSON(): SerializedResourceReferenceNode {
return {
...super.exportJSON(),
reference: this.__reference,
type: 'resource-reference',
version: 1,
};
}
createDOM(_config: EditorConfig) {
return document.createElement('span');
}
updateDOM() {
return false;
}
getTextContent() {
return `@${this.__reference.label}`;
}
isInline() {
return true;
}
isKeyboardSelectable() {
return true;
}
decorate() {
return (
<ResourceReferenceChip
reference={this.__reference}
nodeKey={this.__key}
/>
);
}
}
export function $createResourceReferenceNode(reference: ChatReference) {
return $applyNodeReplacement(new ResourceReferenceNode(reference));
}
export function $isResourceReferenceNode(
node: LexicalNode | null | undefined,
): node is ResourceReferenceNode {
return node instanceof ResourceReferenceNode;
}
@@ -1,10 +1,9 @@
import { Send, Settings } from 'lucide-react';
import type {
ComponentProps,
Dispatch,
FormEventHandler,
Ref,
RefObject,
SetStateAction,
UIEvent,
UIEventHandler,
} from 'react';
@@ -30,6 +29,11 @@ import {
pendingCommandDetail,
pendingCommandTitle,
} from './pendingCommandPresentation';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
type RuntimeControlProps = ComponentProps<
typeof ProjectSupervisorRuntimeControls
@@ -40,10 +44,12 @@ const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
type SupervisorChatOnlyViewProps = {
chatAgentBusy: boolean;
chatInput: string;
chatReferences: ChatReference[];
composerRef?: Ref<ResourceReferenceInputHandle>;
directCodex?: boolean;
messagesRef: RefObject<HTMLDivElement | null>;
onCancelConfirmation: () => void;
onChatInputChange: Dispatch<SetStateAction<string>>;
onChatInputChange: (draft: ChatComposerDraft) => void;
onCloseRuntimeConfig: () => void;
onConfirmConfirmation: () => void;
onOpenRuntimeConfig: () => void;
@@ -67,11 +73,14 @@ type SupervisorChatOnlyViewProps = {
visibleMessages: ChatMessage[];
workspaceStatus: string;
expectedRunId?: string | null;
visibleMainProjectAssets?: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
};
export function SupervisorChatOnlyView({
chatAgentBusy,
chatInput,
chatReferences,
composerRef,
directCodex = false,
messagesRef,
onCancelConfirmation,
@@ -98,6 +107,7 @@ export function SupervisorChatOnlyView({
needsUserInput,
visibleMessages,
workspaceStatus,
visibleMainProjectAssets = [],
}: SupervisorChatOnlyViewProps) {
const shouldFollowLatestRef = useRef(true);
const running = Boolean(
@@ -299,27 +309,21 @@ export function SupervisorChatOnlyView({
) : null}
</div>
<form className="supervisor-chat-only-composer" onSubmit={onSubmit}>
<textarea
aria-label={directCodex ? '陶泥儿对话内容' : '项目总控对话内容'}
<ResourceReferenceInput
ref={composerRef}
ariaLabel={directCodex ? '陶泥儿对话内容' : '项目总控对话内容'}
assets={visibleMainProjectAssets}
projectPath={projectPath}
disabled={chatAgentBusy || needsUserInput}
rows={3}
value={chatInput}
references={chatReferences}
placeholder={
directCodex
? '告诉陶泥儿接下来要做什么'
: '给项目总控 Agent 发消息'
? '告诉陶泥儿接下来要做什么,或输入 @ 选择资源'
: '给项目总控 Agent 发消息,或输入 @ 选择资源'
}
onChange={(event) => onChatInputChange(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
onChange={onChatInputChange}
/>
<button
type="submit"
@@ -0,0 +1,153 @@
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ResourceReferenceSource =
| 'asset-picker'
| 'resource-card'
| 'version-asset'
| 'runtime-picker';
export type ResourceReference = {
type: 'resource';
resourceId: string;
kind: string;
mediaType: string;
label: string;
source: ResourceReferenceSource;
};
export type RuntimeRegionReference = {
type: 'runtime-region';
label: string;
runId?: string;
versionId?: string;
elementTag?: string;
elementRole?: string;
text?: string;
width?: number;
height?: number;
resourceIds: string[];
source: 'runtime-picker';
};
export type ChatReference = ResourceReference | RuntimeRegionReference;
export type ChatComposerDraft = {
text: string;
references: ChatReference[];
};
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
export type ResourceReferenceInsertEventDetail = {
reference: ChatReference;
};
export function dispatchResourceReferenceInsert(reference: ChatReference) {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent<ResourceReferenceInsertEventDetail>(
RESOURCE_REFERENCE_INSERT_EVENT,
{ detail: { reference } },
),
);
}
export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
text: '',
references: [],
};
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
const fileName = asset.localPath.split(/[\\/]/u).pop() ?? asset.id;
return fileName.replace(/\.[^.]+$/u, '').trim() || asset.id;
}
export function resourceReferenceFromAsset(
asset: GameCreationAppAssetManifestEntry,
source: ResourceReferenceSource,
): ResourceReference {
return {
type: 'resource',
resourceId: asset.id,
kind: asset.kind,
mediaType: asset.mediaType,
label: resourceDisplayName(asset),
source,
};
}
export function sameResourceReference(
left: ResourceReference,
right: ResourceReference,
) {
return (
left.resourceId === right.resourceId &&
left.kind === right.kind &&
left.mediaType === right.mediaType &&
left.label === right.label &&
left.source === right.source
);
}
export function resourceReferenceMatchesQuery(
reference: ResourceReference,
query: string,
) {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return true;
return (
reference.label.toLowerCase().includes(normalizedQuery) ||
reference.resourceId.toLowerCase().includes(normalizedQuery) ||
reference.kind.toLowerCase().includes(normalizedQuery)
);
}
export function resourceReferenceFilterKind(reference: ResourceReference) {
const mediaType = reference.mediaType.toLowerCase();
const kind = reference.kind.toLowerCase();
if (mediaType.startsWith('image/')) return 'image';
if (mediaType.startsWith('video/')) return 'video';
if (mediaType.startsWith('audio/')) return 'audio';
if (mediaType === 'application/json' || kind === 'ui') return 'ui';
if (
mediaType.startsWith('text/') ||
mediaType === 'application/json' ||
kind === 'document' ||
kind === 'code'
) {
return 'document';
}
return 'other';
}
export const RESOURCE_REFERENCE_FILTERS = [
{ id: 'all', label: '全部' },
{ id: 'image', label: '图片' },
{ id: 'video', label: '视频' },
{ id: 'audio', label: '音频' },
{ id: 'ui', label: 'UI' },
{ id: 'document', label: '文档' },
{ id: 'other', label: '其它' },
] as const;
export type ResourceReferenceFilter =
(typeof RESOURCE_REFERENCE_FILTERS)[number]['id'];
function chatReferenceKey(reference: ChatReference) {
if (reference.type === 'resource') {
return `resource:${reference.resourceId}:${reference.source}`;
}
return `runtime-region:${reference.runId ?? ''}:${reference.label}:${
reference.elementTag ?? ''
}:${reference.text ?? ''}`;
}
export function dedupeChatReferences(references: ChatReference[]) {
const seen = new Set<string>();
return references.filter((reference) => {
const key = chatReferenceKey(reference);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
+450
View File
@@ -4415,6 +4415,341 @@ h2 {
background: #1f6feb;
}
.resource-reference-input {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 8px;
min-width: 0;
flex: 1;
padding: 8px 8px 8px 12px;
border: 1px solid #cfd6df;
border-radius: 8px;
background: #fff;
color: #111827;
font: inherit;
}
.resource-reference-input:focus-within {
border-color: #6b7280;
outline: 2px solid rgb(107 114 128 / 18%);
outline-offset: 1px;
}
.resource-reference-input[data-disabled='true'] {
cursor: not-allowed;
opacity: 0.55;
}
.resource-reference-input-editor {
min-width: 0;
min-height: 72px;
max-height: 180px;
overflow-y: auto;
outline: 0;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.resource-reference-input.is-single-line .resource-reference-input-editor {
min-height: 24px;
max-height: 24px;
overflow: hidden;
white-space: nowrap;
}
.resource-reference-input-placeholder {
position: absolute;
top: 9px;
left: 13px;
pointer-events: none;
color: #8a94a6;
}
.resource-reference-input-actions {
display: flex;
align-items: end;
gap: 6px;
}
.resource-reference-input-at {
display: grid;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #cfd6df;
border-radius: 8px;
background: #f8fafc;
color: #475569;
place-items: center;
cursor: pointer;
}
.resource-reference-input-at:hover:not(:disabled) {
border-color: #94a3b8;
color: #0f172a;
}
.resource-reference-input-at:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.resource-reference-chip {
display: inline-flex;
align-items: center;
gap: 2px;
margin: 0 2px;
padding: 1px 4px 1px 6px;
border: 1px solid #bfdbfe;
border-radius: 7px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 650;
line-height: 1.5;
vertical-align: baseline;
white-space: nowrap;
}
.resource-reference-chip-label {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.resource-reference-chip button {
display: grid;
width: 15px;
height: 15px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: inherit;
place-items: center;
cursor: pointer;
}
.resource-reference-chip button:hover {
background: rgb(29 78 216 / 12%);
}
.resource-reference-menu {
display: grid;
gap: 2px;
min-width: 240px;
max-height: 260px;
overflow: auto;
padding: 6px;
border: 1px solid var(--platform-subpanel-border, #dbe3ef);
border-radius: 12px;
background: var(--platform-panel-fill, #fff);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
}
.resource-reference-menu button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 8px 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.resource-reference-menu button:hover,
.resource-reference-menu button.is-active {
background: var(--platform-subpanel-fill, #f1f5f9);
}
.resource-reference-menu small {
color: var(--platform-muted-text, #64748b);
}
.resource-reference-picker {
position: absolute;
z-index: 80;
right: 0;
bottom: calc(100% + 8px);
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
width: min(440px, 88vw);
max-height: min(480px, 70vh);
overflow: hidden;
border: 1px solid var(--platform-subpanel-border, #dbe3ef);
border-radius: 14px;
background: var(--platform-panel-fill, #fff);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.22);
}
.resource-reference-picker > header,
.resource-reference-picker > footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
}
.resource-reference-picker > header {
border-bottom: 1px solid var(--platform-subpanel-border, #e2e8f0);
}
.resource-reference-picker > header button {
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
}
.resource-reference-picker-search {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
margin: 10px 12px 8px;
padding: 0 10px;
border: 1px solid var(--platform-surface-border, #dbe3ef);
border-radius: 9px;
background: var(--platform-input-fill, #fff);
}
.resource-reference-picker-search input {
height: 34px;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: inherit;
font: inherit;
}
.resource-reference-picker-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 12px 8px;
}
.resource-reference-picker-filters button {
min-height: 26px;
padding: 0 10px;
border: 1px solid var(--platform-surface-border, #dbe3ef);
border-radius: 999px;
background: transparent;
color: inherit;
font-size: 12px;
cursor: pointer;
}
.resource-reference-picker-filters button[aria-pressed='true'] {
border-color: var(--platform-button-primary-border, #2563eb);
background: var(--platform-button-primary-fill, #2563eb);
color: var(--platform-button-primary-text, #fff);
}
.resource-reference-picker-list {
display: grid;
gap: 4px;
min-height: 0;
overflow: auto;
padding: 0 8px 8px;
}
.resource-reference-picker-list > button {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.resource-reference-picker-list > button:hover,
.resource-reference-picker-list > button.is-selected {
border-color: var(--platform-nav-active-border, #bfdbfe);
background: var(--platform-subpanel-fill, #f1f5f9);
}
.resource-reference-picker-list > button > img,
.resource-reference-picker-list > button > svg {
display: grid;
width: 44px;
height: 44px;
border-radius: 8px;
background: var(--platform-subpanel-fill, #f1f5f9);
object-fit: cover;
place-items: center;
padding: 12px;
color: var(--platform-muted-text, #64748b);
}
.resource-reference-picker-list > button > img {
padding: 0;
}
.resource-reference-picker-list > button > span {
display: grid;
gap: 2px;
min-width: 0;
}
.resource-reference-picker-list > button strong,
.resource-reference-picker-list > button small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-reference-picker-list > button small,
.resource-reference-picker-empty,
.resource-reference-picker > footer span {
color: var(--platform-muted-text, #64748b);
font-size: 12px;
}
.resource-reference-picker-empty {
margin: 12px;
text-align: center;
}
.resource-reference-picker > footer {
border-top: 1px solid var(--platform-subpanel-border, #e2e8f0);
}
.resource-reference-picker > footer button {
min-height: 32px;
padding: 0 14px;
border: 1px solid var(--platform-button-primary-border, #2563eb);
border-radius: 8px;
background: var(--platform-button-primary-fill, #2563eb);
color: var(--platform-button-primary-text, #fff);
cursor: pointer;
}
.resource-reference-picker > footer button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.composer {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -5220,6 +5555,12 @@ iframe.preview-frame {
opacity: 0.6;
}
.game-workbench-view-actions .game-workbench-inspect-button.is-active {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.game-resource-reorder-status {
min-width: 0;
color: #9a725f;
@@ -6020,6 +6361,36 @@ iframe.preview-frame {
outline-offset: 1px;
}
.game-resource-card-reference {
position: absolute;
right: 49px;
bottom: 9px;
z-index: 2;
display: grid;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid rgb(255 255 255 / 70%);
border-radius: 50%;
background: rgb(75 48 38 / 82%);
color: #fff;
box-shadow: 0 5px 14px rgb(52 30 22 / 24%);
cursor: pointer;
place-items: center;
}
.game-resource-card:not(:has(.game-resource-card-media-control))
.game-resource-card-reference {
right: 9px;
}
.game-resource-card-reference:hover,
.game-resource-card-reference:focus-visible {
background: #c96f44;
outline: 2px solid rgb(255 255 255 / 92%);
outline-offset: 1px;
}
.game-resource-card-placeholder,
.game-resource-card-version-visual,
.game-resource-card-audio-visual {
@@ -8640,4 +9011,83 @@ iframe.preview-frame {
color: #6b7280;
background: #fff;
font-size: 12px;
.project-supervisor-composer .resource-reference-input {
min-height: 72px;
}
.supervisor-chat-only-composer .resource-reference-input {
min-height: 74px;
}
.supervisor-chat-only-composer .resource-reference-input-editor {
min-height: 54px;
max-height: 54px;
}
.composer .resource-reference-input {
align-items: center;
min-height: 42px;
padding: 4px 6px 4px 12px;
}
.composer .resource-reference-input-editor {
min-height: 24px;
max-height: 24px;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input {
display: grid;
width: 100%;
min-height: 88px;
padding: 12px 46px 10px 15px;
border-color: var(--platform-surface-border);
border-radius: 14px;
background: var(--platform-input-fill);
color: var(--platform-text-strong);
pointer-events: auto;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input:focus-within {
border-color: var(--platform-surface-hover-border);
outline: none;
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-editor {
min-height: 64px;
max-height: 160px;
padding: 0;
font-size: 13px;
line-height: 1.6;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-actions {
position: absolute;
right: 10px;
bottom: 10px;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-at {
width: 30px;
height: 30px;
border: 0;
border-radius: 9px;
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
@@ -4,7 +4,9 @@ import {
resolveViewportFromWheel,
} from '@genarrative/image-canvas-core';
import {
AtSign,
Code2,
Crosshair,
FileText,
FolderTree,
Gamepad2,
@@ -61,8 +63,10 @@ import {
} from '../../features/asset-canvas/tauriImageCanvasHostAdapter';
import {
LocalGamePreviewFrame,
type LocalGamePreviewInspectSelection,
resolveEmbeddedPreviewUrl,
} from '../../features/project-workspace/LocalGamePreviewFrame';
import { dispatchResourceReferenceInsert } from '../../features/project-workspace/resourceReferences';
import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge';
import {
currentPlatformSessionGeneration,
@@ -837,6 +841,28 @@ const ResourceCard = memo(function ResourceCard({
)}
</button>
) : null}
{resource.manifestAssetId ? (
<button
type="button"
className="game-resource-card-reference"
aria-label={`引用资源 ${resource.label}`}
title="@引用"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
dispatchResourceReferenceInsert({
type: 'resource',
resourceId: resource.manifestAssetId!,
kind: resource.subtype || resource.category,
mediaType: resource.mediaType,
label: resource.label,
source: 'resource-card',
});
}}
>
<AtSign size={16} aria-hidden="true" />
</button>
) : null}
</div>
);
});
@@ -858,6 +884,7 @@ export default function ProjectDevelopmentView({
}: ProjectDevelopmentViewProps) {
const professionalDagVisible = orchestrationMode === 'professional-dag';
const [mode, setMode] = useState<WorkbenchMode>('resources');
const [runtimeInspectMode, setRuntimeInspectMode] = useState(false);
const [sortMode, setSortMode] = useState<ResourceSortMode>('dependency');
const [searchText, setSearchText] = useState('');
const [resourceCanvasViewports, setResourceCanvasViewports] =
@@ -1083,6 +1110,41 @@ export default function ProjectDevelopmentView({
manifest.tasks.some(
(task) => task.id === 'code-prototype' && task.status === 'completed',
);
useEffect(() => {
if (mode !== 'run' || !embeddedPreviewUrl) {
setRuntimeInspectMode(false);
}
}, [embeddedPreviewUrl, mode]);
const handleRuntimeInspectSelection = useCallback(
(selection: LocalGamePreviewInspectSelection) => {
const sourceName = selection.sourcePath?.split('/').pop()?.toLowerCase();
const matchedResourceIds = sourceName
? manifest.assets
.filter(
(asset) =>
asset.localPath.split(/[\\/]/u).pop()?.toLowerCase() ===
sourceName,
)
.map((asset) => asset.id)
: [];
dispatchResourceReferenceInsert({
type: 'runtime-region',
label: selection.label,
runId: preview?.port ? `preview-${preview.port}` : undefined,
elementTag: selection.elementTag,
elementRole: selection.elementRole,
text: selection.text,
width: selection.width,
height: selection.height,
resourceIds: [
...new Set([...selection.resourceIds, ...matchedResourceIds]),
],
source: 'runtime-picker',
});
setRuntimeInspectMode(false);
},
[manifest.assets, preview?.port],
);
const projectedResources = useMemo(
() => projectResourcesFromReadModels(manifest, attachments, agentResults),
[agentResults, attachments, manifest],
@@ -4280,6 +4342,24 @@ export default function ProjectDevelopmentView({
<Play size={15} aria-hidden="true" />
</button>
{mode === 'run' && embeddedPreviewUrl ? (
<button
type="button"
className={`game-workbench-inspect-button${
runtimeInspectMode ? ' is-active' : ''
}`}
aria-pressed={runtimeInspectMode}
onClick={() => setRuntimeInspectMode((current) => !current)}
title={
runtimeInspectMode
? '退出素材点选'
: '在运行画面中选择要引用的区域'
}
>
<Crosshair size={15} aria-hidden="true" />
{runtimeInspectMode ? '退出点选' : '点选素材'}
</button>
) : null}
{mode === 'resources' &&
!assetCanvasRoute &&
!resourceEditorRoute &&
@@ -5090,6 +5170,9 @@ export default function ProjectDevelopmentView({
<LocalGamePreviewFrame
title={`${projectName} 游戏运行画面`}
preview={preview}
inspectMode={runtimeInspectMode}
onInspectSelection={handleRuntimeInspectSelection}
onInspectExit={() => setRuntimeInspectMode(false)}
/>
) : (
<div className="game-run-preview-empty">
@@ -0,0 +1,184 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
parseLocalGamePreviewInspectMessage,
} from '../src/features/project-workspace/LocalGamePreviewFrame';
import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
type ChatReference,
dispatchResourceReferenceInsert,
RESOURCE_REFERENCE_INSERT_EVENT,
resourceReferenceFilterKind,
resourceReferenceFromAsset,
resourceReferenceMatchesQuery,
} from '../src/features/project-workspace/resourceReferences';
function asset(
id: string,
kind: string,
mediaType: string,
localPath: string,
): GameCreationAppAssetManifestEntry {
return {
id,
kind,
mediaType,
localPath,
source: { kind: 'uploaded' },
};
}
const assets = [
asset('hero', 'character', 'image/png', 'assets/hero.png'),
asset('enemy', 'character', 'image/png', 'assets/enemy.png'),
asset('theme', 'background-music', 'audio/mpeg', 'assets/theme.mp3'),
];
afterEach(cleanup);
describe('ResourceReferenceInput', () => {
test('opens the asset picker, supports multi-select, and inserts stable references', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
expect(screen.getByRole('dialog', { name: '选择素材' })).not.toBeNull();
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('option', { name: /enemy/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
const draft = onChange.mock.calls.at(-1)?.[0];
expect(draft?.text).toBe('@hero @enemy');
expect(draft?.references.map((reference) => reference.resourceId)).toEqual([
'hero',
'enemy',
]);
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).not.toBeNull();
});
test('filters candidate references by name, id, kind, and media category', () => {
const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const theme = resourceReferenceFromAsset(assets[2]!, 'asset-picker');
expect(resourceReferenceMatchesQuery(hero, 'her')).toBe(true);
expect(resourceReferenceMatchesQuery(hero, 'character')).toBe(true);
expect(resourceReferenceMatchesQuery(hero, 'missing')).toBe(false);
expect(resourceReferenceFilterKind(hero)).toBe('image');
expect(resourceReferenceFilterKind(theme)).toBe('audio');
});
test('resource-card insertion uses the shared structured reference event', () => {
const reference = resourceReferenceFromAsset(assets[0]!, 'resource-card');
const listener = vi.fn();
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
dispatchResourceReferenceInsert(reference);
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
expect(listener).toHaveBeenCalledTimes(1);
expect(
(listener.mock.calls[0]?.[0] as CustomEvent).detail.reference,
).toEqual(reference);
});
test('runtime inspect messages only expose the bounded safe selection shape', () => {
expect(
parseLocalGamePreviewInspectMessage({
type: LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
action: 'selected',
selection: {
label: '开始游戏',
elementTag: 'button',
elementRole: 'button',
text: '开始游戏',
width: 120.4,
height: 40.2,
resourceIds: ['hero', 'bad id', '../secret'],
sourcePath: '/assets/hero.png?token=secret',
html: '<button>secret</button>',
},
}),
).toEqual({
action: 'selected',
selection: {
label: '开始游戏',
elementTag: 'button',
elementRole: 'button',
text: '开始游戏',
width: 120.4,
height: 40.2,
resourceIds: ['hero'],
sourcePath: '/assets/hero.png',
},
});
expect(
parseLocalGamePreviewInspectMessage({
type: 'unknown',
action: 'selected',
}),
).toBeNull();
});
test('runtime-region references travel through the same structured event', () => {
const reference: ChatReference = {
type: 'runtime-region',
label: '开始按钮',
runId: 'preview-3101',
elementTag: 'button',
text: '开始游戏',
resourceIds: ['hero'],
source: 'runtime-picker',
};
const listener = vi.fn();
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
dispatchResourceReferenceInsert(reference);
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
expect(
(listener.mock.calls[0]?.[0] as CustomEvent).detail.reference,
).toEqual(reference);
});
test('removing a chip keeps the remaining text editable', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await screen.findByRole('button', { name: '移除引用 hero' });
await user.click(screen.getByRole('button', { name: '移除引用 hero' }));
await waitFor(() => {
expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0);
});
});
});
@@ -0,0 +1,27 @@
# AGC 聊天素材引用
更新时间:2026-09-08
AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。
素材选择面板支持缩略图、名称或资源 ID 搜索、类型筛选和多选。确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析。
提交时前端同时发送用户文本和 `references` 数组。Rust 在发起 Agent 回合前读取当前项目 manifest,逐项复核资源是否存在、路径是否安全,并以 manifest 中的 `id / kind / mediaType / localPath` 作为权威投影;客户端传入的路径、名称和类型不会被直接信任。已删除或不存在的资源会阻止发送并提示用户移除后重新选择。
当前已完成:
- 三个聊天入口共用 `ResourceReferenceInput`
- 输入 `@` 触发候选,支持键盘选择和 Esc 关闭;
- `@` 按钮打开素材选择面板;
- 支持搜索、类型筛选和多选;
- 素材芯片可插入、编辑和删除;
- 资源画布素材卡提供 `@引用`
- 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用;
- 提交请求携带结构化 `references`
- Rust 按 manifest 二次校验并生成安全投影;
- 普通无引用消息保持原有行为。
当前未完成:
- 跨会话恢复引用芯片的精确光标位置;
- 资源改名后的引用显示名自动刷新。