Merge remote-tracking branch 'origin/master' into fix/wrong-report
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m23s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m48s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 2m14s
Project CI / Repository checks (pull_request) Failing after 36s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m49s
Project CI / Frontend tests (pull_request) Successful in 4m7s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m41s
Project CI / Native shell tests (pull_request) Failing after 5m46s

# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
2026-10-02 13:56:47 +08:00
44 changed files with 1379 additions and 159 deletions
+7 -1
View File
@@ -1075,7 +1075,11 @@ export interface AdminRechargeRefundActionResponse {
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic';
export type AdminAgcAgentMode = 'codex' | 'cc';
export type AdminAgcModelProtocol =
| 'openai_responses'
| 'openai_chat'
| 'anthropic';
export interface AdminAgcModel {
id: string;
@@ -1083,6 +1087,8 @@ export interface AdminAgcModel {
modelId: string;
enabled: boolean;
agentMode: AdminAgcAgentMode;
/** 上游线协议,与执行器正交。 */
protocol?: AdminAgcModelProtocol;
}
export interface AdminAgcModelCatalog {
@@ -104,3 +104,52 @@ test('new models default to Codex and save the selected Agent mode', async () =>
});
});
});
test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => {
const catalog = {
revision: 3,
defaultModelId: 'quality',
models: [
{
id: 'quality',
alias: '高质量',
modelId: 'claude-opus-5-5',
enabled: true,
agentMode: 'cc' as const,
protocol: 'anthropic' as const,
},
],
};
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
...input,
revision: 4,
}));
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
await screen.findByDisplayValue('claude-opus-5-5');
const agentMode = screen.getByLabelText(
'模型 1 Agent 模式',
) as HTMLSelectElement;
const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement;
expect(agentMode.value).toBe('cc');
expect(protocol.value).toBe('anthropic');
// 执行器下拉里不再混入协议取值。
expect(Array.from(agentMode.options).map((option) => option.value)).toEqual([
'codex',
'cc',
]);
fireEvent.change(agentMode, { target: { value: 'codex' } });
fireEvent.change(protocol, { target: { value: 'openai_chat' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
const payload = calls[calls.length - 1]?.[1];
expect(payload?.models[0]).toMatchObject({
agentMode: 'codex',
protocol: 'openai_chat',
});
});
});
@@ -117,6 +117,7 @@ export function AdminAgcModelsPage({
{ key: 'alias', label: '别名' },
{ key: 'modelId', label: '实际模型名' },
{ key: 'agentMode', label: 'Agent 模式' },
{ key: 'protocol', label: '协议' },
{ key: 'enabled', label: '启用' },
{ key: 'default', label: '默认' },
{ key: 'actions', label: '操作' },
@@ -157,6 +158,7 @@ export function AdminAgcModelsPage({
modelId: '',
enabled: true,
agentMode: 'codex',
protocol: 'openai_responses',
},
],
},
@@ -226,7 +228,22 @@ export function AdminAgcModelsPage({
>
<option value="codex">Codex</option>
<option value="cc">Claude Code</option>
<option value="anthropic">Anthropic 协议</option>
</select>
</td>
<td>
<select
aria-label={`模型 ${index + 1} 协议`}
value={model.protocol ?? 'openai_responses'}
disabled={busy}
onChange={(e) =>
update(model.id, {
protocol: e.target.value as AdminAgcModel['protocol'],
})
}
>
<option value="openai_responses">OpenAI Responses</option>
<option value="openai_chat">OpenAI Chat Completions</option>
<option value="anthropic">Anthropic Messages</option>
</select>
</td>
<td>
@@ -313,7 +313,11 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result<String,
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.map_err(|_| "Claude Agent SDK sidecar 的 Node runtime 不可用".to_string())?;
.map_err(|error| {
// `output()` 失败只说明进程没能启动(可执行文件缺失、权限不足或被拦截),
// 不能笼统说成「Node runtime 不可用」并丢掉 OS 错误。
format!("Claude Agent SDK sidecar 的 Node runtime 启动失败:{error}")
})?;
if !output.status.success() {
return Err("Claude Agent SDK sidecar 的 Node runtime 版本检查失败".to_string());
}
@@ -1971,6 +1971,28 @@ fn configure_game_creator_codex_app_server_command(
)
}
/// Codex 的 `wire_api` 取自所选模型的线协议。
///
/// 单测不读全局配置:用例里没有真实的模型选择,读它会引入环境依赖,所以测试态固定
/// `responses`,真实构建按 `selected_model_protocol` 在 `responses` / `chat` 间切换。
fn game_creator_codex_provider_wire_api() -> &'static str {
#[cfg(test)]
{
"responses"
}
#[cfg(not(test))]
{
match crate::config::load_game_creator_app_config()
.ok()
.and_then(|config| config.selected_model_protocol)
.as_deref()
{
Some("openai_chat") => "chat",
_ => "responses",
}
}
}
fn configure_game_creator_codex_app_server_command_for_mode(
command: &mut tokio::process::Command,
llm: &GameCreatorLlmConfig,
@@ -2127,6 +2149,9 @@ fn configure_game_creator_codex_app_server_command_for_mode(
let provider_token = provider_proxy
.map(CodexProviderProxy::downstream_bearer_token)
.unwrap_or(legacy_api_key);
// 线协议由所选模型的 `protocol` 决定:openai_chat 走 /chat/completions,
// 其余走 /responses。测试里不读全局配置,避免用例依赖运行环境。
let wire_api = game_creator_codex_provider_wire_api();
command
.arg("-c")
.arg(format!(
@@ -2151,7 +2176,7 @@ fn configure_game_creator_codex_app_server_command_for_mode(
.arg("-c")
.arg(format!(
"model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.wire_api={}",
quoted_toml_string("responses")?
quoted_toml_string(wire_api)?
))
.env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, provider_token);
}
@@ -7338,7 +7338,7 @@ mod tests {
);
assert!(error
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary="));
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary="));
assert!(error.contains("<redacted-url>"), "{error}");
assert!(error.contains("<absolute-path>"), "{error}");
assert!(!error.contains("authorization=Bearer secret"), "{error}");
@@ -7382,7 +7382,7 @@ mod tests {
assert!(
error.starts_with(
"direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary="
"direct-codex-failure:v2 stage=code-generation code=runtime-unclassified retryable=false summary="
),
"{error}"
);
@@ -7421,7 +7421,7 @@ mod tests {
);
assert!(
error.contains("stage=art-preparation code=runtime-failure retryable=false"),
error.contains("stage=art-preparation code=runtime-unclassified retryable=false"),
"{error}"
);
assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}");
@@ -204,6 +204,12 @@ fn single_line_log_field(value: &str) -> String {
value.replace(['\r', '\n'], " ")
}
/// 直连回合失败的持久化标签。
///
/// 这里是**唯一**的失败分类出口,标签会被写进 `.agent/runtime/errors/*.json` 与
/// `direct-codex-failure:v2` 文案。未识别的原因必须落回专门的 `runtime-unclassified`:
/// 以前这里借用具体的 `runtime-failure` 兜底,任何新失败形态都会被读成「运行时失败」,
/// 真正的失败原因反而被盖掉,也无法从标签看出分类覆盖率。
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase();
if normalized.contains("等待 turn/completed 超时") {
@@ -224,8 +230,16 @@ pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
"tool-invalid-arguments"
} else if normalized.contains("codex app-server-error:other") {
"app-server-other"
} else if normalized.contains("sidecar") && normalized.contains("超时") {
"sidecar-turn-timeout"
} else if normalized.contains("sidecar") && normalized.contains("启动失败") {
"sidecar-launch-failed"
} else if normalized.contains("codex app-server 启动失败") {
"app-server-launch-failed"
} else if normalized.contains("claude code 返回失败终态") {
"claude-code-failed"
} else {
"runtime-failure"
"runtime-unclassified"
}
}
@@ -361,4 +375,39 @@ mod tests {
"tool-invalid-arguments"
);
}
#[test]
fn known_sidecar_and_app_server_failures_keep_their_own_codes() {
assert_eq!(
classify_direct_codex_error("Claude Agent SDK sidecar 回合超时"),
"sidecar-turn-timeout"
);
assert_eq!(
classify_direct_codex_error(
"Claude Agent SDK sidecar 的 Node runtime 启动失败:拒绝访问"
),
"sidecar-launch-failed"
);
assert_eq!(
classify_direct_codex_error("Codex app-server 启动失败:找不到可执行文件"),
"app-server-launch-failed"
);
assert_eq!(
classify_direct_codex_error("Claude Code 返回失败终态"),
"claude-code-failed"
);
}
#[test]
fn unclassified_failures_get_a_dedicated_code() {
// 任何一个未识别原因都不能被读成某个具体故障:只允许专用兜底码。
assert_eq!(
classify_direct_codex_error("某种尚未识别的新失败"),
"runtime-unclassified"
);
assert_eq!(
classify_direct_codex_error("读取陶泥儿画布资源失败:<redacted-url>"),
"runtime-unclassified"
);
}
}
@@ -13,13 +13,16 @@
use std::path::{Path, PathBuf};
use futures::FutureExt;
#[cfg(test)]
use crate::agent::PendingTurn;
use crate::agent::{
append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved,
direct_codex_user_item_to_prompt, direct_now_ms, redact_agent_runtime_error,
run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project,
DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, DispatchedTurn,
direct_codex_user_item_to_prompt, direct_now_ms, record_direct_codex_failure,
redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter,
thread_id_for_project, DirectCodexFailureStage, DirectGameCreatorTurnUpdateEmitter,
DirectTurnError, DirectTurnTerminal, DispatchedTurn,
};
/// 一次放行的占用。持有它就代表这一轮还没收口。
@@ -84,13 +87,90 @@ impl Drop for TurnReservation {
fn drop(&mut self) {
// 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。
// 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。
let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped());
let finalized_by_guard = self.finish_if_unfinished(DirectTurnTerminal::host_dropped());
// 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。
// 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层
// 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与
// application.log 全空)。测试态不写,避免用例污染本机 AppData。
#[cfg(not(test))]
if finalized_by_guard {
// 项目侧也留一份脱敏诊断:用户在项目目录里就能看到这一轮的收场,
// 不必只依赖 AppData 的应用日志。
let failure = DirectTurnError::TurnFailed {
stage: DirectCodexFailureStage::CodeGeneration,
detail: format!(
"DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} turnToken={}",
self.thread_id, self.token
),
};
let _ = record_direct_codex_failure(Path::new(&self.thread_id), &failure, None);
let _ = crate::append_application_log(
"error".to_string(),
"direct-turn".to_string(),
format!(
"agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}",
self.thread_id,
self.token,
self.user_item_id.as_deref().unwrap_or("-"),
// true = 这一轮是被 panic 带走的;false = future 被丢弃或取消。
std::thread::panicking()
),
);
}
// 占用的释放就是"这个项目腾出了跑回合的位置":踢一脚,队里排着的消息不必等下一次用户动作。
// panic 也走这里,所以队列不会因为一个任务炸掉而永久停住。
kick_queue_dispatch(Path::new(&self.thread_id));
}
}
// 运行段经 task-local 携带回合身份,panic hook 据此把 panic 位置与负载写进应用日志。
// task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。
tokio::task_local! {
static DIRECT_TURN_PANIC_CONTEXT: Option<(String, String)>;
}
/// 兜底诊断:Direct 回合任务 panic 时,把位置与负载写进应用日志。
///
/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成 `HostDropped`——那是"说不出原因"
/// 的分类,离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log`
/// 全空,无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。
fn ensure_direct_turn_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Ok(Some((thread_id, token))) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) {
let location = info
.location()
.map(|location| {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
})
.unwrap_or_else(|| "未知位置".to_string());
#[cfg(not(test))]
let _ = crate::append_application_log(
"error".to_string(),
"direct-turn".to_string(),
format!(
"agent.direct_turn.panic threadId={} turnToken={} location={} payload={}",
thread_id,
token,
location,
info.payload_as_str().unwrap_or("未知 panic 负载")
),
);
#[cfg(test)]
let _ = (thread_id, token, location);
}
previous(info);
}));
});
}
/// 踢一脚:让队首那条待发消息有机会变成一轮真的回合。
///
/// 幂等,三个调用点:**入队之后**(队列空且没有回合在跑时,放行就是这一脚,不必再等一个调度周期)、
@@ -109,10 +189,23 @@ pub(crate) fn kick_queue_dispatch(root: &Path) {
crate::platform_session::current_platform_session_write_state().identity_generation;
let reservation = TurnReservation::resume(&thread_id, &dispatched);
let root = root.to_path_buf();
tauri::async_runtime::spawn(async move {
ensure_direct_turn_panic_hook();
let panic_context = Some((thread_id.clone(), dispatched.token.clone()));
tauri::async_runtime::spawn(DIRECT_TURN_PANIC_CONTEXT.scope(panic_context, async move {
run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation)
.await;
});
}));
}
/// 把 panic 负载转成可读文本:`panic!("…")` 的负载是 `&str`,`panic!("{x}")` 是 `String`。
fn direct_turn_panic_detail(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(text) = payload.downcast_ref::<&str>() {
(*text).to_string()
} else if let Some(text) = payload.downcast_ref::<String>() {
text.clone()
} else {
"未知 panic 负载".to_string()
}
}
/// 放行之后这一段:取这一轮的调用身份 → 落盘用户条目 → 下发 → 跑整轮。
@@ -149,16 +242,38 @@ async fn run_dispatched_direct_turn(
crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item);
let capture = crate::analytics::gui::capture_writer_context();
let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id);
let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter(
&root,
&prompt,
dispatched.pending.creation_type.as_deref(),
Some(&emitter),
Some(canonical_user_item.clone()),
capture,
release_identity_generation,
// 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成"说不出原因"的
// `HostDropped`,症状正是用户看到的「本轮执行已中断,请重试」,而项目侧诊断与应用日志
// **一行都不会有**(真实案例:2026-10-02 连续两轮)。这里把 panic 转成分类失败,
// 走与正常失败同一条收口:先写脱敏诊断,再写终态。
let outcome = match std::panic::AssertUnwindSafe(
run_direct_game_creator_turn_at_with_creation_type_and_emitter(
&root,
&prompt,
dispatched.pending.creation_type.as_deref(),
Some(&emitter),
Some(canonical_user_item.clone()),
capture,
release_identity_generation,
),
)
.await;
.catch_unwind()
.await
{
Ok(result) => result,
Err(payload) => {
let failure = DirectTurnError::turn_failed(
DirectCodexFailureStage::CodeGeneration,
format!(
"DirectProject 宿主任务 panic:{}",
direct_turn_panic_detail(payload.as_ref())
),
);
let stage = failure.turn_failure_stage();
let detail = record_direct_codex_failure(&root, &failure, Some(turn_id.as_str()));
Err(DirectTurnError::TurnFailed { stage, detail })
}
};
match outcome {
Ok(_) => {
// 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。
@@ -422,7 +422,8 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
}
let stylesVisible = true;
let pointerBlocked = false;
// 后代可用 auto 覆盖祖先的 none;未覆盖时计算样式仍会继承 none。
const pointerBlocked = getComputedStyle(this).pointerEvents === 'none';
for (let current = this; current instanceof HTMLElement; current = current.parentElement) {
const style = getComputedStyle(current);
const opacity = Number.parseFloat(style.opacity);
@@ -431,7 +432,6 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
&& style.visibility !== 'hidden'
&& style.visibility !== 'collapse'
&& (Number.isNaN(opacity) || opacity > 0);
pointerBlocked ||= style.pointerEvents === 'none';
}
const rect = this.getBoundingClientRect();
@@ -270,16 +270,12 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
let tetris = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::TetrisV1);
let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1);
assert_eq!(
generic,
"e5e1b4d01e39cb7b0dd2fd5a56982c3ddf825a926738d99152875ee1ff412a8d"
);
assert_eq!(
tetris,
"b3c49f43f70291ea5c9431a21bb751fead2e244c8e97fecec20ee3e38215a59c"
);
assert_eq!(
lane,
"6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a"
[generic.as_str(), tetris.as_str(), lane.as_str()],
[
"85e7ec677411745ad3a7385db67a2c89441cb2ccc12a7a1853bb87ba74f15e0d",
"60c202a9fe6313dd414365db52df8b5b63110971f89708ac1563d531f510f4ff",
"a3120c8cf585f2e06091229c3b7c3a5bd8aa08821704064edc611297ee54f466",
]
);
assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert!(tetris.bytes().all(|byte| byte.is_ascii_hexdigit()));
@@ -1354,7 +1350,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() {
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immediate_flow() {
let result = run_stable_generic_viewport_fixture(false).await;
let result = run_stable_generic_viewport_fixture(false, None).await;
assert!(
result.passed,
"diagnostics={:#?}\nviewports={:#?}",
@@ -1388,7 +1384,7 @@ async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immedi
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
let result = run_stable_generic_viewport_fixture(true).await;
let result = run_stable_generic_viewport_fixture(true, None).await;
assert!(
!result.passed,
"a successful desktop cannot hide mobile gameplay failure"
@@ -1410,8 +1406,117 @@ async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
.any(|message| message.starts_with("playtest mobile:")));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_pointer_events_allows_auto_under_none() {
let result = run_stable_generic_viewport_fixture(
false,
Some(
r#"<div style="pointer-events:none;padding:20px">
<button type="button" data-playtest-id="start" style="pointer-events:auto;padding:0">
<span style="display:block;padding:20px">Start</span>
</button>
</div>"#,
),
)
.await;
assert!(result.passed, "{:?}", result.diagnostics);
for viewport in REQUIRED_VIEWPORTS {
let playtest = &result
.viewport_playtests
.iter()
.find(|entry| entry.viewport == viewport)
.expect("each viewport must have gameplay evidence")
.result;
assert!(playtest.passed, "{viewport:?}: {:?}", playtest.diagnostics);
assert_eq!(playtest.initial_sequence, Some(0));
assert_eq!(playtest.final_sequence, Some(3));
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready));
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
}
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_blocked() {
for (case, markup) in [
(
"inherited-none",
r#"<div style="pointer-events:none"><button data-playtest-id="start">Start</button></div>"#,
),
(
"own-none",
r#"<button data-playtest-id="start" style="pointer-events:none">Start</button>"#,
),
(
"own-none-child-auto",
r#"<button data-playtest-id="start" style="pointer-events:none;padding:0"><span style="pointer-events:auto;display:block;padding:20px">Start</span></button>"#,
),
(
"disabled",
r#"<button data-playtest-id="start" disabled>Start</button>"#,
),
(
"disabled-fieldset",
r#"<fieldset disabled><button data-playtest-id="start">Start</button></fieldset>"#,
),
(
"aria-disabled",
r#"<button data-playtest-id="start" aria-disabled="true">Start</button>"#,
),
(
"inert",
r#"<div inert><button data-playtest-id="start" style="pointer-events:auto">Start</button></div>"#,
),
(
"hidden",
r#"<div style="display:none"><button data-playtest-id="start">Start</button></div>"#,
),
(
"covered",
r#"<div style="position:relative;width:max-content"><button data-playtest-id="start">Start</button><div style="position:absolute;inset:0;background:black"></div></div>"#,
),
] {
let result = run_stable_generic_viewport_fixture(false, Some(markup)).await;
assert!(!result.passed, "{case} must fail");
for viewport in REQUIRED_VIEWPORTS {
let playtest = &result
.viewport_playtests
.iter()
.find(|entry| entry.viewport == viewport)
.expect("each viewport must have gameplay evidence")
.result;
assert!(!playtest.passed, "{case} / {viewport:?} must fail");
assert!(
playtest
.diagnostics
.iter()
.any(|message| message.contains("固定试玩控件 start")),
"{case} / {viewport:?}: {:?}",
playtest.diagnostics
);
assert!(
playtest.assertions.iter().any(|assertion| {
assertion.name == "start-control-clicked" && !assertion.passed
}),
"{case} / {viewport:?} must be rejected before input"
);
if case == "own-none-child-auto" {
assert!(
playtest.diagnostics.iter().any(|message| {
message == "固定试玩控件 start 处于 disabled 状态"
}),
"{viewport:?}: child hit must not enable the pointer-blocked target: {:?}",
playtest.diagnostics
);
}
}
}
}
async fn run_stable_generic_viewport_fixture(
mobile_action_broken: bool,
start_markup: Option<&str>,
) -> BrowserValidationResult {
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -1482,6 +1587,11 @@ async fn run_stable_generic_viewport_fixture(
</body>
</html>"#;
let html = String::from_utf8_lossy(html)
.replace(
r#"<button type="button" data-playtest-id="start">Start</button>"#,
start_markup
.unwrap_or(r#"<button type="button" data-playtest-id="start">Start</button>"#),
)
.replace(
"const mobileActionBroken = false;",
if mobile_action_broken {
@@ -1078,6 +1078,7 @@ fn select_game_creator_model_inner(
model_id: String,
is_default: bool,
catalog_agent_mode: Option<String>,
catalog_protocol: Option<String>,
) -> Result<GameCreatorAppConfigView, String> {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
@@ -1098,13 +1099,34 @@ fn select_game_creator_model_inner(
if let Some(agent_mode) = catalog_agent_mode {
config.agent_mode = match agent_mode.trim() {
"codex" => GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(),
// `cc` 是 Claude 执行器的历史别名,`anthropic` 是同一执行器的显式协议值。
"cc" | "anthropic" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
"cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
value => return Err(format!("模型 Agent 模式无效:{value}")),
};
}
// 协议与执行器正交,但合法组合是封闭的;缺省按执行器推导。
let protocol = match catalog_protocol.as_deref().map(str::trim) {
Some("openai_responses") => "openai_responses",
Some("openai_chat") => "openai_chat",
Some("anthropic") => "anthropic",
Some("") | None => {
if config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI {
"anthropic"
} else {
"openai_responses"
}
}
Some(value) => return Err(format!("模型协议无效:{value}")),
};
let claude = config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI;
if claude != (protocol == "anthropic") {
return Err(format!(
"执行器与协议组合无效:{} 不支持 {protocol}",
config.agent_mode
));
}
config.selected_model_id = model_id;
config.selected_model_is_default = is_default;
config.selected_model_protocol = Some(protocol.to_string());
persist_game_creator_app_config(config, overlays, true)
}
@@ -1113,8 +1135,9 @@ pub(crate) fn select_game_creator_model(
model_id: String,
is_default: bool,
agent_mode: Option<String>,
protocol: Option<String>,
) -> Result<GameCreatorAppConfigView, String> {
select_game_creator_model_inner(model_id, is_default, agent_mode)
select_game_creator_model_inner(model_id, is_default, agent_mode, protocol)
}
fn persist_game_creator_app_config(
@@ -583,7 +583,12 @@ fn check_game_creator_codex_app_server_available() -> Result<(), String> {
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.map_err(|_| "Codex CLI 不支持 app-server 子命令".to_string())?;
.map_err(|error| {
// 进程起不来(可执行文件缺失、路径错误、权限不足或被安全软件拦截)与
// 「不支持 app-server 子命令」是两码事:报成后者会让用户去升级 CLI,
// 升级完仍是同一个错。这里保留真实 OS 错误。
format!("Codex CLI 启动失败:{error}")
})?;
if !output.status.success()
|| !String::from_utf8_lossy(&output.stdout).contains("codex app-server")
{
@@ -4037,6 +4042,9 @@ fn merge_game_creator_config_content(
if let Some(selected_model_is_default) = file_config.selected_model_is_default {
config.selected_model_is_default = selected_model_is_default;
}
if let Some(selected_model_protocol) = file_config.selected_model_protocol {
config.selected_model_protocol = Some(selected_model_protocol);
}
Ok(())
}
@@ -302,6 +302,46 @@ async fn host_npm(
.map_err(str::to_string)
}
/// 浏览器验证失败的宿主错误码。
///
/// 验证层会为不同原因返回不同文本:没有可用浏览器、启动失败或启动超时、宿主取消、
/// 浏览器收束未确认、证据写入失败、输入或页面校验失败。把它们统一压成「浏览器有问题」,
/// 用户和值班人员都会朝浏览器方向排查却查不到原因,所以这里按真实原因分流;
/// 未识别的原因落回专用兜底码 `web-preflight-unclassified`,不再借用某个具体子系统。
fn browser_validation_failure_code(error: &str) -> &'static str {
if error.contains("未发现可用的") {
"web-preflight-browser-missing"
} else if error.contains("启动浏览器超时") {
"web-preflight-browser-launch-timeout"
} else if error.contains("启动浏览器失败") {
"web-preflight-browser-launch-failed"
} else if error.contains("宿主已停止本轮浏览器验证") {
"web-preflight-cancelled"
} else if error.contains("browser-cleanup")
|| error.contains("browser-tree-reap")
|| error.contains("browser-version-invalid")
{
"web-preflight-browser-cleanup-failed"
} else if error.contains("浏览器临时") || error.contains("浏览器配置") {
"web-preflight-browser-environment-failed"
} else if error.contains("evidenceRoot")
|| error.contains("证据")
|| error.contains("浏览器验证报告")
|| error.contains("浏览器验证截图")
{
"web-preflight-evidence-failed"
} else if error.contains("预览 URL")
|| error.contains("viewports")
|| error.contains("settleMs")
{
"web-preflight-input-invalid"
} else if error.contains("页面导航失败") || error.contains("截图不是有效 PNG") {
"web-preflight-page-check-failed"
} else {
"web-preflight-unclassified"
}
}
pub(crate) async fn host_web_creation_preflight() -> Value {
let started = Instant::now();
// 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。
@@ -322,7 +362,7 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput {
url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"),
}).await;
let result = validation.map_err(|_| "web-preflight-browser-failed")?;
let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?;
if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); }
let mut pngs = Vec::new();
for viewport in result.viewport_results {
@@ -382,6 +422,24 @@ fn claim_preparation(root: &Path) -> Result<Option<(ScaffoldReceipt, Preparation
Ok(Some((receipt, claim)))
}
/// 依赖准备收据里的失败码。
///
/// 收据只存机器码,但也不能把任何失败都写成同一个通用码——那等于把这条诊断线索作废。
/// 这里从宿主错误里取出真正的码(`Web 新项目依赖准备失败:web-build-timeout;…` 取
/// `web-build-timeout`,裸码原样保留),只有确实拿不到码时才落回专用兜底。
fn scaffold_failure_code(error: &str) -> String {
let candidate = error
.split(|ch: char| !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'))
.find(|token| {
token.len() >= 4
&& token.chars().any(|ch| ch.is_ascii_lowercase())
&& token.chars().any(|ch| ch == '-')
});
candidate
.unwrap_or("web-scaffold-preparation-failed")
.to_string()
}
pub(crate) async fn prepare_new_web_project_at(
root: &Path,
creation_type: Option<&str>,
@@ -457,7 +515,7 @@ pub(crate) async fn prepare_new_web_project_at(
receipt.last_failure_code = result
.as_ref()
.err()
.map(|_| "web-scaffold-preparation-failed".into());
.map(|error| scaffold_failure_code(error));
if result.is_ok() {
let entry = fs::read(ordinary_path(root, "game/dist/index.html")?)
.map_err(|_| "web-scaffold-dist-missing")?;
@@ -474,6 +532,78 @@ pub(crate) async fn prepare_new_web_project_at(
mod tests {
use super::*;
#[test]
fn browser_validation_failures_keep_their_own_reason_codes() {
assert_eq!(
browser_validation_failure_code(
"未发现可用的 Google Chrome、Chromium 或 Microsoft Edge"
),
"web-preflight-browser-missing"
);
assert_eq!(
browser_validation_failure_code("启动浏览器超时"),
"web-preflight-browser-launch-timeout"
);
assert_eq!(
browser_validation_failure_code("启动浏览器失败:2"),
"web-preflight-browser-launch-failed"
);
assert_eq!(
browser_validation_failure_code("宿主已停止本轮浏览器验证"),
"web-preflight-cancelled"
);
assert_eq!(
browser_validation_failure_code(
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程"
),
"web-preflight-browser-cleanup-failed"
);
assert_eq!(
browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"),
"web-preflight-browser-environment-failed"
);
assert_eq!(
browser_validation_failure_code("evidenceRoot 必须是真实目录且不能是符号链接"),
"web-preflight-evidence-failed"
);
assert_eq!(
browser_validation_failure_code("只允许带显式端口的 http://127.0.0.1 预览 URL"),
"web-preflight-input-invalid"
);
assert_eq!(
browser_validation_failure_code("desktop 页面导航失败:timeout"),
"web-preflight-page-check-failed"
);
// 未识别的原因必须落回专用兜底码,不能借上面任何一个具体子系统码。
assert_eq!(
browser_validation_failure_code("某种尚未识别的新失败"),
"web-preflight-unclassified"
);
}
#[test]
fn scaffold_receipt_keeps_the_real_failure_code() {
assert_eq!(
scaffold_failure_code("web-build-timeout"),
"web-build-timeout"
);
assert_eq!(
scaffold_failure_code("Web 新项目依赖准备失败:web-build-command-failed;尚未启动生成"),
"web-build-command-failed"
);
assert_eq!(
scaffold_failure_code(
"Web 游戏环境预检未通过:web-preflight-browser-missing;尚未启动生成"
),
"web-preflight-browser-missing"
);
// 拿不到机器码时才使用专用兜底。
assert_eq!(
scaffold_failure_code("没有机器码的中文说明"),
"web-scaffold-preparation-failed"
);
}
#[test]
fn only_new_unmodified_scaffolds_receive_a_preparation_receipt() {
let root = tempfile::tempdir().unwrap();
@@ -16,6 +16,10 @@ fn default_codex_agent_mode() -> String {
"codex".to_string()
}
fn default_model_protocol() -> String {
"openai_responses".to_string()
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireModelSummary {
@@ -23,6 +27,9 @@ struct WireModelSummary {
display_name: String,
#[serde(default = "default_codex_agent_mode")]
agent_mode: String,
/// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。
#[serde(default = "default_model_protocol")]
protocol: String,
}
#[derive(Debug, Deserialize)]
@@ -56,6 +63,7 @@ fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse {
id: model.id,
display_name: model.display_name,
agent_mode: model.agent_mode,
protocol: model.protocol,
})
.collect(),
revision: catalog.revision,
@@ -813,6 +813,9 @@ struct GameCreatorAppConfigFile {
selected_model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_is_default: Option<bool>,
/// 所选模型的上游线协议,与 `agent_mode`(执行器)正交。
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_protocol: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
@@ -874,6 +877,9 @@ struct GameCreatorAppConfig {
selected_model_id: String,
#[serde(default)]
selected_model_is_default: bool,
/// 所选模型的上游线协议;缺省按执行器推导。
#[serde(default)]
selected_model_protocol: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -1385,6 +1391,7 @@ impl Default for GameCreatorAppConfig {
editor_api: GameCreatorEditorApiConfig::default(),
selected_model_id: String::new(),
selected_model_is_default: false,
selected_model_protocol: None,
}
}
}
@@ -38,9 +38,9 @@ fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials(
write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap();
let mut config = read_game_creator_app_config().unwrap().config;
assert_eq!(config.llm.model, "a/v1");
let selected = select_game_creator_model("b:v2".into(), false, None).unwrap();
let selected = select_game_creator_model("b:v2".into(), false, None, None).unwrap();
assert_eq!(selected.config.llm.model, "b:v2");
assert!(select_game_creator_model("not-listed".into(), false, None).is_err());
assert!(select_game_creator_model("not-listed".into(), false, None, None).is_err());
config.llm.visible_models = vec!["b:v2".into()];
config.llm.api_key = "changed-fixture-key".into();
write_game_creator_app_config(config).unwrap();
@@ -817,6 +817,7 @@ fn app_config_commands_write_runtime_config_file() {
agent_llm,
selected_model_id: "default".to_string(),
selected_model_is_default: false,
selected_model_protocol: None,
})
.expect("write runtime config");
@@ -964,7 +965,7 @@ fn app_config_model_selection_only_updates_model_overlay() {
)
.expect("write overlay");
let saved =
select_game_creator_model("new-model".to_string(), true, None).expect("select model");
select_game_creator_model("new-model".to_string(), true, None, None).expect("select model");
let effective = load_game_creator_app_config().expect("effective config");
assert_eq!(saved.config.selected_model_id, "new-model");
assert_eq!(effective.selected_model_id, "new-model");
@@ -986,13 +987,13 @@ fn model_selection_default_flag_round_trips() {
fs::create_dir_all(&root).expect("runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let saved =
select_game_creator_model("quality".to_string(), true, None).expect("select default model");
let saved = select_game_creator_model("quality".to_string(), true, None, None)
.expect("select default model");
assert_eq!(saved.config.selected_model_id, "quality");
assert!(saved.config.selected_model_is_default);
let saved =
select_game_creator_model("fast".to_string(), false, None).expect("select explicit model");
let saved = select_game_creator_model("fast".to_string(), false, None, None)
.expect("select explicit model");
assert_eq!(saved.config.selected_model_id, "fast");
assert!(!saved.config.selected_model_is_default);
@@ -1021,6 +1022,7 @@ fn app_config_write_rejects_invalid_api_kind() {
agent_llm: BTreeMap::new(),
selected_model_id: "default".to_string(),
selected_model_is_default: false,
selected_model_protocol: None,
});
assert!(result
@@ -1048,6 +1050,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() {
agent_llm: BTreeMap::new(),
selected_model_id: "default".to_string(),
selected_model_is_default: false,
selected_model_protocol: None,
});
assert!(result
@@ -1075,6 +1078,7 @@ fn app_config_write_rejects_too_small_request_timeout() {
agent_llm: BTreeMap::new(),
selected_model_id: "default".to_string(),
selected_model_is_default: false,
selected_model_protocol: None,
});
assert!(result
@@ -0,0 +1,116 @@
/**
* 首页自动创建失败的用户可见文案与证据出口。
*
* 两条硬约束:
*
* 1. 首页状态区只显示**本模块算出来的**文案。宿主返回的原始文本(Provider 英文、IPC 报错、
* 测试夹具字符串)一律不上屏,只作为证据写进 `application.log`;原来的写法把整段底层文本
* 透传上屏,或者反过来把它覆盖成通用文案,两种都不对。
* 2. 兜底必须是专门的兜底文案,不能借用某个具体原因冒充(把 IPC 断连说成「环境预检未通过」、
* 把已经建好的工作区说成「创建未完成」都属此类)。宿主给了机器码就显示机器码,给不出就
* 落回本模块的专用兜底,并按「工作区是否已经建好」分成两条,避免用户重做一遍。
*/
/** 未分类失败的专用兜底文案。 */
export const HOME_CREATION_UNCLASSIFIED_FAILURE = '创建未完成,请重试';
export const HOME_CREATION_AFTER_PROJECT_FAILURE =
'工作区已创建,但未能进入项目开发;请从「已创建的工作区」打开后重试。';
export const HOME_CREATION_IPC_FAILURE =
'与客户端本体的连接中断,创建未完成;请重启客户端后重试。';
export const HOME_CREATION_AFTER_PROJECT_IPC_FAILURE =
'工作区已创建;与客户端本体的连接中断,未能进入项目开发;请重启客户端后从「已创建的工作区」打开。';
/** WebView / Tauri 调用通道自身失联时的固定形状。 */
const HOME_CREATION_TRANSPORT_PATTERNS = [
'failed to fetch',
'ipc custom protocol failed',
'window.__tauri__',
] as const;
/** 宿主错误里的机器码(`project-revision-conflict`、`web-build-timeout` 这类连字符标识)。 */
const HOME_CREATION_HOST_CODE = /[a-z][a-z0-9]*(?:-[a-z0-9]+)+/;
/**
* 已经整理好用户文案的创建失败。
*
* `diagnostic` 保留宿主原始文本,只用于日志与诊断;因为它可能包含 Provider、路径或凭据文本,
* 不允许直接进入首页状态区。
*/
export class HomeCreationFailure extends Error {
readonly userMessage: string;
readonly diagnostic: string;
constructor(userMessage: string, diagnostic: string) {
super(userMessage);
this.name = 'HomeCreationFailure';
this.userMessage = userMessage;
this.diagnostic = diagnostic;
}
}
export function homeCreationDiagnosticText(error: unknown): string {
if (error instanceof Error) return error.message.trim();
if (typeof error === 'string') return error.trim();
return '';
}
function isHomeCreationTransportText(text: string): boolean {
const normalized = text.toLowerCase();
return HOME_CREATION_TRANSPORT_PATTERNS.some((pattern) =>
normalized.includes(pattern),
);
}
/**
* 由宿主错误算出用户可见文案。
*
* `projectCreated` 必须由调用方按自己掌握的事实传入:工作区已经建好时,文案要保留这个事实
* 并指向「已创建的工作区」恢复入口。
*/
export function homeCreationFailureMessage(
error: unknown,
options: { projectCreated?: boolean } = {},
): string {
const projectCreated = options.projectCreated === true;
const text = homeCreationDiagnosticText(error);
if (isHomeCreationTransportText(text)) {
return projectCreated
? HOME_CREATION_AFTER_PROJECT_IPC_FAILURE
: HOME_CREATION_IPC_FAILURE;
}
const code = HOME_CREATION_HOST_CODE.exec(text)?.[0];
if (code) {
return projectCreated
? `工作区已创建;未能进入项目开发(${code});请从「已创建的工作区」打开后重试。`
: `创建未完成(${code});请重试。`;
}
return projectCreated
? HOME_CREATION_AFTER_PROJECT_FAILURE
: HOME_CREATION_UNCLASSIFIED_FAILURE;
}
/** 包装成带证据的创建失败;控制器负责把 `diagnostic` 写进日志后再抛出。 */
export function createHomeCreationFailure(
error: unknown,
options: { projectCreated?: boolean } = {},
): HomeCreationFailure {
return new HomeCreationFailure(
homeCreationFailureMessage(error, options),
homeCreationDiagnosticText(error),
);
}
/**
* 首页出口:只认控制器包装过的文案。
*
* 走到这里却不是 `HomeCreationFailure`,说明失败发生在控制器之外,只能落回专用兜底——
* 绝不把未知文本原样渲染到状态区。
*/
export function resolveHomeCreationFailureMessage(error: unknown): string {
return error instanceof HomeCreationFailure
? error.userMessage
: HOME_CREATION_UNCLASSIFIED_FAILURE;
}
@@ -1,8 +1,14 @@
import type { ProjectStartMode, TauriInvoke } from '../../app/types';
import type { HomeCreationType } from '../../view/home/useHomeDraftStore';
/**
* 宿主返回 blocked 但没有携带原因码时的专用兜底文案。
*
* 不能像旧文案那样写「请检查 Node/npm 或浏览器」:那是三个未被证实的候选原因,
* 用户照它排查会白费力气,而真正的宿主原因码在上面的分支里已经会原样显示。
*/
export const HOME_WEB_PREFLIGHT_FAILURE =
'Web 游戏环境预检未通过,请检查 Node/npm 或浏览器后重试;尚未开始生成。';
'Web 游戏环境预检未通过,宿主没有返回具体原因码;尚未开始生成。';
export const HOME_WEB_PREFLIGHT_IPC_FAILURE =
'Web 游戏环境预检调用失败,客户端连接异常;请重启客户端后重试,尚未开始生成。';
@@ -36,6 +36,7 @@ import {
createClientOperation,
transitionClientOperation,
} from '../../services/clientOperation';
import { appendWebviewLog } from '../../services/errorReporting';
import type {
HomeAttachmentDraft,
HomeCreationType,
@@ -55,6 +56,10 @@ import {
} from '../project-summary/projectSummary';
import { importDesignFiles } from '../project-workspace/importDesignFiles';
import { clearAclElevationDenials } from './aclElevation';
import {
createHomeCreationFailure,
type HomeCreationFailure,
} from './homeCreationFailure';
import {
ensureHomeWebCreationEnvironment,
homeWebPreflightErrorMessage,
@@ -416,6 +421,24 @@ export function useHomeProjectCreation({
resetLauncherHomeDraft();
}
/**
* 创建失败的统一收口。
*
* 用户可见文案由 `createHomeCreationFailure` 按真实原因算出(宿主机器码 / 调用通道失联 /
* 专用未分类兜底),宿主原始文本只写进 `application.log` 作为证据,不渲染到首页状态区。
*/
function failHomeCreation(
error: unknown,
projectCreated: boolean,
): HomeCreationFailure {
const failure = createHomeCreationFailure(error, { projectCreated });
if (failure.diagnostic) {
appendWebviewLog('error', [`[home-creation] ${failure.diagnostic}`]);
}
setStatus(failure.userMessage);
return failure;
}
async function createHomeProjectFromDirectory(
nextProjectPath: string,
creationType: HomeCreationType,
@@ -439,6 +462,8 @@ export function useHomeProjectCreation({
? beginProjectOpenAnalytics(invoke, 'create')
: analytics;
setStatus('正在创建项目');
// 兜底文案必须区分「还没建」和「建好了但没进去」:只有 init 成功后才置位。
let createdProjectPath: string | null = null;
try {
if (!skipNonEmptyCheck) {
const nonEmpty = await invoke<boolean>(
@@ -471,29 +496,23 @@ export function useHomeProjectCreation({
name: projectNameFromPath(trimmedProjectPath),
},
);
try {
await enterCreatedHomeProject(
invoke,
result,
creationType,
prompt,
attachments,
startMode,
analytics,
);
setStatus('已创建项目,正在开始智能创作');
} catch (error) {
throw new Error(
`项目已创建;首条需求投递失败:${
error instanceof Error ? error.message : String(error)
}`,
);
}
createdProjectPath = result.projectPath;
// 这里失败时项目目录已经建好:文案由外层按「已建好」分类,不拼底层文本,
// 也不指认具体是哪一步失败(运行时切换、附件导入、进入工作台都可能抛)。
await enterCreatedHomeProject(
invoke,
result,
creationType,
prompt,
attachments,
startMode,
analytics,
);
setStatus('已创建项目,正在开始智能创作');
return '已创建项目并进入项目开发';
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(message);
throw error;
// 目录已经建好时保留「已创建」这一事实,避免用户重做一遍。
throw failHomeCreation(error, createdProjectPath !== null);
}
}
@@ -955,17 +974,14 @@ export function useHomeProjectCreation({
return '已创建工作区并进入项目开发';
} catch (error) {
// 项目目录已经建好了:把它登记进最近项目,用户可以直接打开,不必重新建一遍。
// 文案交给外层统一按「已建好」分类,这里不拼底层文本。
rememberRecentWorkspace(result.projectPath);
setHomeCreationOperation(
transitionClientOperation(operation, 'retryable-failure', {
scope: { projectPath: result.projectPath },
}),
);
const message = `工作区已创建;首条需求投递失败:${
error instanceof Error ? error.message : String(error)
}`;
setStatus(message);
throw new Error(message);
throw error;
}
} catch (error) {
setHomeCreationOperation(
@@ -978,7 +994,7 @@ export function useHomeProjectCreation({
if (createdProjectPath) {
rememberRecentWorkspace(createdProjectPath);
}
throw error;
throw failHomeCreation(error, Boolean(createdProjectPath));
} finally {
if (watchdogId !== undefined) {
window.clearTimeout(watchdogId);
@@ -19,6 +19,22 @@ import {
refreshLlmModelCatalog,
} from '../../services/llmModelCatalog';
/**
* 线协议与执行器正交;目录没下发时按执行器推导默认协议,保证发往后端的组合始终合法。
*/
function resolveModelProtocol(
model: { agentMode?: 'codex' | 'cc'; protocol?: string } | undefined,
): 'openai_responses' | 'openai_chat' | 'anthropic' {
if (
model?.protocol === 'openai_responses' ||
model?.protocol === 'openai_chat' ||
model?.protocol === 'anthropic'
) {
return model.protocol;
}
return model?.agentMode === 'cc' ? 'anthropic' : 'openai_responses';
}
export type ConversationModelSelectHandle = {
/** 发送前校验:刷新目录,并在所选模型已停用/删除时回退默认模型。 */
ensureUsable: () => Promise<boolean>;
@@ -174,6 +190,9 @@ export function ConversationModelSelect({
agentMode:
catalog.models.find((model) => model.id === next)?.agentMode ??
'codex',
protocol: resolveModelProtocol(
catalog.models.find((model) => model.id === next),
),
}),
);
} catch {
@@ -341,11 +360,15 @@ export function ConversationModelSelect({
if (!invoke) throw new Error('Native host unavailable');
const agentMode =
models.find((model) => model.id === id)?.agentMode ?? 'codex';
const protocol = resolveModelProtocol(
models.find((model) => model.id === id),
);
const result = await queueConfigWrite(() =>
invoke<GameCreatorAppConfigView>('select_game_creator_model', {
modelId: id,
isDefault: false,
agentMode,
protocol,
}),
);
if (result.config.selectedModelId !== id)
@@ -36,7 +36,7 @@ import {
type GameCreatorLlmReasoningEffort,
gameCreatorLlmReasoningEfforts,
} from '../../app/types';
import { checkForAppUpdate } from '../../services/appUpdate';
import { checkForAppUpdateResult } from '../../services/appUpdate';
import { notifyLlmConfigChanged } from '../../services/llmModelCatalog';
import {
AGC_PLUGIN_STATE_CHANGED_EVENT,
@@ -724,13 +724,21 @@ export function RuntimeConfigDialog({
setAppUpdateChecking(true);
setAppUpdateStatus('正在检查更新…');
try {
const update = await checkForAppUpdate({ force: true });
setAppUpdateStatus(
update
? `发现新版本 v${update.version},可在更新弹窗中下载`
: '当前已是最新版本',
);
} catch {
const result = await checkForAppUpdateResult({ force: true });
if (result.status === 'available') {
setAppUpdateStatus(
`发现新版本 v${result.update.version},可在更新弹窗中下载`,
);
} else if (result.status === 'current') {
setAppUpdateStatus('当前已是最新版本');
} else if (result.status === 'failed') {
// 检查失败必须如实说明,不能显示成「当前已是最新版本」。
setAppUpdateStatus(`检查更新失败:${result.message}`);
} else {
setAppUpdateStatus('当前渠道未启用更新检查');
}
} catch (error) {
// 结果型入口已经收口了检查失败;这里只兜住调用方自身的意外抛错。
setAppUpdateStatus('检查更新失败,请稍后重试');
} finally {
setAppUpdateChecking(false);
@@ -19,8 +19,17 @@ export type AppUpdateProgress = {
totalBytes?: number;
};
/**
* 更新检查结果。必须能区分「有更新 / 已是最新 / 检查失败」,失败不得冒充「已是最新」。
*/
export type AppUpdateCheckResult =
| { status: 'available'; update: AppUpdateInfo }
| { status: 'current' }
| { status: 'failed'; message: string }
| { status: 'disabled' };
let pendingUpdate: Update | null = null;
let updateCheckPromise: Promise<AppUpdateInfo | null> | null = null;
let updateCheckResultPromise: Promise<AppUpdateCheckResult> | null = null;
const updateListeners = new Set<(update: AppUpdateInfo | null) => void>();
function toAppUpdateInfo(update: Update): AppUpdateInfo {
@@ -31,29 +40,53 @@ function toAppUpdateInfo(update: Update): AppUpdateInfo {
};
}
async function runAppUpdateCheck(): Promise<AppUpdateInfo | null> {
function appUpdateFailureMessage(error: unknown): string {
const text =
error instanceof Error ? error.message.trim() : String(error ?? '').trim();
return text || '更新服务未返回原因';
}
async function runAppUpdateCheck(): Promise<AppUpdateCheckResult> {
try {
const update = await check();
pendingUpdate = update;
const info = update ? toAppUpdateInfo(update) : null;
updateListeners.forEach((listener) => listener(info));
return info;
} catch {
// 清单 404、渠道缺少当前平台条目、网络或签名错误都按“无更新”收口,不阻塞启动。
return info ? { status: 'available', update: info } : { status: 'current' };
} catch (error) {
// 启动期仍然不阻塞(调用方是后台提示,不是用户操作),但这次检查必须记为失败:
// 清单 404、渠道缺少当前平台条目、网络或签名错误都不能再冒充「已是最新」。
pendingUpdate = null;
return null;
return { status: 'failed', message: appUpdateFailureMessage(error) };
}
}
/** 同一客户端生命周期内只请求一次清单;`force` 供「关于」页手动检查使用。 */
export function checkForAppUpdate(
/**
* 同一客户端生命周期内只请求一次清单;`force` 供「关于」页手动检查使用。
*
* 手动检查必须用这个结果型入口:旧入口把失败也返回 null,界面就会显示「当前已是最新版本」。
*/
export async function checkForAppUpdateResult(
options: { force?: boolean } = {},
): Promise<AppUpdateCheckResult> {
// 开发态(`agc` 启动)默认关闭更新检查:不请求清单,也不显示更新入口。
if (!appUpdateCheckEnabled) return { status: 'disabled' };
if (options.force) updateCheckResultPromise = null;
updateCheckResultPromise ??= runAppUpdateCheck();
return updateCheckResultPromise;
}
/**
* 只关心「有没有更新」的投影,供启动期更新提示使用。
*
* 失败在这里仍然是 null(不阻塞启动)。**不要**再用这个 null 去显示「已是最新」——
* 那需要 `checkForAppUpdateResult()`。
*/
export async function checkForAppUpdate(
options: { force?: boolean } = {},
): Promise<AppUpdateInfo | null> {
// 开发态(`agc` 启动)默认关闭更新检查:不请求清单,也不显示更新入口。
if (!appUpdateCheckEnabled) return Promise.resolve(null);
if (options.force) updateCheckPromise = null;
updateCheckPromise ??= runAppUpdateCheck();
return updateCheckPromise;
const result = await checkForAppUpdateResult(options);
return result.status === 'available' ? result.update : null;
}
export function subscribeToAppUpdate(
@@ -103,5 +136,5 @@ function restartAppAfterUpdate() {
export function resetAppUpdateCheckForTests() {
pendingUpdate = null;
updateCheckPromise = null;
updateCheckResultPromise = null;
}
@@ -3,7 +3,10 @@ import { resolveTauriInvoke } from '../app/tauri';
export type ClientLlmModel = {
displayName: string;
id: string;
agentMode: 'codex' | 'cc' | 'anthropic';
/** 执行器:codex 或 cc。线协议是与之正交的独立维度。 */
agentMode: 'codex' | 'cc';
/** 上游线协议;服务端未下发时按执行器的默认协议解释。 */
protocol?: 'openai_responses' | 'openai_chat' | 'anthropic';
};
export type ClientLlmModelCatalog = {
@@ -13,6 +13,7 @@ import { useRef, useState } from 'react';
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
import type { ProjectStartMode } from '../../app/types';
import { resolveHomeCreationFailureMessage } from '../../features/app-shell/homeCreationFailure';
import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect';
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
import RichInputArea, { UploadButton } from './components/RichInputArea';
@@ -192,10 +193,10 @@ export default function HomeView({
startMode,
),
);
} catch {
// 详细错误已由工作区创建控制器记录;首页只显示稳定的用户可执行提示,
// 避免把底层 Provider/IPC 文本直接渲染到首页状态区。
onStatusChange('创建未完成,请重试');
} catch (error) {
// 控制器已经给出用户可见文案(宿主机器码、IPC 提示或专用兜底);首页只透传它,
// 既不覆盖成通用文案,也不把宿主原始文本渲染到状态区。
onStatusChange(resolveHomeCreationFailureMessage(error));
} finally {
homeCreationBusyRef.current = false;
setHomeCreationBusy(false);
@@ -1,31 +1,36 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { Copy } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
type CopyState = 'idle' | 'copied' | 'failed';
export function UiEditorCopyPathButton({
relativePath,
}: {
relativePath: string;
}) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
useEffect(() => setCopyState('idle'), [relativePath]);
// 复制态跟着具体路径走:路径变了就等于回到未复制。这里不用 effect 事后重置,
// 因为延迟执行的 effect 会晚于复制结果落盘,把「已复制」覆盖回未复制。
const [copyState, setCopyState] = useState<{
relativePath: string;
value: CopyState;
}>({ relativePath, value: 'idle' });
const value =
copyState.relativePath === relativePath ? copyState.value : 'idle';
async function copyPath() {
setCopyState('idle');
setCopyState({ relativePath, value: 'idle' });
try {
await writeText(relativePath);
setCopyState('copied');
setCopyState({ relativePath, value: 'copied' });
} catch {
setCopyState('failed');
setCopyState({ relativePath, value: 'failed' });
}
}
return (
<>
{copyState === 'failed' ? (
{value === 'failed' ? (
<p className="mt-2 text-xs text-red-600" role="alert">
复制失败,请手动复制路径。
</p>
@@ -36,7 +41,7 @@ export function UiEditorCopyPathButton({
onClick={() => void copyPath()}
>
<Copy size={14} aria-hidden="true" />
{copyState === 'copied' ? '已复制' : '复制路径'}
{value === 'copied' ? '已复制' : '复制路径'}
</button>
</>
);

Some files were not shown because too many files have changed in this diff Show More