feat: AGC 聊天支持素材与运行画面结构化引用
- 新增通用素材引用编辑器与结构化引用协议 - 三个聊天入口接入 @ 候选和素材选择面板 - 资源卡新增 @引用 入口 - 运行画面新增区域点选与 runtime-region 引用 - Rust 按 manifest 二次校验并生成安全投影 - 补充前端与 Rust 定向测试
This commit is contained in:
@@ -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("素材 ID:asset-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("关联素材 ID:asset-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 });
|
||||
})();"#;
|
||||
|
||||
@@ -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}
|
||||
|
||||
+142
-3
@@ -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>
|
||||
);
|
||||
|
||||
+22
-18
@@ -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
|
||||
|
||||
+23
-10
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
+22
-18
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 二次校验并生成安全投影;
|
||||
- 普通无引用消息保持原有行为。
|
||||
|
||||
当前未完成:
|
||||
|
||||
- 跨会话恢复引用芯片的精确光标位置;
|
||||
- 资源改名后的引用显示名自动刷新。
|
||||
Reference in New Issue
Block a user