合并master最新变更
同步主分支的错误反馈重试与工作台入口调整 保留回合流去重、快照落盘收尾与对话输入文案
This commit is contained in:
+1
@@ -13,6 +13,7 @@ Use `agc_browser_playtest` from the `agc_tools` MCP server. Do not replace it wi
|
||||
2. Inspect both desktop and mobile results, including page readiness, visible text, screenshots, console errors, exceptions, failed requests, Canvas probes, blocked actions, and interaction evidence.
|
||||
3. Compare screenshots with the user's request. Check that the active game fills its intended area, HUD elements do not cover gameplay, controls are visible, and requested platform art appears in the core experience.
|
||||
4. If evidence exposes a defect, edit the actual game files and call the tool again when that is useful. The client enforces its own execution and resource bounds; do not invent a fixed repair loop in the response.
|
||||
Feed the structured diagnostics, console errors, failed requests, and exception text back to the same LLM repair turn before reporting the playtest as failed. Treat the evidence as debugging input and rerun the affected stage after a real code or project change.
|
||||
5. Treat browser infrastructure failure, an unloaded page, an unhandled exception, or missing evidence as a failed validation. Do not claim success from a partial result.
|
||||
6. Use game-specific reasoning for quality. Do not require a fixed board, fixed text, fixed number of slices, or a legacy harness scenario; the tool result is evidence for Codex to interpret.
|
||||
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ For a small edit to an existing game where the brief and suitable assets are unc
|
||||
|
||||
When a stage tool, command, or verification fails, retry at most three times before treating that stage as failed. Keep the retries serial and scoped to the same stage and the same input: a retry must not open a parallel path, skip ahead to a later stage, or substitute a placeholder for the missing output.
|
||||
|
||||
Every repairable failure must be fed back to the current LLM as the next debugging context before the stage is considered failed. Preserve the redacted tool or command error, the stage, the attempted input, and the evidence already collected; ask the LLM to inspect the current project, make the smallest real repair, and rerun the failed stage. A client-side `isError` tool result or a failed verification is feedback for the LLM, not by itself a terminal user-facing result. Do not silently swallow the error, replace it with a placeholder, or stop after the first failed attempt. Authentication, permission, billing, project identity, corrupted history, transport loss, cancellation, and uncertain paid-operation state remain terminal safety boundaries.
|
||||
|
||||
Only after the third attempt also fails, stop and tell the user the failure reason — which stage failed, which tool or command reported the error, what the error says, and what is still missing. A stage whose three attempts never succeeded is not complete, and its missing output cannot be reported as delivered.
|
||||
|
||||
Read the referenced specialist Skills for their detailed contracts: `agc-project-structure`, `taonier-art-assets`, `agc-web-game-development`, `agc-client-projection`, and `agc-browser-playtest`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.15",
|
||||
"version": "2026-08-26.16",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -22,7 +22,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/workflow-contract.md"
|
||||
],
|
||||
"sha256": "d9d8e7e0a6bc512e0b463e0e4bd77edee1cc57f4a6965c9553e0920e38985d5c"
|
||||
"sha256": "f25e5bd27e8fc82c61b08dc66366b5b253ee8d16d7fa72dbf2c94d2462f4e7fc"
|
||||
},
|
||||
{
|
||||
"name": "agc-project-structure",
|
||||
@@ -98,7 +98,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/browser-evidence-contract.md"
|
||||
],
|
||||
"sha256": "4437cd8a927a1c79a5faf4bcd40e9946676c08a3b460ab171298cabf899f49ad"
|
||||
"sha256": "92ecce42d6589e034d32b75bcd155c1fee34a8c7b843eea5780c0577300ed521"
|
||||
},
|
||||
{
|
||||
"name": "agc-client-projection",
|
||||
|
||||
+98
-2
@@ -7,17 +7,89 @@ use super::direct_project_history_injection_oversize_error;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
const DIRECT_PROJECT_HISTORY_IMAGE_TOTAL_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT: &str =
|
||||
"[历史图片预览已省略:本次恢复图片预算已用尽]";
|
||||
|
||||
fn omit_image_block(object: &mut serde_json::Map<String, Value>, text_type: &str) {
|
||||
object.clear();
|
||||
object.insert("type".to_string(), Value::String(text_type.to_string()));
|
||||
object.insert(
|
||||
"text".to_string(),
|
||||
Value::String(DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
fn compact_history_images(value: &mut Value, remaining_bytes: &mut usize) {
|
||||
match value {
|
||||
Value::Array(values) => values
|
||||
.iter_mut()
|
||||
.for_each(|value| compact_history_images(value, remaining_bytes)),
|
||||
Value::Object(object) => {
|
||||
let is_image_block = object.get("type").and_then(Value::as_str) == Some("image");
|
||||
if is_image_block {
|
||||
if let Some(data) = object.get("data").and_then(Value::as_str) {
|
||||
if let Some((preview, mime_type)) = crate::agent::compact_mcp_image_data(data) {
|
||||
if preview.len() > *remaining_bytes {
|
||||
omit_image_block(object, "text");
|
||||
} else {
|
||||
*remaining_bytes -= preview.len();
|
||||
object.insert("data".to_string(), Value::String(preview));
|
||||
object.insert(
|
||||
"mimeType".to_string(),
|
||||
Value::String(mime_type.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if object.get("type").and_then(Value::as_str) == Some("input_image") {
|
||||
if let Some(url) = object
|
||||
.get("image_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
{
|
||||
if let Some((header, data)) = url.split_once(",") {
|
||||
if header.ends_with(";base64") {
|
||||
if let Some((preview, mime_type)) =
|
||||
crate::agent::compact_mcp_image_data(data)
|
||||
{
|
||||
if preview.len() > *remaining_bytes {
|
||||
omit_image_block(object, "input_text");
|
||||
} else {
|
||||
*remaining_bytes -= preview.len();
|
||||
object.insert(
|
||||
"image_url".to_string(),
|
||||
Value::String(format!("data:{mime_type};base64,{preview}")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
object
|
||||
.values_mut()
|
||||
.for_each(|value| compact_history_images(value, remaining_bytes));
|
||||
}
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_direct_project_history_injection_params(
|
||||
history_root: &Path,
|
||||
thread_id: &str,
|
||||
) -> Result<Value, platform_llm::LlmError> {
|
||||
let canonical_items = read_direct_project_history_items_at(history_root)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
let mut remaining_image_bytes = DIRECT_PROJECT_HISTORY_IMAGE_TOTAL_MAX_BYTES;
|
||||
let items = canonical_items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
direct_codex_user_item_to_response_item(history_root, item)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)
|
||||
let mut projected = direct_codex_user_item_to_response_item(history_root, item)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
compact_history_images(&mut projected, &mut remaining_image_bytes);
|
||||
Ok(projected)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let params = serde_json::json!({"threadId": thread_id, "items": items});
|
||||
@@ -30,3 +102,27 @@ pub(super) fn build_direct_project_history_injection_params(
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{compact_history_images, DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn history_image_budget_omits_only_wire_preview_when_exhausted() {
|
||||
let mut item = json!({
|
||||
"type": "function_call_output",
|
||||
"output": {"content": [{
|
||||
"type": "image",
|
||||
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"mimeType": "image/png"
|
||||
}]}
|
||||
});
|
||||
let mut remaining = 1;
|
||||
compact_history_images(&mut item, &mut remaining);
|
||||
let block = &item["output"]["content"][0];
|
||||
assert_eq!(block["type"], "text");
|
||||
assert_eq!(block["text"], DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT);
|
||||
assert_eq!(remaining, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2061,6 +2061,84 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool {
|
||||
.any(|marker| error.contains(marker))
|
||||
}
|
||||
|
||||
/// DirectProject 的工具 / 构建 / 试玩失败应作为下一轮 LLM 的调试上下文继续处理,
|
||||
/// 而不是在 app-server 把本轮标成 failed 后立即把错误交给用户。基础设施、身份和
|
||||
/// 历史一致性错误没有安全的自动修复路径,必须保持终止语义。
|
||||
const DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS: usize = 3;
|
||||
|
||||
fn direct_codex_error_should_feedback(error: &str) -> bool {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
let terminal_markers = [
|
||||
"authentication-required",
|
||||
"401",
|
||||
"403",
|
||||
"泥点余额不足",
|
||||
"insufficient_mud_points",
|
||||
"身份不唯一",
|
||||
"身份不匹配",
|
||||
"合同发生变化",
|
||||
"历史记录类型无效",
|
||||
"历史记录缺少 payload",
|
||||
"历史注入载荷超过单行上限",
|
||||
"工具参数",
|
||||
"transport closed",
|
||||
"连接已关闭",
|
||||
"连接上游失败",
|
||||
"硬上限",
|
||||
"超时",
|
||||
"取消",
|
||||
"凭据",
|
||||
"credential",
|
||||
"context-window-exceeded",
|
||||
"request-too-large",
|
||||
"session-budget-exceeded",
|
||||
"usage-limit-exceeded",
|
||||
"stream-required",
|
||||
"cyber-policy",
|
||||
"sandbox-error",
|
||||
"thread-rollback-failed",
|
||||
"bad-request",
|
||||
];
|
||||
if terminal_markers.iter().any(|marker| {
|
||||
if marker.chars().any(|character| character.is_uppercase()) {
|
||||
error.contains(marker)
|
||||
} else {
|
||||
normalized.contains(marker)
|
||||
}
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
let repairable_markers = [
|
||||
"工具",
|
||||
"tool",
|
||||
"构建",
|
||||
"build",
|
||||
"编译",
|
||||
"验证",
|
||||
"verify",
|
||||
"试玩",
|
||||
"playtest",
|
||||
"console",
|
||||
"exception",
|
||||
"未通过",
|
||||
"失败",
|
||||
"error",
|
||||
];
|
||||
repairable_markers.iter().any(|marker| {
|
||||
if marker.chars().any(|character| character.is_uppercase()) {
|
||||
error.contains(marker)
|
||||
} else {
|
||||
normalized.contains(marker)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String {
|
||||
format!(
|
||||
"上一轮 AGC 工具、构建或试玩执行失败。不要直接结束本轮,请把下面的错误当作新的调试信息:读取当前项目和相关输出,定位原因,修改实际项目文件后重新执行必要的失败步骤;只有确认属于鉴权、余额、项目身份、历史损坏、传输断开或操作状态不确定时才停止。不要伪造成功,也不要只复述错误。\n\n错误信息(客户端已脱敏):\n{error}\n\n这是第 {attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS} 次错误反馈。",
|
||||
)
|
||||
}
|
||||
|
||||
/// DirectProject 历史文件里与“行形状”有关的失败:同一份文件每次读都会得到同一结果,
|
||||
/// 重试不会改变结论。IO 类失败(打开/读取目录)不在其中,那些仍按可重试处理。
|
||||
const DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS: &[&str] = &[
|
||||
@@ -4273,7 +4351,7 @@ fn build_direct_codex_system_prompt_with_search(
|
||||
DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(),
|
||||
DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE.to_string(),
|
||||
DIRECT_COCOS_CAPABILITY_GUIDE.to_string(),
|
||||
"工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(),
|
||||
"工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。工具返回 isError、构建失败、验证失败或试玩异常时,把错误当作调试上下文,读取当前项目、修复真实文件并重跑失败步骤,不要直接结束或伪造成功;鉴权、权限、余额、身份、历史、传输断开和操作状态不确定等安全错误才停止。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(),
|
||||
format!("提示词与技能:{skill_index}"),
|
||||
];
|
||||
if controlled_web_search {
|
||||
@@ -4765,16 +4843,40 @@ async fn run_direct_game_creator_turn_inner(
|
||||
}
|
||||
}
|
||||
};
|
||||
let reply_result = direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
prompt.to_string(),
|
||||
Some(&client_turn_id),
|
||||
Some(&mut observer),
|
||||
audit,
|
||||
direct_user_item.clone(),
|
||||
)
|
||||
.await;
|
||||
let mut feedback_prompt = prompt.to_string();
|
||||
let mut audit = audit;
|
||||
let mut attempt = 1;
|
||||
let reply_result = loop {
|
||||
match direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt.clone(),
|
||||
feedback_prompt.clone(),
|
||||
Some(&client_turn_id),
|
||||
Some(&mut observer),
|
||||
audit.as_deref_mut(),
|
||||
direct_user_item.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => break Ok(value),
|
||||
Err(error)
|
||||
if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
|
||||
&& direct_codex_error_should_feedback(&error) =>
|
||||
{
|
||||
let detail = redact_agent_runtime_error(root, &error, 1800);
|
||||
emitter.emit(
|
||||
"running",
|
||||
Some("error-feedback"),
|
||||
Some(format!("检测到执行错误,正在反馈给陶泥儿继续修复({attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS})")),
|
||||
None,
|
||||
);
|
||||
attempt += 1;
|
||||
feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt);
|
||||
}
|
||||
// 失败也先走统一收尾,确保已提交的回合流快照全部落盘。
|
||||
Err(error) => break Err(error),
|
||||
}
|
||||
};
|
||||
drop(observer);
|
||||
if let Some(item) = stream_writer.take_pending_snapshot() {
|
||||
stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, &item));
|
||||
@@ -4788,16 +4890,41 @@ async fn run_direct_game_creator_turn_inner(
|
||||
}
|
||||
reply_result
|
||||
} else {
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
prompt.to_string(),
|
||||
None,
|
||||
None,
|
||||
audit,
|
||||
direct_user_item.clone(),
|
||||
)
|
||||
.await
|
||||
let mut feedback_prompt = prompt.to_string();
|
||||
let mut audit = audit;
|
||||
let mut response = None;
|
||||
for attempt in 1..=DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS {
|
||||
match direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt.clone(),
|
||||
feedback_prompt.clone(),
|
||||
None,
|
||||
None,
|
||||
audit.as_deref_mut(),
|
||||
direct_user_item.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => {
|
||||
response = Some(value);
|
||||
break;
|
||||
}
|
||||
Err(error)
|
||||
if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
|
||||
&& direct_codex_error_should_feedback(&error) =>
|
||||
{
|
||||
let detail = redact_agent_runtime_error(root, &error, 1800);
|
||||
feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt + 1);
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(DirectCodexTurnFailure::new(
|
||||
DirectCodexFailureStage::CodeGeneration,
|
||||
error,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
response.ok_or_else(|| "陶泥儿错误反馈回合未返回结果".to_string())
|
||||
}
|
||||
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
|
||||
// 回合结束:把本回合累积的工具调用整批落盘(一次锁、一次重写,幂等 upsert)。
|
||||
@@ -5172,6 +5299,33 @@ fn persist_direct_codex_assistant_reply_at(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn direct_tool_and_playtest_errors_are_feedbackable_but_transport_and_identity_errors_stop() {
|
||||
assert!(direct_codex_error_should_feedback(
|
||||
"agc_browser_playtest 失败:页面抛出异常"
|
||||
));
|
||||
assert!(direct_codex_error_should_feedback("npm run build 编译失败"));
|
||||
assert!(!direct_codex_error_should_feedback(
|
||||
"authentication-required: HTTP 401"
|
||||
));
|
||||
assert!(!direct_codex_error_should_feedback(
|
||||
"Codex app-server 连接已关闭"
|
||||
));
|
||||
assert!(!direct_codex_error_should_feedback("项目身份不匹配"));
|
||||
assert!(!direct_codex_error_should_feedback(
|
||||
"工具参数 attempt 必须是 1 到 3 的整数"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_error_feedback_prompt_requires_real_repair_and_is_bounded() {
|
||||
let prompt = direct_codex_error_feedback_prompt("npm run build 失败:入口不存在", 2);
|
||||
assert!(prompt.contains("读取当前项目和相关输出"));
|
||||
assert!(prompt.contains("不要伪造成功"));
|
||||
assert!(prompt.contains("第 2/3 次错误反馈"));
|
||||
assert!(prompt.contains("入口不存在"));
|
||||
}
|
||||
|
||||
fn direct_test_llm() -> GameCreatorLlmConfig {
|
||||
GameCreatorLlmConfig {
|
||||
api_key: "fixture-secret".to_string(),
|
||||
|
||||
@@ -19,6 +19,8 @@ const DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS: usize = 4_000;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_IMAGE_PROMPT_CHARS: usize = 32_000;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES: u64 = 6 * 1024 * 1024;
|
||||
const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES: usize = 256 * 1024;
|
||||
const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 1024;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS: usize = 400;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESULTS: usize = 5;
|
||||
const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=rss";
|
||||
@@ -694,14 +696,44 @@ fn direct_tool_bridge_state_with_search(
|
||||
})
|
||||
}
|
||||
|
||||
/// 将 MCP 图片 block 限制为可安全回显和持久化的预览。
|
||||
///
|
||||
/// 工具结果会被 Codex 原样写入 DirectProject 历史;这里保留小图的原始
|
||||
/// PNG,大图则缩放并转成 JPEG。项目文件中的原图不受影响,历史恢复仍有
|
||||
/// 可见证据,但不会把多张几 MiB 的截图永久复制进上下文。
|
||||
pub(crate) fn compact_mcp_image_data(data: &str) -> Option<(String, &'static str)> {
|
||||
let bytes = BASE64_STANDARD.decode(data).ok()?;
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if bytes.len() <= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES {
|
||||
return Some((data.to_string(), "image/png"));
|
||||
}
|
||||
|
||||
let image = image::load_from_memory(&bytes).ok()?;
|
||||
let mut preview = image.thumbnail(
|
||||
DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION,
|
||||
DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION,
|
||||
);
|
||||
for (dimension, quality) in [(1024, 78), (768, 70), (512, 60), (384, 50)] {
|
||||
if preview.width() > dimension || preview.height() > dimension {
|
||||
preview = image.thumbnail(dimension, dimension);
|
||||
}
|
||||
let mut encoded = Vec::new();
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, quality);
|
||||
preview.write_with_encoder(encoder).ok()?;
|
||||
if encoded.len() <= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES {
|
||||
return Some((BASE64_STANDARD.encode(encoded), "image/jpeg"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn bridge_tool_result(text: String, images: Vec<String>, is_error: bool) -> Value {
|
||||
let mut content = vec![json!({ "type": "text", "text": text })];
|
||||
content.extend(images.into_iter().map(|data| {
|
||||
json!({
|
||||
"type": "image",
|
||||
"data": data,
|
||||
"mimeType": "image/png"
|
||||
})
|
||||
content.extend(images.into_iter().filter_map(|data| {
|
||||
let (data, mime_type) = compact_mcp_image_data(&data).unwrap_or((data, "image/png"));
|
||||
Some(json!({ "type": "image", "data": data, "mimeType": mime_type }))
|
||||
}));
|
||||
json!({ "content": content, "isError": is_error })
|
||||
}
|
||||
@@ -2672,7 +2704,7 @@ pub(crate) async fn start_direct_tool_bridge(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
#[tokio::test]
|
||||
async fn controlled_search_client_omits_agc_marker() {
|
||||
@@ -2766,6 +2798,35 @@ mod tests {
|
||||
assert!(bridge_search_max_results(&json!({ "maxResults": 6 })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_mcp_images_are_reduced_to_bounded_jpeg_previews() {
|
||||
let image = image::RgbaImage::from_fn(1600, 1200, |x, y| {
|
||||
image::Rgba([
|
||||
(x % 251) as u8,
|
||||
(y % 251) as u8,
|
||||
((x.wrapping_mul(31) + y.wrapping_mul(17)) % 251) as u8,
|
||||
u8::MAX,
|
||||
])
|
||||
});
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(image)
|
||||
.write_to(&mut png, image::ImageFormat::Png)
|
||||
.expect("encode image fixture");
|
||||
assert!(png.get_ref().len() > DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES);
|
||||
|
||||
let (preview, mime_type) =
|
||||
compact_mcp_image_data(&BASE64_STANDARD.encode(png.into_inner()))
|
||||
.expect("large valid image should produce preview");
|
||||
assert_eq!(mime_type, "image/jpeg");
|
||||
assert!(
|
||||
BASE64_STANDARD
|
||||
.decode(preview)
|
||||
.expect("preview base64")
|
||||
.len()
|
||||
<= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_parser_accepts_only_bounded_public_https_results() {
|
||||
let body = r#"<rss><channel><item><title>Tauri & Rust</title><link>https://tauri.app/</link><description><b>Cross-platform apps</b></description></item><item><title>Private</title><link>http://127.0.0.1:8082/private</link><description>private</description></item><item><title>Credentials</title><link>https://user:pass@example.test/path</link><description>private</description></item><item><title>Loopback host</title><link>https://localhost/private</link><description>private</description></item><item><title>Local host</title><link>https://service.internal/private</link><description>private</description></item></channel></rss>"#;
|
||||
|
||||
@@ -5193,7 +5193,12 @@ pub(crate) fn create_game_creator_agent_session(
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||||
// 首轮策划消息可能紧跟项目初始化写入到达;对话保存应等待这段短暂的
|
||||
// 项目锁竞争,避免把可恢复的初始化竞态直接显示成保存失败。
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"conversation.write",
|
||||
)?;
|
||||
create_game_creator_agent_session_at(root, agent_id.trim(), title.trim())
|
||||
}
|
||||
|
||||
|
||||
@@ -7018,8 +7018,15 @@ export function App({
|
||||
// The legacy Supervisor/harness path remains below for rollback and tests.
|
||||
if (directCodexProductRuntime) {
|
||||
const directInvoke = resolveTauriInvoke();
|
||||
const directProjectPath = resolveChatProjectPath(localProject);
|
||||
if (directProjectPath && directInvoke) {
|
||||
// Capture the project snapshot before any asynchronous policy/session work.
|
||||
// `resolveChatProjectPath` only returns a path and TypeScript cannot infer
|
||||
// that the source project is still non-null after an await; keeping the
|
||||
// immutable snapshot also prevents a project switch from changing the
|
||||
// projectId used by this turn halfway through submission.
|
||||
const directProject = localProject;
|
||||
const directProjectPath = resolveChatProjectPath(directProject);
|
||||
const directProjectId = directProject?.manifest.projectId;
|
||||
if (directProjectPath && directProjectId && directInvoke) {
|
||||
const clientTurnId =
|
||||
directConversationTurnId ?? createDirectCodexConversationTurnId();
|
||||
const effectiveUserItem =
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Copy, Minus, Square, X } from 'lucide-react';
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
|
||||
import { subscribeTauriEvent } from '../services/tauriEventSubscription';
|
||||
import { AppUpdateNotice } from './AppUpdateNotice';
|
||||
import {
|
||||
WINDOW_CHROME_DEFAULT_TITLE,
|
||||
type WindowChromeActiveProjectRuns,
|
||||
WindowChromeContext,
|
||||
type WindowChromeContextValue,
|
||||
} from './windowChromeContext';
|
||||
@@ -39,6 +41,8 @@ function getNativeWindow() {
|
||||
export function WindowChrome({ children }: WindowChromeProps) {
|
||||
const [title, setTitleState] = useState(WINDOW_CHROME_DEFAULT_TITLE);
|
||||
const [walletSlot, setWalletSlot] = useState<HTMLDivElement | null>(null);
|
||||
const [activeProjectRuns, setActiveProjectRuns] =
|
||||
useState<WindowChromeActiveProjectRuns | null>(null);
|
||||
|
||||
const setTitle = useCallback((nextTitle: string | null | undefined) => {
|
||||
const normalizedTitle = nextTitle?.trim();
|
||||
@@ -50,6 +54,8 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
title,
|
||||
setTitle,
|
||||
walletSlot,
|
||||
activeProjectRuns,
|
||||
setActiveProjectRuns,
|
||||
};
|
||||
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
@@ -142,19 +148,33 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
</div>
|
||||
|
||||
<div className="window-chrome__drag-region" data-tauri-drag-region>
|
||||
<span className="window-chrome__title-wrap">
|
||||
<span
|
||||
className="window-chrome__workspace-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="window-chrome__title"
|
||||
title={title}
|
||||
aria-label={`当前工作区:${title}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
<div className="window-chrome__title-wrap">
|
||||
{activeProjectRuns &&
|
||||
(activeProjectRuns.activeTurns.length > 0 ||
|
||||
activeProjectRuns.readFailed) ? (
|
||||
<ActiveProjectRunsPanel
|
||||
activeTurns={activeProjectRuns.activeTurns}
|
||||
currentProjectPath={activeProjectRuns.currentProjectPath}
|
||||
readFailed={activeProjectRuns.readFailed}
|
||||
onOpenProject={activeProjectRuns.onOpenProject}
|
||||
placement="titlebar"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="window-chrome__workspace-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="window-chrome__title"
|
||||
title={title}
|
||||
aria-label={`当前工作区:${title}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-chrome__trailing">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { GameCreatorDirectActiveTurn } from '../app/types';
|
||||
|
||||
export const WINDOW_CHROME_DEFAULT_TITLE = '创作工作台';
|
||||
|
||||
export type WindowChromeContextValue = {
|
||||
@@ -7,6 +9,17 @@ export type WindowChromeContextValue = {
|
||||
title: string;
|
||||
setTitle: (title: string | null | undefined) => void;
|
||||
walletSlot: HTMLElement | null;
|
||||
activeProjectRuns: WindowChromeActiveProjectRuns | null;
|
||||
setActiveProjectRuns: (
|
||||
activeProjectRuns: WindowChromeActiveProjectRuns | null,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type WindowChromeActiveProjectRuns = {
|
||||
activeTurns: GameCreatorDirectActiveTurn[];
|
||||
currentProjectPath?: string | null;
|
||||
readFailed?: boolean;
|
||||
onOpenProject?: (projectPath: string) => void;
|
||||
};
|
||||
|
||||
export const WindowChromeContext = createContext<WindowChromeContextValue>({
|
||||
@@ -14,6 +27,8 @@ export const WindowChromeContext = createContext<WindowChromeContextValue>({
|
||||
title: WINDOW_CHROME_DEFAULT_TITLE,
|
||||
setTitle: () => undefined,
|
||||
walletSlot: null,
|
||||
activeProjectRuns: null,
|
||||
setActiveProjectRuns: () => undefined,
|
||||
});
|
||||
|
||||
export function useWindowChrome() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PlatformMudPointWalletEntry } from '../../../../../packages/shared/src/components/PlatformMudPointWalletEntry';
|
||||
import { PlatformProfileRechargeModal } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../../../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
import type { AccountWalletController } from './useAccountWallet';
|
||||
|
||||
export function AccountWalletBar({
|
||||
@@ -21,6 +22,7 @@ export function AccountWalletBar({
|
||||
onRequestDetails={() => void controller.onWalletBalanceMayHaveChanged()}
|
||||
onRecharge={controller.openRecharge}
|
||||
onOpenLedger={controller.openWalletLedger}
|
||||
onRedeemCode={controller.openRedeemCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -63,6 +65,54 @@ export function AccountWalletDialogs({
|
||||
onRetry={() => void controller.loadWalletLedger()}
|
||||
/>
|
||||
) : null}
|
||||
<ThemedModal
|
||||
open={controller.redeemCodeOpen}
|
||||
ariaLabel="兑换码"
|
||||
onClose={controller.closeRedeemCode}
|
||||
panelClassName="launcher-redeem-modal"
|
||||
>
|
||||
<header className="launcher-redeem-modal-header">
|
||||
<strong>兑换码</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={controller.closeRedeemCode}
|
||||
aria-label="关闭兑换码"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<form
|
||||
className="launcher-redeem-modal-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void controller.redeemCode();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={controller.redeemCodeInput}
|
||||
onChange={(event) =>
|
||||
controller.setRedeemCodeInput(event.target.value)
|
||||
}
|
||||
placeholder="输入兑换码"
|
||||
aria-label="兑换码"
|
||||
autoFocus
|
||||
/>
|
||||
{controller.redeemCodeError ? (
|
||||
<p role="alert">{controller.redeemCodeError}</p>
|
||||
) : null}
|
||||
{controller.redeemCodeSuccess ? (
|
||||
<p role="status">{controller.redeemCodeSuccess}</p>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
controller.redeemCodeLoading || !controller.redeemCodeInput.trim()
|
||||
}
|
||||
>
|
||||
{controller.redeemCodeLoading ? '兑换中' : '兑换'}
|
||||
</button>
|
||||
</form>
|
||||
</ThemedModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { GameCreatorDirectActiveTurn } from '../../app/types';
|
||||
import { projectNameFromPath } from '../agent-runtime';
|
||||
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
|
||||
|
||||
/**
|
||||
* 左上角的"正在运行的项目"面板。
|
||||
* 窗口标题栏的"正在运行的项目"入口,也保留面板布局供独立组件测试和复用。
|
||||
*
|
||||
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连),
|
||||
* 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时
|
||||
@@ -14,6 +17,7 @@ export type ActiveProjectRunsPanelProps = {
|
||||
currentProjectPath?: string | null;
|
||||
readFailed?: boolean;
|
||||
onOpenProject?: (projectPath: string) => void;
|
||||
placement?: 'panel' | 'titlebar';
|
||||
};
|
||||
|
||||
const ACTIVE_TURN_STATUS_LABELS: Record<string, string> = {
|
||||
@@ -56,11 +60,47 @@ export function ActiveProjectRunsPanel({
|
||||
currentProjectPath = null,
|
||||
readFailed = false,
|
||||
onOpenProject,
|
||||
placement = 'panel',
|
||||
}: ActiveProjectRunsPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || placement !== 'titlebar') {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, placement]);
|
||||
|
||||
if (activeTurns.length === 0) {
|
||||
if (!readFailed) {
|
||||
return null;
|
||||
}
|
||||
if (placement === 'titlebar') {
|
||||
return (
|
||||
<span
|
||||
className="launcher-runs-titlebar launcher-runs-titlebar--error"
|
||||
role="status"
|
||||
>
|
||||
正在运行的项目读取失败
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
@@ -75,6 +115,94 @@ export function ActiveProjectRunsPanel({
|
||||
const orderedTurns = [...activeTurns].sort(
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
if (placement === 'titlebar') {
|
||||
const latestTurn = orderedTurns[orderedTurns.length - 1];
|
||||
if (!latestTurn) {
|
||||
return null;
|
||||
}
|
||||
const latestName = activeTurnDisplayName(latestTurn);
|
||||
const openProject = (projectPath: string) => {
|
||||
setOpen(false);
|
||||
onOpenProject?.(projectPath);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="launcher-runs-titlebar"
|
||||
data-active-project-count={orderedTurns.length}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-runs-titlebar-trigger"
|
||||
aria-label={`正在运行的项目:${latestName}`}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span className="launcher-runs-panel-dot" aria-hidden="true" />
|
||||
<span className="launcher-runs-titlebar-name" title={latestName}>
|
||||
{latestName}
|
||||
</span>
|
||||
{orderedTurns.length > 1 ? (
|
||||
<span className="launcher-runs-titlebar-count">
|
||||
{orderedTurns.length}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
className={`launcher-runs-titlebar-chevron${open ? ' is-open' : ''}`}
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="launcher-runs-titlebar-menu" role="menu">
|
||||
<div className="launcher-runs-titlebar-menu-header">
|
||||
<strong>正在运行的项目</strong>
|
||||
<span>{orderedTurns.length} 个</span>
|
||||
</div>
|
||||
<ul className="launcher-runs-titlebar-list">
|
||||
{orderedTurns.map((turn) => {
|
||||
const name = activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
const isCurrent = Boolean(
|
||||
currentProjectPath &&
|
||||
projectPathsMatchForInvalidation(
|
||||
turn.projectPath,
|
||||
currentProjectPath,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<li key={`${turn.projectPath}:${turn.turnId}`}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="launcher-runs-titlebar-item"
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
disabled={!onOpenProject}
|
||||
onClick={() => openProject(turn.projectPath)}
|
||||
>
|
||||
<span
|
||||
className="launcher-runs-titlebar-item-name"
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="launcher-runs-titlebar-item-meta">
|
||||
{[activeTurnStatusLabel(turn.status), elapsed]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
<header className="launcher-runs-panel-header">
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||||
import { ActiveProjectRunsPanel } from './ActiveProjectRunsPanel';
|
||||
import {
|
||||
DeveloperAgentDialogs,
|
||||
DeveloperAgentPanel,
|
||||
@@ -50,6 +49,7 @@ export function WorkspaceLauncherShell({
|
||||
isWindowChrome,
|
||||
setTitle: setWindowTitle,
|
||||
walletSlot,
|
||||
setActiveProjectRuns,
|
||||
} = useWindowChrome();
|
||||
const accountWallet = useAccountWallet(currentUser.id);
|
||||
const [status, setStatus] = useState('');
|
||||
@@ -197,6 +197,31 @@ export function WorkspaceLauncherShell({
|
||||
* 清掉就等于这条提示时有时无。所以只有真的从 A 项目切到 B 项目(或关掉项目)才清。
|
||||
*/
|
||||
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
|
||||
|
||||
const openActiveProject = useCallback(
|
||||
(nextProjectPath: string) => {
|
||||
setProjectPath(nextProjectPath);
|
||||
void openProject(nextProjectPath, 'open');
|
||||
},
|
||||
[openProject, setProjectPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveProjectRuns({
|
||||
activeTurns,
|
||||
currentProjectPath: currentProjectContext?.projectPath ?? null,
|
||||
readFailed: snapshotReadFailed,
|
||||
onOpenProject: openActiveProject,
|
||||
});
|
||||
return () => setActiveProjectRuns(null);
|
||||
}, [
|
||||
activeTurns,
|
||||
currentProjectContext?.projectPath,
|
||||
openActiveProject,
|
||||
setActiveProjectRuns,
|
||||
snapshotReadFailed,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const projectPath = currentProjectContext?.projectPath ?? null;
|
||||
const previousProjectPath = manifestMergeNoticeScopeRef.current;
|
||||
@@ -524,16 +549,6 @@ export function WorkspaceLauncherShell({
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<ActiveProjectRunsPanel
|
||||
activeTurns={activeTurns}
|
||||
currentProjectPath={currentProjectContext?.projectPath ?? null}
|
||||
readFailed={snapshotReadFailed}
|
||||
onOpenProject={(nextProjectPath) => {
|
||||
setProjectPath(nextProjectPath);
|
||||
void openProject(nextProjectPath, 'open');
|
||||
}}
|
||||
/>
|
||||
|
||||
{launcherView === 'home' ? (
|
||||
<HomeView
|
||||
hasPromo={launcherNotifications.length > 0}
|
||||
@@ -631,6 +646,11 @@ export function WorkspaceLauncherShell({
|
||||
onMakeGame={() =>
|
||||
void switchToGameRuntime(currentProjectContext.projectPath)
|
||||
}
|
||||
onRevealProjectDirectory={() =>
|
||||
recentProjects.handleRevealProjectDirectory(
|
||||
currentProjectContext.projectPath,
|
||||
)
|
||||
}
|
||||
onManifestChange={syncActiveProjectManifest}
|
||||
onHomeOpen={() => setLauncherView('home')}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createClientProfileRechargeOrder,
|
||||
getClientProfileRechargeCenter,
|
||||
getClientProfileWalletLedger,
|
||||
redeemClientProfileRewardCode,
|
||||
} from '../../services/clientApi';
|
||||
import { useWalletStore } from '../../stores/useWalletStore';
|
||||
|
||||
@@ -45,8 +46,16 @@ export function useAccountWallet(currentUserId: string) {
|
||||
useState<string | null>(null);
|
||||
const [nativeRechargePayment, setNativeRechargePayment] =
|
||||
useState<PlatformProfileRechargeNativePaymentState | null>(null);
|
||||
const [redeemCodeOpen, setRedeemCodeOpen] = useState(false);
|
||||
const [redeemCodeInput, setRedeemCodeInput] = useState('');
|
||||
const [redeemCodeLoading, setRedeemCodeLoading] = useState(false);
|
||||
const [redeemCodeError, setRedeemCodeError] = useState<string | null>(null);
|
||||
const [redeemCodeSuccess, setRedeemCodeSuccess] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const rechargeLifecycleRef = useRef(0);
|
||||
const walletLedgerLifecycleRef = useRef(0);
|
||||
const redeemLifecycleRef = useRef(0);
|
||||
const [walletUiOwnerUserId, setWalletUiOwnerUserId] = useState(currentUserId);
|
||||
const currentUserIdRef = useRef(currentUserId);
|
||||
const walletOwnerMatchesCurrentUser =
|
||||
@@ -105,6 +114,12 @@ export function useAccountWallet(currentUserId: string) {
|
||||
setRechargeError(null);
|
||||
setSubmittingRechargeProductId(null);
|
||||
setNativeRechargePayment(null);
|
||||
redeemLifecycleRef.current += 1;
|
||||
setRedeemCodeOpen(false);
|
||||
setRedeemCodeInput('');
|
||||
setRedeemCodeLoading(false);
|
||||
setRedeemCodeError(null);
|
||||
setRedeemCodeSuccess(null);
|
||||
}, [currentUserId]);
|
||||
|
||||
async function loadWalletLedger() {
|
||||
@@ -214,6 +229,55 @@ export function useAccountWallet(currentUserId: string) {
|
||||
setSubmittingRechargeProductId(null);
|
||||
}
|
||||
|
||||
function openRedeemCode() {
|
||||
redeemLifecycleRef.current += 1;
|
||||
setRedeemCodeOpen(true);
|
||||
setRedeemCodeInput('');
|
||||
setRedeemCodeError(null);
|
||||
setRedeemCodeSuccess(null);
|
||||
}
|
||||
|
||||
function closeRedeemCode() {
|
||||
redeemLifecycleRef.current += 1;
|
||||
setRedeemCodeOpen(false);
|
||||
setRedeemCodeLoading(false);
|
||||
}
|
||||
|
||||
async function redeemCode() {
|
||||
const code = redeemCodeInput.trim();
|
||||
if (!code || redeemCodeLoading) return;
|
||||
const lifecycle = redeemLifecycleRef.current;
|
||||
const owner = currentUserId;
|
||||
setRedeemCodeLoading(true);
|
||||
setRedeemCodeError(null);
|
||||
setRedeemCodeSuccess(null);
|
||||
try {
|
||||
const response = await redeemClientProfileRewardCode(code);
|
||||
if (
|
||||
redeemLifecycleRef.current !== lifecycle ||
|
||||
currentUserIdRef.current !== owner
|
||||
)
|
||||
return;
|
||||
setRedeemCodeSuccess(`兑换成功,已到账 ${response.amountGranted} 泥点`);
|
||||
setRedeemCodeInput('');
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
} catch (error) {
|
||||
if (
|
||||
redeemLifecycleRef.current === lifecycle &&
|
||||
currentUserIdRef.current === owner
|
||||
) {
|
||||
setRedeemCodeError(error instanceof Error ? error.message : '兑换失败');
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
redeemLifecycleRef.current === lifecycle &&
|
||||
currentUserIdRef.current === owner
|
||||
) {
|
||||
setRedeemCodeLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buyRechargeProduct(product: ProfileRechargeProduct) {
|
||||
if (submittingRechargeProductId) {
|
||||
return;
|
||||
@@ -359,6 +423,15 @@ export function useAccountWallet(currentUserId: string) {
|
||||
closeRecharge,
|
||||
buyRechargeProduct,
|
||||
confirmNativeRechargePayment,
|
||||
redeemCodeOpen: walletUiIsVisible && redeemCodeOpen,
|
||||
redeemCodeInput,
|
||||
redeemCodeLoading: walletUiIsVisible && redeemCodeLoading,
|
||||
redeemCodeError: walletUiIsVisible ? redeemCodeError : null,
|
||||
redeemCodeSuccess: walletUiIsVisible ? redeemCodeSuccess : null,
|
||||
setRedeemCodeInput,
|
||||
openRedeemCode,
|
||||
closeRedeemCode,
|
||||
redeemCode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -885,7 +885,9 @@ export function ProjectSupervisorView({
|
||||
placeholder={
|
||||
directCodex
|
||||
? '描述你的想法,或 @ 引用素材'
|
||||
: '告诉项目总控接下来要做什么,或输入 @ 选择资源'
|
||||
: planningSurfaceActive
|
||||
? ''
|
||||
: '告诉项目总控接下来要做什么,或输入 @ 选择资源'
|
||||
}
|
||||
onChange={onChatInputChange}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ProfileDashboardSummary,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
unwrapApiResponse,
|
||||
} from '../../../../packages/shared/src';
|
||||
import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
|
||||
@@ -303,3 +304,15 @@ export function getClientProfileWalletLedger() {
|
||||
'读取泥点账单失败',
|
||||
);
|
||||
}
|
||||
|
||||
export function redeemClientProfileRewardCode(code: string) {
|
||||
return requestClientApi<RedeemProfileRewardCodeResponse>(
|
||||
'/api/profile/redeem-codes/redeem',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
},
|
||||
'兑换失败',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,7 +358,9 @@ body {
|
||||
|
||||
.window-chrome__title-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: min(42vw, 460px, 100%);
|
||||
}
|
||||
@@ -384,6 +386,162 @@ body {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
max-width: min(42vw, 460px, 100%);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: 7px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-base, #6f5848);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
border-color 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-trigger:hover,
|
||||
.launcher-runs-titlebar-trigger:focus-visible,
|
||||
.launcher-runs-titlebar-trigger[aria-expanded='true'] {
|
||||
border-color: var(--platform-surface-border, #ead8cb);
|
||||
background: rgb(255 255 255 / 72%);
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-count {
|
||||
display: inline-grid;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
background: rgb(199 101 61 / 12%);
|
||||
color: var(--platform-accent, #c7653d);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-chevron {
|
||||
flex: 0 0 auto;
|
||||
color: var(--platform-text-muted, #a38f80);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-chevron.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 50%;
|
||||
z-index: 40;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 10px;
|
||||
border: 1px solid var(--platform-subpanel-border, #ead8cb);
|
||||
border-radius: 12px;
|
||||
background: var(--platform-subpanel-fill, #fffaf5);
|
||||
box-shadow: 0 14px 36px rgb(31 24 16 / 18%);
|
||||
color: var(--platform-text-base, #6f5848);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 5px 8px;
|
||||
border-bottom: 1px solid var(--platform-surface-border, #ead8cb);
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-menu-header span {
|
||||
color: var(--platform-text-muted, #a38f80);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-list {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
max-height: min(52vh, 360px);
|
||||
margin: 7px 0 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 3px;
|
||||
padding: 8px 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-item:hover,
|
||||
.launcher-runs-titlebar-item:focus-visible,
|
||||
.launcher-runs-titlebar-item[aria-current='true'] {
|
||||
background: rgb(199 101 61 / 10%);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-item:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-item-name {
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-item-meta,
|
||||
.launcher-runs-titlebar--error {
|
||||
color: var(--platform-text-muted, #a38f80);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar--error {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgb(199 101 61 / 8%);
|
||||
}
|
||||
|
||||
.window-chrome__trailing {
|
||||
grid-column: 3;
|
||||
position: relative;
|
||||
@@ -530,8 +688,8 @@ body {
|
||||
}
|
||||
|
||||
.window-chrome__drag-region {
|
||||
display: none;
|
||||
padding-inline: 0;
|
||||
display: grid;
|
||||
padding-inline: 48px;
|
||||
}
|
||||
|
||||
.window-chrome__leading {
|
||||
@@ -1026,6 +1184,75 @@ textarea {
|
||||
top: 44px;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal {
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 0;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--platform-surface-border);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-header button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 10px;
|
||||
background: var(--platform-body-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form p[role='alert'] {
|
||||
color: #b5432e;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form p[role='status'] {
|
||||
color: #34804b;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form > button {
|
||||
padding: 11px 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--platform-accent, #c7653d);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-redeem-modal-form > button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.launcher-project-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
FolderKanban,
|
||||
FolderOpen,
|
||||
Gamepad2,
|
||||
Image,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
Sparkles,
|
||||
@@ -50,13 +49,6 @@ const HOME_CREATION_TYPE_ITEMS: readonly HomeCreationTypeItem[] = [
|
||||
emptyPrompt: '请输入游戏灵感或上传参考素材',
|
||||
icon: Gamepad2,
|
||||
},
|
||||
{
|
||||
creationType: 'art',
|
||||
label: '做素材',
|
||||
placeholder: '今天想做什么样的美术素材',
|
||||
emptyPrompt: '请输入素材需求或上传参考图',
|
||||
icon: Image,
|
||||
},
|
||||
{
|
||||
creationType: 'doc',
|
||||
label: '做方案',
|
||||
@@ -66,7 +58,7 @@ const HOME_CREATION_TYPE_ITEMS: readonly HomeCreationTypeItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// 首页只在做游戏与做方案之间分叉:做素材已被 master 并进直连构建,与做游戏同链,
|
||||
// 首页只保留做游戏与做方案两个入口;素材生成在项目内按实际工作流触发。
|
||||
export type HomeProjectRow = {
|
||||
path: string;
|
||||
name: string;
|
||||
@@ -149,13 +141,15 @@ export default function HomeView({
|
||||
const [planningCompletionEnabled, setPlanningCompletionEnabled] =
|
||||
useState(false);
|
||||
const homeCreationBusyRef = useRef(false);
|
||||
const effectiveCreationType =
|
||||
homeCreationType === 'art' ? 'game' : homeCreationType;
|
||||
const activeCreationType =
|
||||
HOME_CREATION_TYPE_ITEMS.find(
|
||||
(item) => item.creationType === homeCreationType,
|
||||
(item) => item.creationType === effectiveCreationType,
|
||||
) ?? HOME_CREATION_TYPE_ITEMS[0]!;
|
||||
// 做方案始终走立项策划链路;做游戏勾选“策划补全”时复用该链路,做素材保持直接开建。
|
||||
// 做方案始终走立项策划链路;做游戏勾选“策划补全”时复用该链路。
|
||||
const startMode = resolveHomeStartMode(
|
||||
homeCreationType,
|
||||
effectiveCreationType,
|
||||
planningCompletionEnabled,
|
||||
);
|
||||
|
||||
@@ -166,7 +160,7 @@ export default function HomeView({
|
||||
const referencedAttachments = richTextToAttachments(homeRichText);
|
||||
const prompt = richTextToPrompt(homeRichText);
|
||||
if (!prompt && referencedAttachments.length === 0) {
|
||||
if (homeCreationType !== 'doc') {
|
||||
if (effectiveCreationType !== 'doc') {
|
||||
onStatusChange(activeCreationType.emptyPrompt);
|
||||
}
|
||||
return;
|
||||
@@ -177,7 +171,7 @@ export default function HomeView({
|
||||
onStatusChange(
|
||||
await onCreateDraftAutomatically(
|
||||
{
|
||||
creationType: homeCreationType,
|
||||
creationType: effectiveCreationType,
|
||||
prompt,
|
||||
attachments: referencedAttachments,
|
||||
},
|
||||
@@ -274,7 +268,7 @@ export default function HomeView({
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<UploadButton />
|
||||
{homeCreationType === 'game' ? (
|
||||
{effectiveCreationType === 'game' ? (
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Crosshair,
|
||||
FileCode2,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
FolderTree,
|
||||
Gamepad2,
|
||||
Image,
|
||||
@@ -158,7 +159,6 @@ import {
|
||||
resolveResourceCanvasFocusEscapeActive,
|
||||
} from '../../features/resource-canvas/resourceCanvasFocusModel';
|
||||
import {
|
||||
isResourceCanvasGenerationAvailable,
|
||||
type ResourceCanvasGenerationKind,
|
||||
resourceCanvasGenerationOption,
|
||||
resourceCanvasGenerationSourceId,
|
||||
@@ -592,6 +592,7 @@ export type ProjectDevelopmentViewProps = {
|
||||
onProjectsOpen: () => void;
|
||||
onPlay?: () => void;
|
||||
onMakeGame?: () => void;
|
||||
onRevealProjectDirectory?: () => void | Promise<void>;
|
||||
onManifestChange?: (
|
||||
projectPath: string,
|
||||
manifest: GameCreationAppManifest,
|
||||
@@ -1462,6 +1463,7 @@ export default function ProjectDevelopmentView({
|
||||
onManifestChange,
|
||||
onPlay,
|
||||
onMakeGame,
|
||||
onRevealProjectDirectory,
|
||||
}: ProjectDevelopmentViewProps) {
|
||||
const professionalDagVisible = orchestrationMode === 'professional-dag';
|
||||
const [mode, setMode] = useState<WorkbenchMode>('resources');
|
||||
@@ -7462,12 +7464,6 @@ export default function ProjectDevelopmentView({
|
||||
RESOURCE_CHARACTER_ANIMATION_RESOLUTION,
|
||||
RESOURCE_CHARACTER_ANIMATION_DURATION_SECONDS,
|
||||
);
|
||||
// 没有客户端 invoke 桥或项目还没就绪时不渲染生成入口,避免留下点了没反应的按钮。
|
||||
const resourceGenerationAvailable = isResourceCanvasGenerationAvailable({
|
||||
hasRuntimeInvoke: Boolean(window.__TAURI__?.core?.invoke),
|
||||
projectPath,
|
||||
projectId: manifest.projectId,
|
||||
});
|
||||
/**
|
||||
* 栏目画布底部工具栏的渲染判据:只在栏目页 `child` 且命中矩阵里的四个栏目时成立。
|
||||
*
|
||||
@@ -7657,6 +7653,17 @@ export default function ProjectDevelopmentView({
|
||||
</button>
|
||||
</div>
|
||||
<div className="game-workbench-view-actions">
|
||||
{onRevealProjectDirectory ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-resource-panel-button"
|
||||
aria-label="打开项目目录"
|
||||
onClick={() => void onRevealProjectDirectory()}
|
||||
>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
打开项目目录
|
||||
</button>
|
||||
) : null}
|
||||
{mode === 'run' && embeddedPreviewUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -7690,27 +7697,9 @@ export default function ProjectDevelopmentView({
|
||||
<FolderTree size={15} aria-hidden="true" />
|
||||
资源面板
|
||||
</button>
|
||||
{resourceGenerationAvailable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-resource-panel-button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={resourceGenerationOpen}
|
||||
onClick={() =>
|
||||
setResourceGenerationDraft({
|
||||
initialKind: 'video',
|
||||
kinds: ['video'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
生成素材
|
||||
</button>
|
||||
) : null}
|
||||
{/*
|
||||
{/*
|
||||
「整理画布」是一枚资源动作,不是第三种排列方式:它排在「生成素材」之后、
|
||||
「管理未完成编辑」之前,与其他资源动作同类相邻,并留在
|
||||
「整理画布」是一枚资源动作,不是第三种排列方式:它与其他资源动作相邻,
|
||||
并留在
|
||||
`game-workbench-view-actions` 动作区里——外观直接复用该容器既有的动作按钮
|
||||
样式(有边圆角 + secondary 填充),与分段 pill 的模式切换一眼可分;因此不
|
||||
新增任何 CSS。**不要放到这一行的行尾**:行尾会被读成“针对整个工具条”的动作。
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameCreatorDirectActiveTurn } from '../src/app/types';
|
||||
import { WindowChrome } from '../src/components/WindowChrome';
|
||||
import { useWindowChrome } from '../src/components/windowChromeContext';
|
||||
|
||||
@@ -16,6 +17,24 @@ function TitleSetter({ value }: { value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveRunsSetter({
|
||||
activeTurns,
|
||||
}: {
|
||||
activeTurns: GameCreatorDirectActiveTurn[];
|
||||
}) {
|
||||
const { setActiveProjectRuns } = useWindowChrome();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActiveProjectRuns({ activeTurns, onOpenProject: () => undefined })
|
||||
}
|
||||
>
|
||||
显示运行项目
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('WindowChrome', () => {
|
||||
it('renders the陶泥儿 brand, default title, and controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -82,4 +101,44 @@ describe('WindowChrome', () => {
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: '页面按钮' }));
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the latest active project in the title bar and expands the full list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WindowChrome>
|
||||
<ActiveRunsSetter
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/first',
|
||||
projectName: '先开始',
|
||||
turnId: 'turn-first',
|
||||
startedAt: 100,
|
||||
status: 'running',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
{
|
||||
projectPath: 'C:/projects/later',
|
||||
projectName: '后开始',
|
||||
turnId: 'turn-later',
|
||||
startedAt: 200,
|
||||
status: 'streaming',
|
||||
updatedAt: 220,
|
||||
sequence: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</WindowChrome>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '显示运行项目' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: /正在运行的项目:后开始/ }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /正在运行的项目:后开始/ }),
|
||||
);
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1498,14 +1498,13 @@ export function registerHomeProjectCreationTests() {
|
||||
const gameType = within(creationTypes).getByRole('button', {
|
||||
name: '做游戏',
|
||||
});
|
||||
const artType = within(creationTypes).getByRole('button', {
|
||||
name: '做素材',
|
||||
});
|
||||
const documentType = within(creationTypes).getByRole('button', {
|
||||
name: '做方案',
|
||||
});
|
||||
expect(gameType.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(artType.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(
|
||||
within(creationTypes).queryByRole('button', { name: '做素材' }),
|
||||
).toBeNull();
|
||||
expect(documentType.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
|
||||
expect(screen.getAllByText('今天想把什么灵感做成游戏')).toHaveLength(1);
|
||||
@@ -1519,11 +1518,10 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'create_automatic_local_game_project',
|
||||
);
|
||||
fireEvent.click(artType);
|
||||
expect(gameType.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(artType.getAttribute('aria-pressed')).toBe('true');
|
||||
fireEvent.click(gameType);
|
||||
expect(gameType.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
|
||||
expect(screen.getAllByText('今天想做什么样的美术素材')).toHaveLength(1);
|
||||
expect(screen.getAllByText('今天想把什么灵感做成游戏')).toHaveLength(1);
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '你好,今天多少号';
|
||||
@@ -1562,7 +1560,7 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
|
||||
projectPath: automaticProjectPath,
|
||||
prompt: '你好,今天多少号',
|
||||
creationType: 'art',
|
||||
creationType: 'game',
|
||||
clientTurnId: expect.any(String),
|
||||
userItem: {
|
||||
id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/),
|
||||
|
||||
@@ -577,6 +577,21 @@ export function registerPlanGddApprovalTests() {
|
||||
expect(screen.queryByText(/计划 \d+\/\d+/)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the chat composer but hides its placeholder in the planning lane', async () => {
|
||||
const harness = createProjectSupervisorRuntimeHarness({
|
||||
planningV2Result: planningV2WorkingResult(),
|
||||
});
|
||||
await mountPlanningSurface(harness);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: '项目需求' })).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByText('告诉策划 Agent 接下来要做什么,或输入 @ 选择资源'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByText('告诉项目总控接下来要做什么,或输入 @ 选择资源'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('still surfaces the clarification card on the planning lane', async () => {
|
||||
// 澄清卡是 V2 策划链路唯一需要用户动手的交互面之一。
|
||||
const harness = createProjectSupervisorRuntimeHarness({
|
||||
|
||||
@@ -49,3 +49,41 @@ it('读取失败时保留明确的读取提示,不伪装成没有运行项目'
|
||||
|
||||
expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目');
|
||||
});
|
||||
|
||||
it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
|
||||
const onOpenProject = vi.fn();
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
placement="titlebar"
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/first',
|
||||
projectName: '先开始',
|
||||
turnId: 'turn-first',
|
||||
startedAt: 100,
|
||||
status: 'running',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
{
|
||||
projectPath: 'C:/projects/later',
|
||||
projectName: '后开始',
|
||||
turnId: 'turn-later',
|
||||
startedAt: 200,
|
||||
status: 'streaming',
|
||||
updatedAt: 220,
|
||||
sequence: 2,
|
||||
},
|
||||
]}
|
||||
onOpenProject={onOpenProject}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy();
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /后开始/ }));
|
||||
expect(screen.getByRole('menu')).toBeTruthy();
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
|
||||
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
|
||||
});
|
||||
|
||||
@@ -1109,49 +1109,11 @@ describe('project resource live canvas integration', () => {
|
||||
expect(readViewport()).not.toBe(viewportBefore);
|
||||
});
|
||||
|
||||
it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => {
|
||||
const { deriveCalls } = installTauri({ failFirstDerive: true });
|
||||
it('does not expose a top-level canvas generation entry', async () => {
|
||||
installTauri();
|
||||
render(<DerivedWorkbench />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '生成素材' }));
|
||||
// 「生成素材」入口只保留视频:音频入口已由栏目画布底部工具栏承载。
|
||||
const panel = await screen.findByRole('dialog', { name: '生成视频' });
|
||||
fireEvent.change(within(panel).getByLabelText('生成提示词'), {
|
||||
target: { value: '一段片头动画,镜头缓慢推进' },
|
||||
});
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '生成视频' }));
|
||||
expect(await within(panel).findByRole('alert')).not.toBeNull();
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '使用原请求重试' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(deriveCalls).toHaveLength(2));
|
||||
const operationId = String(deriveCalls[0]?.operationId);
|
||||
expect(deriveCalls[0]).toMatchObject({
|
||||
projectPath,
|
||||
expectedProjectId: 'live-canvas-project',
|
||||
editKind: 'video',
|
||||
generationMode: 'create',
|
||||
sourceResourceId: `create:${operationId}`,
|
||||
sourceAssetId: null,
|
||||
sourcePath: null,
|
||||
sourceMediaType: 'video/mp4',
|
||||
sourceSubtype: null,
|
||||
producerTaskId: null,
|
||||
sourceVersionId: null,
|
||||
prompt: '一段片头动画,镜头缓慢推进',
|
||||
assetName: '新视频',
|
||||
});
|
||||
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
|
||||
expect(deriveCalls[1]?.operationId).toBe(operationId);
|
||||
expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey);
|
||||
expect(screen.queryByRole('dialog', { name: '生成视频' })).toBeNull();
|
||||
// 产出物是新素材:画布定位并选中新卡片。
|
||||
expect(
|
||||
(await findResourceSelectButton(`${operationId}-rules.md`)).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
).toBe('true');
|
||||
expect(screen.queryByRole('button', { name: '生成素材' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the search condition, offers an explicit clear-and-locate, then locates the new asset', async () => {
|
||||
|
||||
@@ -790,8 +790,7 @@ describe('资源画布手动重排口径', () => {
|
||||
const actionsRow = rederiveButton.closest('.game-workbench-view-actions');
|
||||
expect(actionsRow).not.toBeNull();
|
||||
|
||||
// 位置:不再是这一行的最后一个按钮(行尾会被读成"针对整个工具条"的动作),
|
||||
// 紧跟「生成素材」,并且在排序组左侧。
|
||||
// 位置:整理画布仍在排序组左侧,不会被读成排列方式的一部分。
|
||||
const rowButtons = Array.from(actionsRow!.querySelectorAll('button'));
|
||||
expect(rowButtons.at(-1)).not.toBe(rederiveButton);
|
||||
const rederiveIndex = rowButtons.indexOf(rederiveButton);
|
||||
@@ -801,9 +800,6 @@ describe('资源画布手动重排口径', () => {
|
||||
expect(rederiveIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(sortGroupIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(rederiveIndex).toBeLessThan(sortGroupIndex);
|
||||
expect(rederiveButton.previousElementSibling).toBe(
|
||||
screen.getByRole('button', { name: '生成素材' }),
|
||||
);
|
||||
|
||||
// 语义没变:可点性只跟布局就绪绑定,布局读完后它就是可点的。
|
||||
await waitFor(() =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user