Merge branch 'master' into fix/548
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m22s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m20s
Project CI / Frontend tests (pull_request) Successful in 1m59s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m42s
Project CI / Native shell tests (pull_request) Successful in 7m24s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m33s
Project CI / Repository checks (pull_request) Failing after 3m17s
Project CI / Backend tests (pull_request) Failing after 4m16s

This commit is contained in:
2026-10-03 18:16:33 +08:00
128 changed files with 9876 additions and 155 deletions
+7
View File
@@ -49,6 +49,7 @@ import type {
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminPaymentOrderListResponse,
AdminProjectSnapshotChannelsResponse,
AdminProjectSnapshotListQuery,
AdminProjectSnapshotListResponse,
@@ -801,6 +802,12 @@ export function listAdminRechargeOrders(
);
}
export function listAdminPaymentOrders(token: string) {
return request<AdminPaymentOrderListResponse>('/admin/api/payment/orders', {
token,
});
}
export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) {
return request<AdminUserDetailResponse>(
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
+33
View File
@@ -1054,6 +1054,39 @@ export interface AdminWechatPaymentCheckPayload {
knownRefundsRefreshed: number;
}
export interface AdminPaymentOrderEntry {
orderId: string;
appId: string;
ownerUserId: string;
merchantOrderId: string;
title: string;
amountCents: number;
currency: string;
provider: string;
providerTradeNo: string | null;
status: string;
createdAt: string;
expiresAt: string;
paidAt: string | null;
}
export interface AdminPaymentOrderListResponse {
entries: AdminPaymentOrderEntry[];
webhooks: AdminPaymentWebhookEntry[];
}
export interface AdminPaymentWebhookEntry {
deliveryId: string;
orderId: string;
appId: string;
callbackUrl: string;
status: string;
attemptCount: number;
availableAt: string;
lastErrorMessage: string | null;
updatedAt: string;
}
export interface AdminRechargeRefundPreviewResponse {
order: AdminRechargeOrderEntryPayload;
paymentCheck: AdminWechatPaymentCheckPayload;
+7
View File
@@ -36,6 +36,7 @@ import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
import { AdminPaymentOrderPage } from '../pages/AdminPaymentOrderPage';
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
@@ -297,6 +298,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'payment-orders' ? (
<AdminPaymentOrderPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-generation-pricing' ? (
<AdminEditorGenerationPricingPage
token={token}
+1
View File
@@ -51,6 +51,7 @@ const routeIcons = {
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'recharge-orders': ReceiptText,
'payment-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
+2
View File
@@ -14,6 +14,7 @@ export type AdminRouteId =
| 'tasks'
| 'recharge-products'
| 'recharge-orders'
| 'payment-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
@@ -53,6 +54,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'tasks', label: '任务配置', hash: '#tasks' },
{ id: 'recharge-products', label: '充值商品', hash: '#recharge-products' },
{ id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders' },
{ id: 'payment-orders', label: '支付订单', hash: '#payment-orders' },
{
id: 'editor-generation-pricing',
label: '模型定价',
@@ -0,0 +1,198 @@
import {
AdminEmptyState,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminStatusPill,
} from '@genarrative/shared/components';
import { useCallback, useEffect, useState } from 'react';
import {
formatAdminApiError,
listAdminPaymentOrders,
} from '../api/adminApiClient';
import type {
AdminPaymentOrderEntry,
AdminPaymentWebhookEntry,
} from '../api/adminApiTypes';
import { handlePageError } from './pageUtils';
type AdminPaymentOrderPageProps = {
token: string;
onUnauthorized: (message?: string) => void;
};
function statusTone(status: string): 'ok' | 'pending' | 'error' {
if (status === 'paid') return 'ok';
if (status === 'closed' || status === 'expired') return 'error';
return 'pending';
}
export function AdminPaymentOrderPage({
token,
onUnauthorized,
}: AdminPaymentOrderPageProps) {
const [entries, setEntries] = useState<AdminPaymentOrderEntry[]>([]);
const [webhooks, setWebhooks] = useState<AdminPaymentWebhookEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(() => {
setIsLoading(true);
setError('');
void listAdminPaymentOrders(token)
.then((response) => {
setEntries(response.entries ?? []);
setWebhooks(response.webhooks ?? []);
})
.catch((nextError: unknown) => {
handlePageError(nextError, onUnauthorized, (message) =>
setError(message || formatAdminApiError(nextError)),
);
})
.finally(() => setIsLoading(false));
}, [onUnauthorized, token]);
useEffect(() => {
load();
}, [load]);
return (
<AdminPage wide>
<AdminPageHeading
title="支付订单"
description="平台统一收款的外部产品订单与到账状态。"
actions={
<button
className="admin-button admin-button--secondary"
type="button"
onClick={load}
>
刷新
</button>
}
/>
<AdminListPanel
title="外部回调投递"
count={`${webhooks.length} 条`}
busy={isLoading}
emptyText={<AdminEmptyState>暂无外部回调记录。</AdminEmptyState>}
columns={[
{
key: 'delivery',
label: '投递',
render: (entry) => (
<>
<strong>{entry.deliveryId}</strong>
<small className="admin-muted-block">{entry.orderId}</small>
</>
),
},
{ key: 'app', label: '应用', render: (entry) => entry.appId },
{
key: 'callback',
label: '回调地址',
render: (entry) => entry.callbackUrl,
},
{
key: 'status',
label: '状态',
render: (entry) => (
<AdminStatusPill
tone={
entry.status === 'delivered'
? 'ok'
: entry.status === 'failed'
? 'error'
: 'pending'
}
>
{entry.status}
</AdminStatusPill>
),
},
{
key: 'attempts',
label: '尝试次数',
render: (entry) => entry.attemptCount,
},
{
key: 'error',
label: '最近错误',
render: (entry) => entry.lastErrorMessage ?? '-',
},
{
key: 'updated',
label: '更新时间',
render: (entry) => entry.updatedAt,
},
]}
rows={webhooks}
rowKey={(entry) => entry.deliveryId}
/>
{error ? (
<div className="admin-alert admin-alert--error">{error}</div>
) : null}
<AdminListPanel
title="订单记录"
count={`${entries.length} 条`}
busy={isLoading}
emptyText={
<AdminEmptyState>
暂无支付订单,外部产品创建订单后会显示在这里。
</AdminEmptyState>
}
columns={[
{
key: 'order',
label: '订单',
render: (entry) => (
<>
<strong>{entry.merchantOrderId}</strong>
<small className="admin-muted-block">{entry.orderId}</small>
</>
),
},
{
key: 'app',
label: '应用',
render: (entry) => (
<>
<strong>{entry.title}</strong>
<small className="admin-muted-block">{entry.appId}</small>
</>
),
},
{
key: 'amount',
label: '金额',
render: (entry) =>
`${entry.currency} ${(entry.amountCents / 100).toFixed(2)}`,
},
{ key: 'provider', label: '渠道', render: (entry) => entry.provider },
{
key: 'status',
label: '状态',
render: (entry) => (
<AdminStatusPill tone={statusTone(entry.status)}>
{entry.status}
</AdminStatusPill>
),
},
{
key: 'created',
label: '创建时间',
render: (entry) => entry.createdAt,
},
{
key: 'paid',
label: '支付时间',
render: (entry) => entry.paidAt ?? '-',
},
]}
rows={entries}
rowKey={(entry) => entry.orderId}
/>
</AdminPage>
);
}
@@ -86,7 +86,7 @@ maud = "0.27.0"
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
[profile.dev]
opt-level = 0
@@ -45,9 +45,9 @@ pub mod tool;
pub(crate) use art_manifest::*;
use claude_code_cli::*;
pub(crate) use claude_code_cli::{
cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at,
direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error,
game_creator_claude_code_cli_version_identity,
cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error,
direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat,
game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity,
};
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
pub(crate) use codex_app_server::turn_error::*;
@@ -747,6 +747,46 @@ fn parse_usage(value: &serde_json::Value) -> Option<platform_llm::LlmTokenUsage>
})
}
/// 把 Claude Code/sidecar 的字符串失败投影到与 Codex app-server 相同的 LlmError 分类。
///
/// DirectProject 不能把上游 HTTP 错误统统包装成 Transport:那会把 429、401、5xx 等可识别
/// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` /
/// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。
pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError {
if let Some(status_code) = claude_code_failure_status_code(&detail) {
return platform_llm::LlmError::Upstream {
status_code,
message: detail,
};
}
if detail.contains("Claude Agent SDK sidecar 回合超时") {
return platform_llm::LlmError::Timeout { attempts: 1 };
}
if detail.contains("Claude Code 缺少最终回复") {
return platform_llm::LlmError::EmptyResponse;
}
if detail.contains("sidecar 输出不是有效 JSON") || detail.contains("stream-json 包含无效 JSON")
{
return platform_llm::LlmError::Deserialize(detail);
}
platform_llm::LlmError::Transport(detail)
}
fn claude_code_failure_status_code(detail: &str) -> Option<u16> {
["Request rejected (", "HTTP "].iter().find_map(|marker| {
let tail = detail.split_once(marker)?.1;
let digits = tail
.chars()
.take_while(|character| character.is_ascii_digit())
.collect::<String>();
if digits.len() != 3 {
return None;
}
let status_code = digits.parse::<u16>().ok()?;
(100..=599).contains(&status_code).then_some(status_code)
})
}
fn parse_claude_code_result(
stdout: &[u8],
request: &LlmRunRequest,
@@ -758,7 +798,7 @@ fn parse_claude_code_result(
let detail = claude_result_error_detail(&value)
.map(|detail| format!(":{detail}"))
.unwrap_or_default();
return Err(platform_llm::LlmError::Transport(format!(
return Err(claude_code_failure_to_llm_error(format!(
"Claude Code 返回失败终态{detail}"
)));
}
@@ -1156,6 +1196,29 @@ mod tests {
assert!(error.to_string().contains("Authentication failed"));
}
#[test]
fn claude_failure_reuses_codex_error_categories() {
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Code 返回失败终态:API Error: Request rejected (429)".into()
),
platform_llm::LlmError::Upstream {
status_code: 429,
..
}
));
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".into()
),
platform_llm::LlmError::Timeout { attempts: 1 }
));
assert!(matches!(
claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()),
platform_llm::LlmError::EmptyResponse
));
}
#[test]
fn parses_direct_stream_result_and_final_text() {
let mut observed = Vec::new();
@@ -5300,7 +5300,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
observer,
)
.await
.map_err(|detail| TurnError::from_model_call(&platform_llm::LlmError::Transport(detail)));
.map_err(|detail| {
TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail))
});
}
game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| {
TurnError::EnvironmentNotReady(EnvironmentNotReady {
@@ -304,7 +304,7 @@ impl ModelCallKind {
native,
} => match native {
Some(native) => native.is_retryable(),
None => *status_code >= 500,
None => matches!(*status_code, 401 | 408 | 429 | 500..=599),
},
Self::PaidCreditsInsufficient => false,
Self::EmptyResponse => false,
@@ -322,9 +322,16 @@ impl ModelCallKind {
}
Self::EmptyResponse => Some("模型未返回内容"),
Self::PayloadInvalid { .. } => Some("模型回执无法解析"),
Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => {
Self::RequestRejected { native } => {
native.as_ref().and_then(NativeKind::public_summary)
}
Self::UpstreamFailed {
status_code,
native,
} => native
.as_ref()
.and_then(NativeKind::public_summary)
.or_else(|| upstream_status_summary(*status_code)),
}
}
@@ -341,13 +348,40 @@ impl ModelCallKind {
Self::EmptyResponse | Self::PayloadInvalid { .. } => {
Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断")
}
Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => {
native.as_ref().and_then(NativeKind::recovery_hint)
}
Self::RequestRejected { native } => native.as_ref().and_then(NativeKind::recovery_hint),
Self::UpstreamFailed {
status_code,
native,
} => native
.as_ref()
.and_then(NativeKind::recovery_hint)
.or_else(|| upstream_status_recovery_hint(*status_code)),
}
}
}
fn upstream_status_summary(status_code: u16) -> Option<&'static str> {
Some(match status_code {
401 => "上游服务拒绝认证(HTTP 401)",
403 => "上游服务拒绝访问(HTTP 403)",
408 => "上游请求超时(HTTP 408)",
429 => "上游服务返回 HTTP 429",
500..=599 => "上游服务暂时不可用",
_ => return None,
})
}
fn upstream_status_recovery_hint(status_code: u16) -> Option<&'static str> {
Some(match status_code {
401 => "请重新登录陶泥儿后重试;如持续失败请检查项目诊断",
403 => "请检查当前账号的上游访问权限后重试;如持续失败请检查项目诊断",
408 => "上游请求超时,请稍后重试;如持续失败请检查项目诊断",
429 => "上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断",
500..=599 => "上游服务暂时不可用,请稍后重试;如持续失败请检查项目诊断",
_ => return None,
})
}
// ══════════════════════════════════════════════════════════════════════════════════════════
// 入队失败:`#[tauri::command]` 的 `Err`,这一轮没有开始也没有进队列
// ══════════════════════════════════════════════════════════════════════════════════════════
@@ -1327,6 +1361,31 @@ mod tests {
assert!(!projected.is_model_repairable());
}
#[test]
fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() {
let rate_limited = TurnError::from_model_call(&LlmError::Upstream {
status_code: 429,
message: "Claude Code 返回失败终态:API Error: Request rejected (429)".into(),
});
assert_eq!(rate_limited.public_summary(), Some("上游服务返回 HTTP 429"));
assert_eq!(
rate_limited.recovery_hint(),
Some("上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断")
);
assert!(rate_limited.is_retryable());
assert!(!rate_limited.is_model_repairable());
let unauthorized = TurnError::from_model_call(&LlmError::Upstream {
status_code: 401,
message: "Claude Code 返回失败终态:HTTP 401".into(),
});
assert_eq!(
unauthorized.public_summary(),
Some("上游服务拒绝认证(HTTP 401)")
);
assert!(unauthorized.is_retryable());
}
/// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。
#[test]
fn terminal_native_kinds_are_not_fed_back_to_the_model() {
@@ -567,6 +567,23 @@ pub(crate) fn upload_local_asset_at(
file_name: &str,
media_type: &str,
bytes: &[u8],
) -> Result<UploadLocalAssetResult, String> {
upload_local_asset_at_with_category(root, file_name, media_type, bytes, None)
}
/// 带**显式入口栏目**的上传:只给 GUI 工具栏上传用(前端 `targetCategory`)。
///
/// 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`),
/// 与栏目不是同一套词汇:在栏目画布里上传素材时,用户选定的栏目才是归属真相,
/// 否则素材会落进「待归类」、在上传它的那一栏里看不见。
/// 取值校验与失败关闭沿用 [`register_local_asset_entry_with_category`]:
/// 非法值报错、绝不回退到 kind 派生;其它调用方继续走 [`upload_local_asset_at`]。
pub(crate) fn upload_local_asset_at_with_category(
root: &Path,
file_name: &str,
media_type: &str,
bytes: &[u8],
target_category: Option<&str>,
) -> Result<UploadLocalAssetResult, String> {
if bytes.is_empty() {
return Err("上传文件不能为空".to_string());
@@ -584,7 +601,7 @@ pub(crate) fn upload_local_asset_at(
}
crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?;
register_local_asset_entry(
register_local_asset_entry_with_category(
root,
&relative_path,
uploaded_asset_kind(file_name, media_type),
@@ -602,6 +619,7 @@ pub(crate) fn upload_local_asset_at(
generation_kind: None,
reference_resource_ids: Vec::new(),
},
target_category,
)
}
@@ -2375,6 +2393,70 @@ mod tests {
);
}
/// Issue 359:工具栏上传带上入口栏目,素材才不会落进「待归类」。
///
/// 上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`;
/// 在栏目画布(如「角色与对象」)里上传时栏目才是归属真相。不传时保持既有行为,
/// 非法值失败关闭且不留下半成品登记。
#[test]
fn upload_registers_into_the_explicit_entry_category() {
fn upload_category(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory {
read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.into_iter()
.find(|asset| asset.id == asset_id)
.expect("uploaded asset is registered")
.category
}
let temporary = tempfile::tempdir().expect("tempdir");
let root = temporary.path();
crate::project::init_local_game_project_at(root, "upload-category-test", "上传入口栏目")
.expect("init project");
let uploaded = upload_local_asset_at_with_category(
root,
"hero.png",
"image/png",
b"png-bytes",
Some("character"),
)
.expect("upload with an entry category");
assert_eq!(
upload_category(root, &uploaded.id),
GameCreationAppAssetCategory::Character
);
let plain = upload_local_asset_at(root, "plain.png", "image/png", b"png-bytes")
.expect("upload without an entry category");
assert_eq!(
upload_category(root, &plain.id),
GameCreationAppAssetCategory::Unclassified
);
// 非法入口栏目(栏目侧伪值 `version` 不在枚举里)失败关闭:不新增登记。
let assets_before = read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.len();
assert!(upload_local_asset_at_with_category(
root,
"bad.png",
"image/png",
b"png-bytes",
Some("version"),
)
.is_err());
assert_eq!(
read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.len(),
assets_before
);
}
/// 画板导出推断出的 kind 必须已经是 canonical 值。
#[test]
fn canvas_export_asset_kind_is_always_canonical() {
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize};
use super::discovery::system_browser_candidates;
use super::process::{browser_process_temp_root, BROWSER_TEMP_PREFIX};
#[cfg(windows)]
use crate::process_session::WindowsProcessJob;
const OWNER_FILE: &str = "owner.json";
const OWNER_SCHEMA: &str = "agc-browser-process.v1";
@@ -34,13 +36,17 @@ pub(super) struct BrowserProcessOwner {
/// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写,
/// 该目录之后按旧残留只删不杀。
pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) {
pub(super) fn write_browser_process_owner(
temp_root: &Path,
browser_pid: u32,
executable: &Path,
) -> bool {
let identity =
crate::process_identity::external_agent_runner_process_start_identity(browser_pid);
let owner_identity =
crate::process_identity::external_agent_runner_process_start_identity(std::process::id());
let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else {
return;
return false;
};
let owner = BrowserProcessOwner {
schema_version: OWNER_SCHEMA.into(),
@@ -52,9 +58,9 @@ pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, ex
created_unix_ms: super::evidence::unix_time_ms(),
};
let Ok(bytes) = serde_json::to_vec_pretty(&owner) else {
return;
return false;
};
let _ = fs::write(temp_root.join(OWNER_FILE), bytes);
fs::write(temp_root.join(OWNER_FILE), bytes).is_ok()
}
fn read_browser_process_owner(dir: &Path) -> Option<BrowserProcessOwner> {
@@ -115,34 +121,142 @@ fn trusted_browser_executable(path: &Path) -> bool {
}
/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行
/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造
/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。
/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置
/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。
/// 文件,命令行还必须绑定同一份临时 Profile;只核对镜像会把其它 AGC
/// 实例或用户 Edge 误认成本轮浏览器。
#[cfg(windows)]
fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool {
// 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对
// 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。
fn windows_process_command_line(pid: u32) -> Option<String> {
use std::ffi::c_void;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ,
};
#[repr(C)]
struct UnicodeString {
length: u16,
maximum_length: u16,
buffer: *const u16,
}
#[link(name = "ntdll")]
unsafe extern "system" {
fn NtQueryInformationProcess(
process: *mut c_void,
information_class: u32,
information: *mut c_void,
information_length: u32,
return_length: *mut u32,
) -> i32;
}
let process = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) };
if process.is_null() {
return None;
}
let mut required = 0;
let _ =
unsafe { NtQueryInformationProcess(process, 60, std::ptr::null_mut(), 0, &mut required) };
if required == 0 || required > 64 * 1024 {
unsafe { CloseHandle(process) };
return None;
}
let mut buffer = vec![0u8; required as usize];
let status = unsafe {
NtQueryInformationProcess(
process,
60,
buffer.as_mut_ptr().cast(),
required,
&mut required,
)
};
unsafe { CloseHandle(process) };
if status != 0 {
return None;
}
let command_line = unsafe { &*(buffer.as_ptr().cast::<UnicodeString>()) };
if command_line.buffer.is_null() || command_line.length == 0 || command_line.length % 2 != 0 {
return None;
}
let text = unsafe {
std::slice::from_raw_parts(command_line.buffer, command_line.length as usize / 2)
};
String::from_utf16(text).ok()
}
#[cfg(windows)]
fn windows_command_line_arguments(command_line: &str) -> Option<Vec<String>> {
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::UI::Shell::CommandLineToArgvW;
let wide = command_line
.encode_utf16()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let mut count = 0;
let argv = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut count) };
if argv.is_null() || count < 0 {
if !argv.is_null() {
unsafe { LocalFree(argv.cast()) };
}
return None;
}
let result = unsafe { std::slice::from_raw_parts(argv, count as usize) }
.iter()
.map(|argument| {
if argument.is_null() {
return None;
}
let mut length = 0;
unsafe {
while *argument.add(length) != 0 {
length += 1;
}
String::from_utf16(std::slice::from_raw_parts(*argument, length)).ok()
}
})
.collect::<Option<Vec<_>>>();
unsafe { LocalFree(argv.cast()) };
result
}
#[cfg(windows)]
fn command_line_contains_profile(command_line: &str, profile_dir: &Path) -> bool {
const MARKER: &str = "--user-data-dir=";
let Some(arguments) = windows_command_line_arguments(command_line) else {
return false;
};
arguments.iter().enumerate().any(|(index, argument)| {
let value = argument.strip_prefix(MARKER).or_else(|| {
(argument == "--user-data-dir")
.then(|| arguments.get(index + 1).map(String::as_str))
.flatten()
});
value.is_some_and(|value| same_executable_path(Path::new(value), profile_dir))
})
}
#[cfg(windows)]
fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
};
// SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return false;
}
let mut buffer = [0u16; 1024];
let mut length = buffer.len() as u32;
// SAFETY: buffer 可写,length 先传入容量、返回实际长度。
let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) };
// SAFETY: handle 是本函数持有的合法句柄。
unsafe { CloseHandle(handle) };
if okay == 0 || length == 0 {
return false;
}
let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize]));
same_executable_path(&actual, expected)
&& windows_process_command_line(pid)
.is_some_and(|command_line| command_line_contains_profile(&command_line, profile_dir))
}
#[cfg(target_os = "linux")]
@@ -181,7 +295,10 @@ fn directory_owned_by_current_user(dir: &Path) -> bool {
.unwrap_or(false)
}
fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> {
pub(super) fn kill_browser_process_tree(
root_pid: u32,
expected_identity: &str,
) -> Result<(), String> {
#[cfg(windows)]
{
// 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判
@@ -190,15 +307,17 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(
for _ in 0..TREE_KILL_MAX_PASSES {
let snapshot = windows_process_snapshot()?;
let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid);
if root_present && process_identity_matches(root_pid, expected_identity) {
if root_present {
if !process_identity_matches(root_pid, expected_identity) {
return Err("browser-sweep-root-identity-mismatch".into());
}
// root 仍是目标浏览器:发现并追踪当前整棵子树。
for pid in windows_process_tree_pids_from(&snapshot, root_pid)? {
track_process(&mut tracked, pid);
}
} else if !root_present {
} else {
// root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父
// PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认
// 复用者的子进程。
// PID 上的孤儿子进程。PID 已被复用时不会进入此分支。
for (pid, ppid) in &snapshot {
if *ppid == root_pid {
track_process(&mut tracked, *pid);
@@ -323,6 +442,62 @@ fn windows_process_tree_pids_from(
Ok(tree)
}
#[cfg(windows)]
pub(super) fn ensure_browser_tree_in_job(
root_pid: u32,
root_identity: &str,
job: &WindowsProcessJob,
) -> Result<(), String> {
for _ in 0..TREE_KILL_MAX_PASSES {
let snapshot = windows_process_snapshot()?;
if !snapshot.iter().any(|(pid, _)| *pid == root_pid)
|| !process_identity_matches(root_pid, root_identity)
{
return Err("browser-job-root-identity-unconfirmed".into());
}
let tree = windows_process_tree_pids_from(&snapshot, root_pid)?;
if tree.is_empty() {
return Err("browser-job-root-exited-before-coverage".into());
}
let mut members = Vec::with_capacity(tree.len());
for pid in tree {
let identity = if pid == root_pid {
root_identity.to_string()
} else {
crate::process_identity::external_agent_runner_process_start_identity(pid)
.ok()
.flatten()
.ok_or_else(|| "browser-job-process-identity-unconfirmed".to_string())?
};
members.push((pid, identity));
}
for (pid, identity) in members {
if !process_identity_matches(pid, &identity) {
return Err("browser-job-process-identity-changed".into());
}
if !job.contains_pid(pid)? {
job.assign_pid(pid)?;
}
if !process_identity_matches(pid, &identity) {
return Err("browser-job-process-identity-changed".into());
}
}
let verified = windows_process_snapshot()?;
let verified_tree = windows_process_tree_pids_from(&verified, root_pid)?;
if process_identity_matches(root_pid, root_identity)
&& !verified_tree.is_empty()
&& verified_tree
.iter()
.all(|pid| job.contains_pid(*pid).unwrap_or(false))
{
return Ok(());
}
std::thread::sleep(TREE_KILL_PASS_INTERVAL);
}
Err("browser-job-tree-coverage-unconfirmed".into())
}
#[cfg(windows)]
fn windows_terminate_process(pid: u32) {
use windows_sys::Win32::Foundation::CloseHandle;
@@ -468,6 +643,18 @@ mod tests {
}
}
#[test]
fn owner_write_failure_leaves_no_partial_ownership_record() {
let root = tempfile::tempdir().unwrap();
let missing_root = root.path().join("missing");
assert!(!write_browser_process_owner(
&missing_root,
std::process::id(),
&std::env::current_exe().unwrap(),
));
assert!(!missing_root.join(OWNER_FILE).exists());
}
#[test]
fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() {
let root = tempfile::tempdir().unwrap();
@@ -548,6 +735,90 @@ mod tests {
assert!(trusted_browser_executable(&installed));
}
#[cfg(windows)]
#[test]
fn windows_browser_cleanup_requires_the_exact_profile_argument() {
let profile = Path::new(r#"C:\Users\tester\App Data\ga-browser-1\profile"#);
assert!(command_line_contains_profile(
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile""#,
profile
));
assert!(command_line_contains_profile(
r#""msedge.exe" "--user-data-dir=C:\Users\tester\App Data\ga-browser-1\profile""#,
profile
));
assert!(!command_line_contains_profile(
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-2\profile""#,
profile
));
assert!(!command_line_contains_profile(
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile-copy""#,
profile
));
}
#[cfg(windows)]
#[test]
fn browser_job_adopts_children_created_before_assignment() {
use std::process::{Command, Stdio};
let mut child = Command::new("cmd.exe")
.args([
"/c",
"ping",
"127.0.0.1",
"-n",
"60",
"&",
"ping",
"127.0.0.1",
"-n",
"60",
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn fixture");
let pid = child.id();
let identity = crate::process_identity::external_agent_runner_process_start_identity(pid)
.expect("fixture identity")
.expect("fixture identity present");
std::thread::sleep(Duration::from_millis(500));
let job = WindowsProcessJob::assign_std(&child).expect("assign fixture job");
ensure_browser_tree_in_job(pid, &identity, &job).expect("adopt fixture tree");
let snapshot = windows_process_snapshot().expect("snapshot after adoption");
let tree = windows_process_tree_pids_from(&snapshot, pid).expect("tree after adoption");
assert!(tree
.iter()
.all(|member| job.contains_pid(*member).expect("job membership")));
job.terminate().expect("terminate fixture job");
let _ = child.wait();
assert!(job.is_empty().expect("fixture job empty"));
}
#[cfg(windows)]
#[test]
fn kill_browser_process_tree_rejects_root_identity_mismatch() {
use std::process::{Command, Stdio};
let mut child = Command::new("cmd.exe")
.args(["/c", "ping", "127.0.0.1", "-n", "60"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn fixture");
let pid = child.id();
let result = kill_browser_process_tree(pid, "not-the-fixture-identity");
let _ = child.kill();
let _ = child.wait();
assert_eq!(
result.expect_err("identity mismatch must fail closed"),
"browser-sweep-root-identity-mismatch"
);
}
#[cfg(windows)]
#[test]
fn kill_browser_process_tree_reaps_fixture_tree() {
@@ -600,10 +871,8 @@ mod tests {
fn live_process_executable_matches_current_process_image() {
let exe = std::env::current_exe().unwrap();
let profile = Path::new("C:\\fixture\\ga-browser-x\\profile");
// Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程
// 不具备该标记,正例只在 Windows 断言。
#[cfg(windows)]
assert!(live_process_executable_matches(
// 当前测试进程不携带目标 Profile 标识,即使镜像一致也不能清理。
assert!(!live_process_executable_matches(
std::process::id(),
&exe,
profile
@@ -493,12 +493,19 @@ pub(crate) fn upload_local_asset(
file_name: String,
media_type: String,
bytes: Vec<u8>,
target_category: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.upload")?;
let _lock = acquire_project_write_lock(root, "asset.upload")?;
advance_agent_runtime_project_revision_locked(root)?;
upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes)
upload_local_asset_at_with_category(
root,
file_name.trim(),
media_type.trim(),
&bytes,
target_category.as_deref(),
)
}
#[tauri::command]
@@ -311,6 +311,13 @@ async fn host_npm(
fn browser_validation_failure_code(error: &str) -> &'static str {
if error.contains("未发现可用的") {
"web-preflight-browser-missing"
} else if error.starts_with("browser-recovery-") {
"web-preflight-browser-recovery-failed"
} else if error.contains("browser-temp-unavailable") || error.contains("browser-config-invalid")
{
"web-preflight-browser-environment-failed"
} else if error.starts_with("browser-launch-failed:") {
"web-preflight-browser-launch-failed"
} else if error.contains("启动浏览器超时") {
"web-preflight-browser-launch-timeout"
} else if error.contains("启动浏览器失败") {
@@ -342,6 +349,13 @@ fn browser_validation_failure_code(error: &str) -> &'static str {
}
}
fn browser_validation_diagnostic(error: &str) -> Option<&str> {
(error.starts_with("browser-recovery-")
|| error.starts_with("browser-launch-failed:")
|| error.starts_with("browser-cleanup-unconfirmed:"))
.then_some(error)
}
pub(crate) async fn host_web_creation_preflight() -> Value {
let started = Instant::now();
// 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。
@@ -362,18 +376,60 @@ 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(|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 result = validation
.map_err(|error| {
let code = browser_validation_failure_code(&error);
browser_validation_diagnostic(&error)
.map(|diagnostic| format!("{code};diagnostic={diagnostic}"))
.unwrap_or_else(|| code.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 {
let bytes = fs::read(&viewport.screenshot_path).map_err(|_| "web-preflight-screenshot-missing")?;
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { return Err("web-preflight-screenshot-invalid".into()); }
image::load_from_memory(&bytes).map_err(|_| "web-preflight-screenshot-invalid")?;
pngs.push(json!({"viewport":viewport.viewport,"passed":viewport.passed,"pngBytes":bytes.len()}));
let bytes = fs::read(&viewport.screenshot_path)
.map_err(|_| "web-preflight-screenshot-missing")?;
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 {
return Err("web-preflight-screenshot-invalid".into());
}
image::load_from_memory(&bytes)
.map_err(|_| "web-preflight-screenshot-invalid")?;
pngs.push(json!({
"viewport": viewport.viewport,
"passed": viewport.passed,
"pngBytes": bytes.len(),
}));
}
Ok::<_, String>(json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"ready","runtime":{"source":runtime.source,"nodeVersion":node,"npmVersion":npm},"build":{"status":"ready","kind":"npm-node-fixture"},"browser":{"status":"ready","viewports":pngs}}))
}.await;
let mut result = run.unwrap_or_else(|code| json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"blocked","code":code}));
Ok::<_, String>(json!({
"schemaVersion": "agc-web-creation-preflight.v1",
"status": "ready",
"runtime": {
"source": runtime.source,
"nodeVersion": node,
"npmVersion": npm,
},
"build": {"status": "ready", "kind": "npm-node-fixture"},
"browser": {"status": "ready", "viewports": pngs},
}))
}
.await;
let mut result = run.unwrap_or_else(|error| {
let (code, diagnostic) = error
.split_once(";diagnostic=")
.map_or((error.as_str(), None), |(code, diagnostic)| {
(code, Some(diagnostic))
});
let mut blocked = json!({
"schemaVersion": "agc-web-creation-preflight.v1",
"status": "blocked",
"code": code,
});
if let Some(diagnostic) = diagnostic {
blocked["diagnostic"] = json!(diagnostic);
}
blocked
});
result["elapsedMs"] = json!(started.elapsed().as_millis());
result
}
@@ -382,9 +438,14 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
pub(crate) async fn preflight_web_game_creation() -> Result<Value, String> {
let result = host_web_creation_preflight().await;
if result["status"] != "ready" {
let diagnostic = result["diagnostic"]
.as_str()
.map(|value| format!(";诊断:{value}"))
.unwrap_or_default();
return Err(format!(
"Web 游戏环境预检未通过:{};尚未启动生成",
result["code"].as_str().unwrap_or("web-preflight-failed")
"Web 游戏环境预检未通过:{}{};尚未启动生成",
result["code"].as_str().unwrap_or("web-preflight-failed"),
diagnostic,
));
}
Ok(result)
@@ -548,16 +609,40 @@ mod tests {
browser_validation_failure_code("启动浏览器失败:2"),
"web-preflight-browser-launch-failed"
);
assert_eq!(
browser_validation_failure_code(
"browser-launch-failed: stage=setup cause=browser-temp-unavailable"
),
"web-preflight-browser-environment-failed"
);
let recovery_diagnostic = "browser-recovery-failed: initial-stage=ws-handshake final-stage=cdp-connect subprocess-exited=true cleanup-confirmed=true recovery-retried=true";
assert_eq!(
browser_validation_failure_code(recovery_diagnostic),
"web-preflight-browser-recovery-failed"
);
assert_eq!(
browser_validation_diagnostic(recovery_diagnostic),
Some(recovery_diagnostic)
);
assert_eq!(
browser_validation_diagnostic("启动浏览器失败:原始错误"),
None
);
assert_eq!(
browser_validation_failure_code("宿主已停止本轮浏览器验证"),
"web-preflight-cancelled"
);
let cleanup_diagnostic = "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false";
assert_eq!(
browser_validation_failure_code(
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程"
),
browser_validation_failure_code(cleanup_diagnostic),
"web-preflight-browser-cleanup-failed"
);
assert_eq!(
browser_validation_diagnostic(cleanup_diagnostic),
Some(cleanup_diagnostic)
);
assert_eq!(
browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"),
"web-preflight-browser-environment-failed"
@@ -236,7 +236,42 @@ impl WindowsProcessJob {
.ok_or_else(|| "子进程缺少 Windows process handle".to_string())?;
Self::assign_handle(handle as windows_sys::Win32::Foundation::HANDLE)
}
pub(crate) fn contains_pid(&self, pid: u32) -> Result<bool, String> {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::JobObjects::IsProcessInJob;
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if process.is_null() {
return Err("无法打开进程核对 Windows Job 归属".into());
}
let mut in_job = 0;
let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) };
unsafe { CloseHandle(process) };
if okay == 0 {
return Err("无法核对进程 Windows Job 归属".into());
}
Ok(in_job != 0)
}
pub(crate) fn assign_pid(&self, pid: u32) -> Result<(), String> {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
};
let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if process.is_null() {
return Err("无法打开进程加入 Windows Job".into());
}
let okay = unsafe { AssignProcessToJobObject(self.0, process) };
unsafe { CloseHandle(process) };
if okay == 0 {
return Err("无法将进程加入 Windows Job".into());
}
Ok(())
}
pub(crate) fn assign_tokio_named(
child: &tokio::process::Child,
name: &str,
@@ -99,7 +99,13 @@ export type ResourceCanvasAudioToolAction =
audioKind: ResourceCanvasGenerationKind;
};
/** 上传入口:复用既有 `upload_local_asset`。 */
/**
* 上传入口:复用既有 `upload_local_asset`。
*
* 调用方按当前栏目带 `targetCategory`(见 `uploadResourceCanvasToolbarFiles`)——上传的
* manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都会派生成 `unclassified`,
* 不带入口栏目素材就落「待归类」、在上传它的那一栏里看不见。
*/
export type ResourceCanvasUploadToolAction =
ResourceCanvasBottomToolActionBase & {
route: 'upload';
@@ -3348,8 +3348,9 @@ export default function ProjectDevelopmentView({
* 两条判据缺一不可:
* 1. 这张卡已经进了布局(`resourcePositionById`),否则 reconcile 还没补位、写坐标是空操作;
* 2. 这张卡已经有正式归类(投影里的 `category`)——`commitPosition` 的 `section` 必须与该资源
* 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目本来就可能不同
* (普通图片落「待归类」、规范落「文档」),所以这里按**正式归类**写,而不是入口栏目。
* 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目**不一定相同**
* (Agent / Direct 路径不传 `targetCategory`,普通图片仍落「待归类」),所以这里按**正式归类**写,
* 而不是入口栏目。
*
* 写完撤掉占位:结果已经接管了它的位置。占位被用户先删掉时同样清掉这条意图(没有位置可接管)。
*/
@@ -9062,9 +9063,15 @@ export default function ProjectDevelopmentView({
[selectResourceCanvasPage],
);
/** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */
/**
* 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。
*
* 入口栏目随上传一起交给原生(`targetCategory`):上传的 kind 只由内容证据推导
* (图片 / 视频 / 代码 → `unclassified`),不带上它,素材会落进「待归类」、
* 在上传它的那一栏里看不见。取值就是工具栏自己的栏目,与生成入口同一口径。
*/
const uploadResourceCanvasToolbarFiles = useCallback(
async (files: readonly File[]) => {
async (files: readonly File[], targetCategory: ResourceCategory | null) => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setResourceWorkbenchNotice('上传素材需要在客户端内打开');
@@ -9088,6 +9095,7 @@ export default function ProjectDevelopmentView({
bytes: Array.from(new Uint8Array(await file.arrayBuffer())),
})),
),
targetCategory,
invoke,
});
setResourceWorkbenchNotice(`已上传 ${uploadable.length} 个素材`);
@@ -10548,7 +10556,10 @@ export default function ProjectDevelopmentView({
}
onSelectAction={handleResourceBottomToolAction}
onUploadFiles={(files) => {
void uploadResourceCanvasToolbarFiles(files);
void uploadResourceCanvasToolbarFiles(
files,
resourceCanvasBottomToolbarCategory,
);
}}
uploading={resourceBottomToolbarUploading}
/>
@@ -1,4 +1,7 @@
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type {
GameCreationAppManifest,
ProjectResourceCanvasCategory,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ProjectManifestSnapshotSource =
| 'initial'
@@ -285,6 +288,14 @@ export async function uploadProjectAssetFilesAndReadSnapshot(input: {
mediaType: string;
bytes: number[];
}[];
/**
* 入口栏目:栏目画布里上传时由调用方显式给出当前栏目,原生按它登记归类。
*
* 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`),
* 不传这一项时素材会落进「待归类」、在上传它的那一栏里看不见。
* 判定与失败关闭都在原生:非法值报错,前端不做伪分类。
*/
targetCategory?: ProjectResourceCanvasCategory | null;
invoke<T>(command: string, args: Record<string, unknown>): Promise<T>;
}): Promise<ProjectManifestSnapshot | null> {
for (const file of input.files) {
@@ -293,6 +304,7 @@ export async function uploadProjectAssetFilesAndReadSnapshot(input: {
fileName: file.fileName,
mediaType: file.mediaType,
bytes: file.bytes,
...(input.targetCategory ? { targetCategory: input.targetCategory } : {}),
});
}
const fresh = await rereadAuthoritativeProjectManifestSnapshot({
@@ -8839,6 +8839,52 @@ export function registerProjectAgentStatusTests() {
);
}, 20_000);
it('uploads toolbar files into the entry column so they stay visible where they were uploaded', async () => {
// Issue 359:图片只由内容证据派生成 `unclassified`,在「角色与对象」栏目里上传后
// 会落进「待归类」、在上传它的那一栏里看不见。工具栏上传必须把当前栏目交给原生。
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-upload-category',
'底部工具栏上传归类项目',
);
const { calls } = installResourceBookBottomToolbarInvoke(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-bottom-toolbar-upload-category',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('角色与对象');
const uploadInput = screen.getByLabelText(
'上传素材文件',
) as HTMLInputElement;
const file = new File(['png'], 'hero.png', { type: 'image/png' });
if (typeof file.arrayBuffer !== 'function') {
Object.defineProperty(file, 'arrayBuffer', {
value: async () => new Uint8Array([112, 110, 103]).buffer,
});
}
fireEvent.change(uploadInput, { target: { files: [file] } });
await waitFor(() =>
expect(uploadCall(calls, 'hero.png')).toMatchObject({
projectPath: '/tmp/workbench-bottom-toolbar-upload-category',
fileName: 'hero.png',
mediaType: 'image/png',
targetCategory: 'character',
bytes: [112, 110, 103],
}),
);
}, 20_000);
it('keeps the bottom toolbar clear of the zoom dock and above the book scene', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
+2
View File
@@ -128,6 +128,8 @@
- [预览画布缩放滑杆](./【交互设计】预览画布缩放滑杆-2026-09-05.md)
- [图片画布撤销、恢复范围与操作提示方案](./【图片画布】撤销范围与操作提示方案-2026-07-17.md)
- [微信虚拟支付接入](./【技术方案】微信虚拟支付接入-2026-05-26.md)
- [外部产品支付服务接入](./【技术方案】外部产品支付服务接入-2026-10-03.md)
- [支付服务接入与收银台使用说明](./【使用说明】支付服务接入与收银台使用说明-2026-10-03.md)
- [SSE 客户端传输层收口约定](./technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md)
- [全站客服悬浮入口接入约定](./technical/【前端架构】全站客服悬浮入口接入约定-2026-06-23.md)
- [图片画布素材导出方案](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)
@@ -43,6 +43,10 @@
{
"name": "Agent Integration",
"description": "远程 MCP、OpenAPI 和完整 Skill 包发现"
},
{
"name": "Payment",
"description": "平台统一收款与 Web 收银台"
}
],
"paths": {
@@ -1614,6 +1618,52 @@
}
}
}
},
"/api/external/v1/payment/orders": {
"post": {
"tags": ["Payment"],
"operationId": "createExternalPaymentOrder",
"summary": "创建支付订单并返回收银台地址",
"description": "使用支付应用 API Key 创建平台统一收款订单。支付金额按整数分传入;支付成功必须以平台通知或服务端查单为准。",
"security": [{"PaymentApiKey": []}],
"parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/PaymentOrderCreateRequest"}
}
}
},
"responses": {
"200": {
"description": "支付订单与收银台信息",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaymentOrderCreateResponse"}}}
},
"400": {"$ref": "#/components/responses/BadRequest"},
"401": {"$ref": "#/components/responses/Unauthorized"},
"403": {"$ref": "#/components/responses/Forbidden"},
"502": {"$ref": "#/components/responses/UpstreamError"}
}
}
},
"/api/external/v1/payment/orders/{orderId}": {
"get": {
"tags": ["Payment"],
"operationId": "getExternalPaymentOrder",
"summary": "查询支付订单",
"security": [{"PaymentApiKey": []}],
"parameters": [{"name": "orderId", "in": "path", "required": true, "schema": {"type": "string"}}],
"responses": {
"200": {
"description": "支付订单",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaymentOrderCreateResponse"}}}
},
"401": {"$ref": "#/components/responses/Unauthorized"},
"403": {"$ref": "#/components/responses/Forbidden"},
"404": {"$ref": "#/components/responses/NotFound"}
}
}
}
},
"components": {
@@ -1622,6 +1672,11 @@
"type": "http",
"scheme": "bearer",
"bearerFormat": "tnr_sk"
},
"PaymentApiKey": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "tnr_pay"
}
},
"parameters": {
@@ -1747,6 +1802,47 @@
}
},
"schemas": {
"PaymentOrderCreateRequest": {
"type": "object",
"required": ["merchantOrderId", "title", "amountCents"],
"properties": {
"merchantOrderId": {"type": "string", "minLength": 1, "maxLength": 96},
"title": {"type": "string", "minLength": 1, "maxLength": 128},
"items": {"type": "array", "items": {"type": "object", "additionalProperties": true}},
"amountCents": {"type": "integer", "minimum": 1},
"currency": {"type": "string", "default": "CNY"},
"provider": {"type": "string", "enum": ["wechat_native"], "default": "wechat_native"}
},
"additionalProperties": false
},
"PaymentOrder": {
"type": "object",
"required": ["orderId", "appId", "merchantOrderId", "title", "items", "amountCents", "currency", "provider", "checkoutToken", "status", "checkoutUrl", "createdAt", "expiresAt", "updatedAt"],
"properties": {
"orderId": {"type": "string"},
"appId": {"type": "string"},
"merchantOrderId": {"type": "string"},
"title": {"type": "string"},
"items": {"type": "array", "items": {"type": "object", "additionalProperties": true}},
"amountCents": {"type": "integer", "minimum": 1},
"currency": {"type": "string"},
"provider": {"type": "string", "enum": ["wechat_native"]},
"providerTradeNo": {"type": ["string", "null"]},
"checkoutToken": {"type": "string"},
"status": {"type": "string", "enum": ["pending", "paying", "paid", "expired", "closed", "refunded"]},
"checkoutUrl": {"type": "string"},
"providerQrCode": {"type": ["string", "null"]},
"createdAt": {"type": "string", "format": "date-time"},
"expiresAt": {"type": "string", "format": "date-time"},
"paidAt": {"type": ["string", "null"], "format": "date-time"},
"updatedAt": {"type": "string", "format": "date-time"}
}
},
"PaymentOrderCreateResponse": {
"type": "object",
"required": ["order"],
"properties": {"order": {"$ref": "#/components/schemas/PaymentOrder"}}
},
"ExternalEditorProjectCreateRequest": {
"type": "object",
"properties": {
@@ -164,7 +164,7 @@
- 位置与层级:画布左下角(`left: 14px; bottom: 14px; z-index: 40`)。右下角是既有的缩放 / 撤销 Dock(`right: 14px; bottom: 14px`),左下角是画布上唯一两者都不占的稳定空位。工具栏是管理区 `.game-resource-book-manager` 的**直接子节点**、与画本场景并列,不进带 `scale()` 的场景层;二级菜单与「入口不可用原因」都贴着工具栏上沿弹出,不做内嵌内容。
- 外壳用共享 chrome(`packages/image-canvas-react` 的 `CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`),样式落在 AGC 的 `resourceCanvasChrome.css`;共享包只承接通用表现,不含业务规则。
- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。
- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`,并带上当前栏目 `targetCategory`(上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`;不带入口栏目素材就落进「待归类」、在上传它的那一栏里看不见,Issue 359)。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。
- 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(位置与开合形态照抄网页端美术画布的任务侧栏的右上角锚点,颜色与外形仍走 AGC 平台 token;2026-09-21 由「左侧贴边 + 工具条入口」改为「右上角锚点 + 常驻开关」):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除)、折叠即整块让出画布、只留那一枚右上角开关(工具条上不再有重复入口),开合只走画布右上角那一枚「生成任务 · N」开关(两个页签下都在;**展开后开关让位、只留面板**,收起走面板头部 × 或点画布外部);锚点是画布那一格网格里的条目(不是写死 `top` 的绝对定位),工具条换行变高也不会压上去;每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布右上角锚点里(照抄美术画布那一处)、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。锚点按工作面分档:资源栏目画布与 UI 编辑器用画布顶边那一档,运行表现层下移让开右上角的版本入口;锚点是**画布那一格网格里的条目**(不是写死 `top` 的绝对定位),所以工具条换行变高也不会压上去。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。
- 面板形态:独立浮层(`ThemedModal`),**不在当前面板下面追加内容**;面板内不写功能说明或规则解释文案。**点「生成」即同步关闭面板**(不等 IPC、不等排队、不等生成),面板里**不出现**「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是任务侧栏与工具栏提示条。**只有「点击瞬间就失败」**(校验不过、权限拒绝、start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消请求(请求挂在任务与账本上,不挂在面板生命周期上)。
- 参数口径:比例 / 尺寸选项来自网页端美术画布的纯模型(`src/components/image-editor/ImageCanvasGenerationModel.ts`),并按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`);默认档 `1:1 · 1K`,生成 UI 设计图沿用网页端 UI 设计面板的默认 `16:9 · 1K`。本地 IPC 没有 `model` 入参,因此面板**不渲染模型选择器**(渲染一个改不了请求的控件就是假控件)。
@@ -0,0 +1,31 @@
# Web 预检浏览器失败恢复实现计划
- Version: `1`
- Status: `active`
- Date: `2026-10-02`
- Milestone: [`Web 预检浏览器失败恢复`](./【里程碑】Web预检浏览器失败恢复-2026-10-02.md)
## 实现顺序
1. 将浏览器启动失败建模为带阶段、原因、子进程退出确认、清理确认的内部结果;Windows root spawn 后立即绑定 Job,并把绑定瞬间已经出现的子树逐 PID 纳入同一 Job,失败收束必须复用本轮 `BrowserProcessGuard`,并在成功收束时确认 Windows Job 为空。
2. 在 Web 预检使用的一次浏览器启动入口加入一次有界恢复:仅 WS/CDP 瞬态失败且首次清理确认后创建新的临时目录/Profile 重试;其余失败直接失败关闭。
3. 保留/加强 owner 与 sweep 的归属门禁,使用 Windows argv 解析核对完整 Profile 参数;无 owner、身份未知、PID 退出或复用均不得按猜测杀进程。
4. 将启动失败和清理失败的安全诊断保留到预检 blocked 报告和 Tauri 错误中;稳定错误码独立于诊断文本,前端继续区分宿主阻塞与 IPC 故障。
5. 补充 Rust 纯策略测试、清理边界测试和现有真实 Edge/Chromium ignored smoke;补充首页错误展示的定向测试。
## 验证命令
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell browser::`
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell environment_check::web_creation::tests`
- `npx vitest run apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx`
- `npm run typecheck --workspace apps/ai-game-creator-shell`
- `npm run check:encoding`
- `git diff --check`
可选真实环境证据:安装 Windows Edge 时运行现有 `real_browser_health_checks_can_run_concurrently` 及新增恢复 smoke;无浏览器时保持 ignored,不把缺失环境写成通过。
## 风险与回滚
- Windows 进程命令行读取失败按不匹配处理,不执行杀进程;这可能留下临时目录,但保证不误杀。
- 首次失败进程树收束未确认时不自动重试,避免第二个 Edge 与残留树并存;错误返回安全诊断。
- 回滚点为浏览器启动恢复入口和 owner/sweep 归属校验,不触及 Web 预检的 Node/npm 或项目写入流程。

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