AGC 原生侧新增平台 facade,退役渲染层 HTTP 能力
- 新增 account_api、auth_session、platform_asset_upload、game_distribution_publish、llm_catalog、workspace_preferences 与 error_report::submit:凭据、origin、HTTP、队列等待、幂等键与导入队列全部由 Rust 持有。 - 删除 capabilities/main.json 的 http:default 授权与主窗口的 tauri-plugin-http 注册,Cargo.toml / Cargo.lock / AGC package.json 与根 package-lock.json 同步移除该依赖。 - 修复 workspace_preferences 重复 #[test] 造成的分片门禁「同一用例被分到两片」误判,并同步该模块的会话存储与偏好读写用例。
This commit is contained in:
@@ -36,7 +36,6 @@
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
"@tauri-apps/plugin-updater": "2.11.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
|
||||
-126
@@ -832,29 +832,10 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -1048,12 +1029,6 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.11"
|
||||
@@ -1189,15 +1164,6 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -1319,15 +1285,6 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1833,7 +1790,6 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
@@ -2095,25 +2051,6 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -2240,7 +2177,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -2266,7 +2202,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2821,12 +2756,6 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -4185,22 +4114,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
@@ -4460,13 +4373,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -4476,7 +4385,6 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
@@ -4501,7 +4409,6 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5670,30 +5577,6 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store",
|
||||
"data-url",
|
||||
"http",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
"urlpattern",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
@@ -6906,15 +6789,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
|
||||
@@ -69,7 +69,6 @@ regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-updater = "2.11.0"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。渲染层是离线前端:这里不授予任何 HTTP 权限,平台接口、OSS 直传、Provider 与更新清单的网络 IO 全部由 Rust 侧承担。",
|
||||
"windows": ["client"],
|
||||
"permissions": [
|
||||
"clipboard-manager:allow-read-image",
|
||||
@@ -9,17 +9,6 @@
|
||||
"core:image:allow-rgba",
|
||||
"core:image:allow-size",
|
||||
"core:resources:allow-close",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://dev.genarrative.world/api/*" },
|
||||
{ "url": "https://www.genarrative.world/api/*" },
|
||||
{ "url": "https://*/api/*" },
|
||||
{ "url": "http://localhost:*/*" },
|
||||
{ "url": "http://127.0.0.1:*/*" },
|
||||
{ "url": "https://*.aliyuncs.com/*" }
|
||||
]
|
||||
},
|
||||
"opener:default",
|
||||
"updater:default",
|
||||
"dialog:allow-open",
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! 账户与钱包 typed facade。
|
||||
//!
|
||||
//! 渲染层只提交结构化业务参数(商品 ID、订单 ID、兑换码);平台 origin、Bearer、
|
||||
//! 响应 envelope 解析与错误分类都在 Rust 内完成。access token 过期时返回稳定的
|
||||
//! `authentication-required`,由调用方按既有会话续期策略决定是否重试一次。
|
||||
|
||||
use crate::http_client::agc_main_site_client_builder;
|
||||
use crate::platform_session::{current_platform_session, PlatformSessionSnapshot};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::runtime::{
|
||||
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse,
|
||||
ProfileRechargeCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope";
|
||||
const API_RESPONSE_ENVELOPE_VERSION: &str = "v1";
|
||||
const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client";
|
||||
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
|
||||
const MAX_BUSINESS_ID_CHARS: usize = 128;
|
||||
const MAX_REDEEM_CODE_CHARS: usize = 128;
|
||||
|
||||
fn require_session() -> Result<PlatformSessionSnapshot, String> {
|
||||
current_platform_session()
|
||||
.ok_or_else(|| "authentication-required: 请先登录后再继续".to_string())
|
||||
}
|
||||
|
||||
fn build_client() -> Result<reqwest::Client, String> {
|
||||
agc_main_site_client_builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|_| "创建账户网络客户端失败".to_string())
|
||||
}
|
||||
|
||||
fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result<String, String> {
|
||||
let mut url = Url::parse(&format!("{}/", snapshot.api_base_url.trim_end_matches('/')))
|
||||
.map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||||
{
|
||||
let mut path = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||||
for segment in segments {
|
||||
if segment.is_empty() || segment.contains(['/', '?', '#']) {
|
||||
return Err("账户请求路径无效".to_string());
|
||||
}
|
||||
path.push(segment);
|
||||
}
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn bounded_business_id(value: &str, label: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty()
|
||||
|| value.chars().count() > MAX_BUSINESS_ID_CHARS
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
return Err(format!("{label}无效"));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn error_message(body: &str) -> Option<String> {
|
||||
let value = serde_json::from_str::<Value>(body).ok()?;
|
||||
let error = value.get("error").unwrap_or(&value);
|
||||
error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|message| !message.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string();
|
||||
}
|
||||
if status == StatusCode::FORBIDDEN {
|
||||
return format!(
|
||||
"permission-denied: {}",
|
||||
error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string())
|
||||
);
|
||||
}
|
||||
let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
format!("{fallback}:{detail}")
|
||||
}
|
||||
|
||||
fn unwrap_envelope(body: &str, fallback: &str) -> Result<Value, String> {
|
||||
let value: Value =
|
||||
serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?;
|
||||
if value.get("ok").and_then(Value::as_bool) == Some(false) {
|
||||
return Err(format!(
|
||||
"{fallback}:{}",
|
||||
error_message(body).unwrap_or_else(|| "服务端请求失败".to_string())
|
||||
));
|
||||
}
|
||||
Ok(value.get("data").cloned().unwrap_or(value))
|
||||
}
|
||||
|
||||
async fn request_json<T: DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
method: Method,
|
||||
segments: &[&str],
|
||||
body: Option<Value>,
|
||||
fallback: &str,
|
||||
) -> Result<T, String> {
|
||||
let url = endpoint(snapshot, segments)?;
|
||||
let mut request = client
|
||||
.request(method, &url)
|
||||
.bearer_auth(&snapshot.access_token)
|
||||
.header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE)
|
||||
.header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION)
|
||||
.header(reqwest::header::ACCEPT, "application/json");
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
}
|
||||
let response = request.send().await.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
format!("{fallback}:请求超时,请稍后重试")
|
||||
} else {
|
||||
format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试")
|
||||
}
|
||||
})?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|_| format!("{fallback}:读取响应失败"))?;
|
||||
if !status.is_success() {
|
||||
return Err(map_http_error(status, &text, fallback));
|
||||
}
|
||||
let data = unwrap_envelope(&text, fallback)?;
|
||||
serde_json::from_value(data).map_err(|_| format!("{fallback}:响应格式无效"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_profile_recharge_center() -> Result<ProfileRechargeCenterResponse, String>
|
||||
{
|
||||
let snapshot = require_session()?;
|
||||
let client = build_client()?;
|
||||
request_json(
|
||||
&client,
|
||||
&snapshot,
|
||||
Method::GET,
|
||||
&["api", "profile", "recharge-center"],
|
||||
None,
|
||||
"读取泥点明细失败",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_profile_wallet_ledger() -> Result<ProfileWalletLedgerResponse, String> {
|
||||
let snapshot = require_session()?;
|
||||
let client = build_client()?;
|
||||
request_json(
|
||||
&client,
|
||||
&snapshot,
|
||||
Method::GET,
|
||||
&["api", "profile", "wallet-ledger"],
|
||||
None,
|
||||
"读取泥点账单失败",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn create_profile_recharge_order(
|
||||
product_id: String,
|
||||
) -> Result<CreateProfileRechargeOrderResponse, String> {
|
||||
let product_id = bounded_business_id(&product_id, "充值商品标识")?;
|
||||
let snapshot = require_session()?;
|
||||
let client = build_client()?;
|
||||
request_json(
|
||||
&client,
|
||||
&snapshot,
|
||||
Method::POST,
|
||||
&["api", "profile", "recharge", "orders"],
|
||||
Some(serde_json::json!({
|
||||
"productId": product_id,
|
||||
"paymentChannel": "wechat_native",
|
||||
})),
|
||||
"充值失败",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn confirm_wechat_profile_recharge_order(
|
||||
order_id: String,
|
||||
) -> Result<ConfirmWechatProfileRechargeOrderResponse, String> {
|
||||
let order_id = bounded_business_id(&order_id, "充值订单标识")?;
|
||||
let snapshot = require_session()?;
|
||||
let client = build_client()?;
|
||||
request_json(
|
||||
&client,
|
||||
&snapshot,
|
||||
Method::POST,
|
||||
&[
|
||||
"api",
|
||||
"profile",
|
||||
"recharge",
|
||||
"orders",
|
||||
order_id.as_str(),
|
||||
"wechat",
|
||||
"confirm",
|
||||
],
|
||||
None,
|
||||
"确认微信支付订单失败",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn redeem_profile_reward_code(
|
||||
code: String,
|
||||
) -> Result<RedeemProfileRewardCodeResponse, String> {
|
||||
let code = code.trim();
|
||||
if code.is_empty() || code.chars().count() > MAX_REDEEM_CODE_CHARS {
|
||||
return Err("兑换码无效".to_string());
|
||||
}
|
||||
let snapshot = require_session()?;
|
||||
let client = build_client()?;
|
||||
request_json(
|
||||
&client,
|
||||
&snapshot,
|
||||
Method::POST,
|
||||
&["api", "profile", "redeem-codes", "redeem"],
|
||||
Some(serde_json::json!({ "code": code })),
|
||||
"兑换失败",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snapshot() -> PlatformSessionSnapshot {
|
||||
PlatformSessionSnapshot {
|
||||
user_id: "user-1".to_string(),
|
||||
access_token: "token".to_string(),
|
||||
api_base_url: "https://dev.genarrative.world".to_string(),
|
||||
identity_generation: 1,
|
||||
revision: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_encodes_path_segments_and_keeps_origin() {
|
||||
assert_eq!(
|
||||
endpoint(
|
||||
&snapshot(),
|
||||
&["api", "profile", "recharge", "orders", "order-1", "wechat", "confirm"]
|
||||
)
|
||||
.expect("endpoint"),
|
||||
"https://dev.genarrative.world/api/profile/recharge/orders/order-1/wechat/confirm"
|
||||
);
|
||||
assert!(endpoint(&snapshot(), &["api", "bad/id"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expires_and_permissions_keep_stable_categories() {
|
||||
assert_eq!(
|
||||
map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"),
|
||||
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试"
|
||||
);
|
||||
assert_eq!(
|
||||
map_http_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
r#"{"error":{"message":"无权"}}"#,
|
||||
"读取失败"
|
||||
),
|
||||
"permission-denied: 无权"
|
||||
);
|
||||
assert_eq!(
|
||||
map_http_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
r#"{"error":{"message":"余额不足"}}"#,
|
||||
"充值失败"
|
||||
),
|
||||
"充值失败:余额不足"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_unwrap_reports_in_band_failures() {
|
||||
assert_eq!(
|
||||
unwrap_envelope(r#"{"ok":true,"data":{"walletBalance":1}}"#, "读取失败")
|
||||
.expect("envelope")["walletBalance"],
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
unwrap_envelope(
|
||||
r#"{"ok":false,"error":{"message":"登录已失效"}}"#,
|
||||
"读取失败"
|
||||
)
|
||||
.expect_err("in-band failure"),
|
||||
"读取失败:登录已失效"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn business_identifiers_are_bounded_before_network() {
|
||||
assert!(bounded_business_id(" ", "充值商品标识").is_err());
|
||||
assert!(bounded_business_id("product-1", "充值商品标识").is_ok());
|
||||
assert!(bounded_business_id(&"x".repeat(MAX_BUSINESS_ID_CHARS + 1), "订单").is_err());
|
||||
assert!(bounded_business_id("bad\nid", "订单").is_err());
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,40 @@ mod tests {
|
||||
assert!(error.to_string().contains("unknown field"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_reference_serializes_with_only_the_stable_name() {
|
||||
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "agc_skill_reference", "name": "agc-web-game-development"}],
|
||||
"id": "turn-1"
|
||||
}))
|
||||
.expect("skill reference should parse");
|
||||
assert_eq!(
|
||||
serde_json::to_value(item).expect("serialize skill reference")["content"][0],
|
||||
json!({"type": "agc_skill_reference", "name": "agc-web-game-development"})
|
||||
);
|
||||
|
||||
// canonical part 不接受正文、路径或凭据类附加字段:它们只可能来自宿主私密状态。
|
||||
for forbidden in ["path", "body", "content", "token", "apiKey"] {
|
||||
let error = serde_json::from_value::<DirectCodexUserItem>(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "agc_skill_reference",
|
||||
"name": "agc-web-game-development",
|
||||
forbidden: "x"
|
||||
}],
|
||||
"id": "turn-1"
|
||||
}))
|
||||
.expect_err("extra skill reference fields must be rejected");
|
||||
assert!(
|
||||
error.to_string().contains("unknown field"),
|
||||
"{forbidden}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_content_part_fails_closed() {
|
||||
serde_json::from_value::<DirectCodexUserItem>(json!({
|
||||
|
||||
@@ -282,8 +282,9 @@ fn render_ui_design_code_context(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||
direct_codex_user_item_to_wire_input, validate_direct_codex_user_item,
|
||||
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
|
||||
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
|
||||
validate_direct_codex_user_item,
|
||||
};
|
||||
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
|
||||
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||
@@ -423,6 +424,47 @@ mod tests {
|
||||
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_references_keep_order_when_one_ui_design_doc_generation_fails() {
|
||||
let (project, failing_ui_doc_id) = ui_design_doc_fixture(false);
|
||||
let hero_id = register_fixture_asset(
|
||||
project.path(),
|
||||
"assets/hero.png",
|
||||
GameCreationAppAssetKind::Character,
|
||||
"image/png",
|
||||
);
|
||||
let item: super::DirectCodexUserItem = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-multi-1:user",
|
||||
"content": [
|
||||
{"type": "agc_resource_reference", "resourceId": failing_ui_doc_id},
|
||||
{"type": "input_text", "text": "顺带看看主角"},
|
||||
{"type": "agc_resource_reference", "resourceId": hero_id},
|
||||
],
|
||||
}))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
|
||||
let ui_doc_at = prompt
|
||||
.find(&failing_ui_doc_id)
|
||||
.unwrap_or_else(|| panic!("失败的 UI 文档引用也必须保留:{prompt}"));
|
||||
let hero_at = prompt
|
||||
.find(&hero_id)
|
||||
.unwrap_or_else(|| panic!("同一轮里的其它引用不能被丢掉:{prompt}"));
|
||||
assert!(
|
||||
ui_doc_at < hero_at,
|
||||
"引用顺序必须与 canonical content 顺序一致:{prompt}"
|
||||
);
|
||||
assert!(prompt.contains("顺带看看主角"), "{prompt}");
|
||||
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||
assert!(
|
||||
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
||||
"生成失败时不能出现代码片段指引:{prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
||||
let project = prompt_context_project();
|
||||
@@ -613,6 +655,62 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_skill_reference_fails_closed_before_any_turn_input() {
|
||||
let root = prompt_context_project();
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-skill:user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "请使用 Skill"},
|
||||
{"type": "agc_skill_reference", "name": "missing-skill"}
|
||||
]
|
||||
});
|
||||
let user_item: DirectCodexUserItem =
|
||||
serde_json::from_value(item).expect("parse canonical item");
|
||||
// 已启用目录里没有这个 Skill:转换必须在启动回合前失败关闭,
|
||||
// 不能把不可用的引用降级成正文放行。
|
||||
let error = direct_codex_user_item_to_codex_turn_input(
|
||||
root.path(),
|
||||
&user_item,
|
||||
&[root.path().join("skills")],
|
||||
)
|
||||
.expect_err("unavailable skill must fail closed");
|
||||
assert!(
|
||||
error.contains("当前不可用"),
|
||||
"错误文案要能指导用户重新选择:{error}"
|
||||
);
|
||||
|
||||
// 目录里存在时按受控路径解析成 Codex 原生 skill 输入项,顺序与 canonical content 一致。
|
||||
let skill_root = root.path().join("skills");
|
||||
let skill_path = skill_root.join("present-skill").join("SKILL.md");
|
||||
std::fs::create_dir_all(skill_path.parent().expect("skill directory"))
|
||||
.expect("create skill directory");
|
||||
std::fs::write(&skill_path, "# 测试 Skill").expect("write skill");
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-skill-ok:user",
|
||||
"content": [
|
||||
{"type": "agc_skill_reference", "name": "present-skill"},
|
||||
{"type": "input_text", "text": "然后创建菜单"}
|
||||
]
|
||||
});
|
||||
let user_item: DirectCodexUserItem =
|
||||
serde_json::from_value(item).expect("parse canonical item");
|
||||
let input =
|
||||
direct_codex_user_item_to_codex_turn_input(root.path(), &user_item, &[skill_root])
|
||||
.expect("available skill should convert");
|
||||
assert_eq!(
|
||||
input,
|
||||
json!([
|
||||
{"type": "skill", "name": "present-skill", "path": skill_path},
|
||||
{"type": "text", "text": "然后创建菜单"}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_item_without_type_fails_closed() {
|
||||
let error = direct_codex_user_item_to_response_item(
|
||||
|
||||
@@ -512,6 +512,8 @@ fn direct_taonier_active_now_millis() -> u64 {
|
||||
pub(crate) struct DirectTaonierActiveInvocationGuard {
|
||||
root: PathBuf,
|
||||
invocation_id: String,
|
||||
/// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,渲染层不再定时刷新。
|
||||
_session_keepalive: Option<crate::auth_session::ClientSessionKeepalive>,
|
||||
}
|
||||
|
||||
impl DirectTaonierActiveInvocationGuard {
|
||||
@@ -557,9 +559,12 @@ impl DirectTaonierActiveInvocationGuard {
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(active);
|
||||
crate::agent::emit_direct_active_turns_changed();
|
||||
Ok(Self {
|
||||
root,
|
||||
invocation_id: invocation_id.to_string(),
|
||||
_session_keepalive: crate::auth_session::spawn_client_session_keepalive(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -578,6 +583,10 @@ impl Drop for DirectTaonierActiveInvocationGuard {
|
||||
if remove {
|
||||
active.remove(&self.root);
|
||||
}
|
||||
drop(active);
|
||||
if remove {
|
||||
crate::agent::emit_direct_active_turns_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,10 +635,15 @@ pub(crate) fn update_direct_active_turn(
|
||||
if invocation.invocation_id != turn_id || sequence < invocation.sequence {
|
||||
return;
|
||||
}
|
||||
let changed = invocation.status != status || invocation.activity.as_deref() != activity;
|
||||
invocation.status = status.to_string();
|
||||
invocation.activity = activity.map(str::to_string);
|
||||
invocation.updated_at = updated_at;
|
||||
invocation.sequence = sequence;
|
||||
drop(active);
|
||||
if changed {
|
||||
crate::agent::emit_direct_active_turns_changed();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result<String, String> {
|
||||
@@ -746,6 +760,8 @@ pub(crate) fn release_stale_direct_taonier_active_invocation(
|
||||
}
|
||||
let released = existing.invocation_id.clone();
|
||||
active.remove(&root);
|
||||
drop(active);
|
||||
crate::agent::emit_direct_active_turns_changed();
|
||||
Ok(released)
|
||||
}
|
||||
|
||||
@@ -6113,8 +6129,14 @@ mod tests {
|
||||
fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() {
|
||||
let root = tempfile::tempdir().expect("active snapshot root");
|
||||
let turn_id = "client-turn-snapshot-0001";
|
||||
let events_before_enter = crate::agent::direct_active_turns_event_test_count();
|
||||
let guard = DirectTaonierActiveInvocationGuard::enter(root.path(), turn_id)
|
||||
.expect("active snapshot turn");
|
||||
assert_eq!(
|
||||
crate::agent::direct_active_turns_event_test_count(),
|
||||
events_before_enter + 1,
|
||||
"enter publishes one active-turn notification"
|
||||
);
|
||||
update_direct_active_turn(
|
||||
root.path(),
|
||||
turn_id,
|
||||
@@ -6123,6 +6145,21 @@ mod tests {
|
||||
3,
|
||||
42,
|
||||
);
|
||||
let events_after_progress = crate::agent::direct_active_turns_event_test_count();
|
||||
assert_eq!(events_after_progress, events_before_enter + 2);
|
||||
update_direct_active_turn(
|
||||
root.path(),
|
||||
turn_id,
|
||||
"streaming",
|
||||
Some("response-finalization"),
|
||||
4,
|
||||
43,
|
||||
);
|
||||
assert_eq!(
|
||||
crate::agent::direct_active_turns_event_test_count(),
|
||||
events_after_progress,
|
||||
"text/progress sequence changes do not publish duplicate active-turn notifications"
|
||||
);
|
||||
let snapshot = list_direct_active_turns()
|
||||
.expect("list active turns")
|
||||
.into_iter()
|
||||
@@ -6130,9 +6167,14 @@ mod tests {
|
||||
.expect("snapshot entry");
|
||||
assert_eq!(snapshot.status, "streaming");
|
||||
assert_eq!(snapshot.activity.as_deref(), Some("response-finalization"));
|
||||
assert_eq!(snapshot.sequence, 3);
|
||||
assert_eq!(snapshot.updated_at, 42);
|
||||
assert_eq!(snapshot.sequence, 4);
|
||||
assert_eq!(snapshot.updated_at, 43);
|
||||
drop(guard);
|
||||
assert_eq!(
|
||||
crate::agent::direct_active_turns_event_test_count(),
|
||||
events_after_progress + 1,
|
||||
"drop publishes the removal notification"
|
||||
);
|
||||
assert!(list_direct_active_turns()
|
||||
.expect("list after completion")
|
||||
.into_iter()
|
||||
@@ -8128,6 +8170,45 @@ mod tests {
|
||||
assert!(!diagnostic.contains("provider.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_codex_failure_text_keeps_the_detail_ref_marker_for_the_renderer() {
|
||||
let parent = tempfile::tempdir().expect("temp dir");
|
||||
let root = parent.path().join("project");
|
||||
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
|
||||
let error = record_direct_codex_turn_failure(
|
||||
&root,
|
||||
DirectCodexTurnFailure::new(
|
||||
DirectCodexFailureStage::CodeGeneration,
|
||||
"入口模块语法错误",
|
||||
),
|
||||
Some("client-turn-detail-ref"),
|
||||
);
|
||||
|
||||
// 渲染层按这个后缀取回 `detailRef` 再读有界诊断;标记或后缀变化等于跨层协议变化,
|
||||
// 必须同时改前端解析(`src/services/agentRuntimeErrorDetail.ts`)与两侧用例。
|
||||
let marker = ";详情:.agent/runtime/errors/";
|
||||
let ref_start = error
|
||||
.find(marker)
|
||||
.unwrap_or_else(|| panic!("失败文案缺少诊断引用标记:{error}"))
|
||||
+ marker.len();
|
||||
let detail_ref = &error[ref_start..];
|
||||
assert!(
|
||||
detail_ref.ends_with(".json"),
|
||||
"诊断引用必须以 .json 结尾:{error}"
|
||||
);
|
||||
assert!(
|
||||
!detail_ref[..detail_ref.len() - ".json".len()].contains('/'),
|
||||
"诊断引用只能是错误目录下的单个文件名:{error}"
|
||||
);
|
||||
assert!(
|
||||
detail_ref.starts_with("error-"),
|
||||
"诊断引用沿用统一错误事件的 eventId:{error}"
|
||||
);
|
||||
// 引用指向的文件名就是事件身份,且渲染层读得到它。
|
||||
let detail_path = root.join(format!(".agent/runtime/errors/{detail_ref}"));
|
||||
assert!(detail_path.is_file(), "诊断 sidecar 必须真的落盘:{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() {
|
||||
let parent = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -208,6 +208,23 @@ impl DirectThreadManager {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 移除订阅者并立即释放它钉住的队列前缀。重复移除是幂等空操作,允许前端
|
||||
/// cleanup 与迟到的 bootstrap 回执安全竞争。
|
||||
pub(crate) fn unsubscribe(&mut self, subscription_id: &str) -> bool {
|
||||
let Some((_, thread)) = self
|
||||
.threads
|
||||
.iter_mut()
|
||||
.find(|(_, thread)| thread.subscribers.contains_key(subscription_id))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let removed = thread.subscribers.remove(subscription_id).is_some();
|
||||
if removed {
|
||||
Self::trim_prefix(thread);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn thread_debug(&self, thread_id: &str) -> Option<(usize, usize, usize)> {
|
||||
self.threads.get(thread_id).map(|thread| {
|
||||
@@ -413,6 +430,13 @@ pub(crate) fn consume_direct_thread(
|
||||
.consume(subscription_id)
|
||||
}
|
||||
|
||||
pub(crate) fn unsubscribe_direct_thread(subscription_id: &str) {
|
||||
let mut manager = global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
manager.unsubscribe(subscription_id);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -475,6 +499,34 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsubscribe_releases_the_subscriber_and_is_idempotent() {
|
||||
let mut manager = DirectThreadManager::with_limits(100, 100_000);
|
||||
manager.append("thread-1", item_started("item-1"));
|
||||
let subscription = manager.subscribe("thread-1");
|
||||
manager.append("thread-1", item_delta("item-1"));
|
||||
|
||||
assert!(manager.unsubscribe(&subscription.subscription_id));
|
||||
assert!(!manager.unsubscribe(&subscription.subscription_id));
|
||||
assert_eq!(
|
||||
manager.consume(&subscription.subscription_id),
|
||||
Err(SUBSCRIPTION_EXPIRED.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsubscribe_allows_a_cleanable_prefix_to_be_trimmed() {
|
||||
let mut manager = DirectThreadManager::with_limits(100, 100_000);
|
||||
manager.append("thread-1", item_started("item-1"));
|
||||
let subscription = manager.subscribe("thread-1");
|
||||
manager.append("thread-1", item_completed("item-1"));
|
||||
assert_eq!(manager.thread_debug("thread-1").unwrap().0, 1);
|
||||
|
||||
manager.unsubscribe(&subscription.subscription_id);
|
||||
|
||||
assert_eq!(manager.thread_debug("thread-1").unwrap().0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() {
|
||||
let mut manager = DirectThreadManager::with_limits(100, 100_000);
|
||||
|
||||
@@ -284,6 +284,8 @@ pub(in crate::agent) use task_start::*;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use entrypoints::acquire_game_creator_manifest_invalidation_event_sink_test_guard;
|
||||
#[cfg(test)]
|
||||
pub(crate) use entrypoints::direct_active_turns_event_test_count;
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use entrypoints::{
|
||||
chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at,
|
||||
@@ -291,11 +293,12 @@ pub(crate) use entrypoints::{
|
||||
chat_with_game_creator_role_agent_runtime_for_session_at,
|
||||
chat_with_game_creator_role_agent_stream_at,
|
||||
chat_with_game_creator_role_agent_stream_for_session_at,
|
||||
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
|
||||
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
|
||||
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
|
||||
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
|
||||
read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink,
|
||||
configure_game_creator_manifest_invalidation_event_sink, emit_direct_active_turns_changed,
|
||||
emit_direct_game_creator_progress, emit_game_creator_agent_runtime_update,
|
||||
emit_game_creator_manifest_invalidated, game_creator_agent_runtime_update_event,
|
||||
generate_local_game_draft_at, read_game_creator_agent_runtime_at,
|
||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
||||
register_game_creator_manifest_invalidation_event_sink,
|
||||
set_game_creator_agent_runtime_update_app_handle,
|
||||
start_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) const DIRECT_ACTIVE_TURNS_CHANGED_EVENT: &str =
|
||||
"game-creator-direct-active-turns-changed";
|
||||
static DIRECT_ACTIVE_TURNS_EVENT_REVISION: AtomicU64 = AtomicU64::new(0);
|
||||
#[cfg(test)]
|
||||
static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
|
||||
|
||||
@@ -15,6 +21,29 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa
|
||||
let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app);
|
||||
}
|
||||
|
||||
/// Notify the GUI that the Rust-owned Direct active-turn registry changed.
|
||||
///
|
||||
/// The payload is intentionally only a monotonic revision. The GUI must read
|
||||
/// the authoritative snapshot through `list_game_creator_direct_active_turns`
|
||||
/// and coalesce duplicate notifications while a read is in flight.
|
||||
pub(crate) fn emit_direct_active_turns_changed() {
|
||||
let revision = DIRECT_ACTIVE_TURNS_EVENT_REVISION.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
#[cfg(test)]
|
||||
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.fetch_add(1, Ordering::AcqRel);
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
let _ = app.emit(
|
||||
DIRECT_ACTIVE_TURNS_CHANGED_EVENT,
|
||||
serde_json::json!({ "revision": revision }),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn direct_active_turns_event_test_count() -> u64 {
|
||||
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) {
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
|
||||
@@ -48,6 +48,28 @@ pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued";
|
||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running";
|
||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed";
|
||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed";
|
||||
pub(crate) const ASSET_GENERATION_TASK_CHANGED_EVENT: &str =
|
||||
"game-creator-asset-generation-task-changed";
|
||||
|
||||
static ASSET_GENERATION_TASK_APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
|
||||
|
||||
pub(crate) fn set_asset_generation_task_app_handle(app: tauri::AppHandle) {
|
||||
let _ = ASSET_GENERATION_TASK_APP_HANDLE.set(app);
|
||||
}
|
||||
|
||||
fn emit_asset_generation_task_changed(root: &Path, task_id: &str) {
|
||||
let Some(app) = ASSET_GENERATION_TASK_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
let _ = tauri::Emitter::emit(
|
||||
app,
|
||||
ASSET_GENERATION_TASK_CHANGED_EVENT,
|
||||
serde_json::json!({
|
||||
"projectPath": root.to_string_lossy(),
|
||||
"taskId": task_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。";
|
||||
const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。";
|
||||
@@ -261,6 +283,10 @@ pub(crate) fn list_local_project_asset_generation_tasks(
|
||||
let registered = registered_asset_ids_by_local_path(root);
|
||||
if repair_interrupted_tasks(&mut tasks, ®istered) {
|
||||
write_ledger(root, &tasks)?;
|
||||
drop(_guard);
|
||||
for task in tasks.iter() {
|
||||
emit_asset_generation_task_changed(root, &task.task_id);
|
||||
}
|
||||
}
|
||||
Ok(tasks)
|
||||
}
|
||||
@@ -282,6 +308,8 @@ where
|
||||
mutate(record);
|
||||
let snapshot = record.clone();
|
||||
write_ledger(root, &tasks)?;
|
||||
drop(_guard);
|
||||
emit_asset_generation_task_changed(root, task_id);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
@@ -333,6 +361,8 @@ pub(crate) fn begin_local_project_asset_generation_task(
|
||||
tasks.push(record.clone());
|
||||
trim_ledger(&mut tasks);
|
||||
write_ledger(root, &tasks)?;
|
||||
drop(_guard);
|
||||
emit_asset_generation_task_changed(root, task_id);
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ use crate::agent::{
|
||||
DirectProjectHistoryAnchor,
|
||||
};
|
||||
use crate::ui_editor::resource::font::FontAsset;
|
||||
use base64::Engine as _;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
@@ -17,6 +18,7 @@ const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100;
|
||||
// imageSrc 与签名地址只在本文件的客户端下载阶段存在,绝不进入 Agent observation。
|
||||
const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500;
|
||||
const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512;
|
||||
const EDITOR_ASSET_PREVIEW_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000;
|
||||
const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64;
|
||||
// Project naming is an optional homepage enhancement. It must never hold the
|
||||
@@ -508,12 +510,17 @@ pub(crate) fn validate_requested_game_project_creation_root(
|
||||
Ok(root.to_path_buf())
|
||||
}
|
||||
|
||||
/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。
|
||||
/// 解析本次建项要使用的根目录:显式参数优先,其次读取 Rust 工作区偏好,最后回落 AGC 默认目录。
|
||||
pub(crate) fn resolve_game_project_creation_root(
|
||||
app: &tauri::AppHandle,
|
||||
requested: Option<&str>,
|
||||
) -> Result<PathBuf, String> {
|
||||
match requested.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let stored = requested
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| crate::workspace_preferences::stored_project_creation_directory(app));
|
||||
match stored.as_deref() {
|
||||
Some(requested) => validate_requested_game_project_creation_root(requested),
|
||||
None => automatic_local_game_projects_root(app),
|
||||
}
|
||||
@@ -1813,14 +1820,11 @@ pub(crate) async fn polish_local_project_prompt(
|
||||
.map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_platform_account_session_state(
|
||||
) -> crate::platform_session::PlatformSessionWriteState {
|
||||
crate::platform_session::current_platform_session_write_state()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_platform_account_session(
|
||||
/// 安装客户端会话:先传播到本地 Runner,再写本进程会话快照。
|
||||
///
|
||||
/// Tauri 命令与 Rust 认证会话(`auth_session`)共用这一条路径,避免出现「命令装好了、
|
||||
/// 认证链路没装」这种半状态。
|
||||
pub(crate) async fn install_client_session_locally(
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
api_base_url: String,
|
||||
@@ -1854,8 +1858,8 @@ pub(crate) async fn install_platform_account_session(
|
||||
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn clear_platform_account_session(
|
||||
/// 清除客户端会话:先停掉本机会话相关的 app-server,再清 Runner 与本进程快照。
|
||||
pub(crate) async fn clear_client_session_locally(
|
||||
identity_generation: u64,
|
||||
revision: u64,
|
||||
) -> Result<(), String> {
|
||||
@@ -1869,6 +1873,9 @@ pub(crate) async fn clear_platform_account_session(
|
||||
.map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))?
|
||||
}
|
||||
|
||||
// 会话安装/清除只由 Rust 认证会话(`auth_session`)调用 `install_client_session_locally`
|
||||
// 与 `clear_client_session_locally`;渲染层不再持有写入入口。
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView, String> {
|
||||
game_creator_app_config_view(load_game_creator_app_config()?)
|
||||
@@ -2983,6 +2990,98 @@ mod agent_asset_import_tests {
|
||||
assert!(parse_agent_editor_project_resources(&payload, "project-other").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_asset_library_snapshot_exposes_only_ui_safe_projection() {
|
||||
let records = vec![AgentEditorAssetRecord {
|
||||
asset_id: "asset-1".to_string(),
|
||||
origin: AgentEditorAssetOrigin::AccountLibrary,
|
||||
canvas_project_id: None,
|
||||
folder_id: Some("folder-1".to_string()),
|
||||
folder_label: Some("角色".to_string()),
|
||||
label: "英雄".to_string(),
|
||||
object_key: Some("private/hero.png".to_string()),
|
||||
image_src: Some("/private/hero.png".to_string()),
|
||||
asset_object_id: Some("object-1".to_string()),
|
||||
asset_kind: Some("character".to_string()),
|
||||
source_type: Some("uploaded".to_string()),
|
||||
width: Some(64),
|
||||
height: Some(64),
|
||||
size_bytes: Some(128),
|
||||
}];
|
||||
|
||||
let snapshot = editor_asset_library_snapshot_from_records(&records);
|
||||
assert_eq!(snapshot.folders[0].folder_id, "folder-1");
|
||||
assert_eq!(snapshot.assets[0].asset_id, "asset-1");
|
||||
assert!(snapshot.assets[0].preview_available);
|
||||
let serialized = serde_json::to_string(&snapshot).expect("serialize snapshot");
|
||||
assert!(!serialized.contains("private/hero.png"));
|
||||
assert!(!serialized.contains("object-1"));
|
||||
assert!(!serialized.contains("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_asset_read_url_uses_stable_object_reference_and_account_route() {
|
||||
let session = PlatformSessionSnapshot {
|
||||
user_id: "user-1".to_string(),
|
||||
access_token: "token-1".to_string(),
|
||||
api_base_url: "https://dev.genarrative.world".to_string(),
|
||||
identity_generation: 1,
|
||||
revision: 1,
|
||||
};
|
||||
let access = ExternalEditorBindingAccess::for_platform(&session).expect("platform access");
|
||||
let record = AgentEditorAssetRecord {
|
||||
asset_id: "asset-1".to_string(),
|
||||
origin: AgentEditorAssetOrigin::AccountLibrary,
|
||||
canvas_project_id: None,
|
||||
folder_id: None,
|
||||
folder_label: None,
|
||||
label: "英雄".to_string(),
|
||||
object_key: Some("private/hero image.png".to_string()),
|
||||
image_src: None,
|
||||
asset_object_id: None,
|
||||
asset_kind: Some("image".to_string()),
|
||||
source_type: None,
|
||||
width: None,
|
||||
height: None,
|
||||
size_bytes: None,
|
||||
};
|
||||
assert_eq!(
|
||||
editor_asset_read_url(&access, &record).expect("read URL"),
|
||||
"https://dev.genarrative.world/api/assets/read-url?objectKey=private%2Fhero%20image.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_asset_preview_data_url_is_bounded_and_typed() {
|
||||
assert_eq!(
|
||||
editor_asset_preview_data_url("image/png", &tiny_png()).expect("data URL"),
|
||||
"data:image/png;base64,iVBORw0KGgo="
|
||||
);
|
||||
assert!(editor_asset_preview_data_url("application/octet-stream", b"x").is_err());
|
||||
assert!(editor_asset_preview_data_url(
|
||||
"image/png",
|
||||
&vec![0_u8; EDITOR_ASSET_PREVIEW_MAX_BYTES + 1]
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_remote_import_accepts_only_bounded_image_data_urls() {
|
||||
let bytes = download_ui_editor_remote_asset(
|
||||
"data:image/png;base64,iVBORw0KGgo=",
|
||||
EDITOR_ASSET_PREVIEW_MAX_BYTES as u64,
|
||||
)
|
||||
.await
|
||||
.expect("decode preview data URL");
|
||||
assert_eq!(bytes, tiny_png());
|
||||
assert!(download_ui_editor_remote_asset(
|
||||
"data:text/plain;base64,eA==",
|
||||
EDITOR_ASSET_PREVIEW_MAX_BYTES as u64,
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_asset_import_registers_multiple_types_and_is_idempotent() {
|
||||
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
|
||||
@@ -3380,6 +3479,39 @@ struct AgentEditorAssetRecord {
|
||||
size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EditorAssetLibraryFolderView {
|
||||
pub(crate) folder_id: String,
|
||||
pub(crate) label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EditorAssetLibraryAssetView {
|
||||
pub(crate) asset_id: String,
|
||||
pub(crate) folder_id: String,
|
||||
pub(crate) label: String,
|
||||
pub(crate) asset_kind: Option<String>,
|
||||
pub(crate) width: Option<u32>,
|
||||
pub(crate) height: Option<u32>,
|
||||
pub(crate) size_bytes: Option<u64>,
|
||||
pub(crate) preview_available: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EditorAssetLibrarySnapshot {
|
||||
pub(crate) folders: Vec<EditorAssetLibraryFolderView>,
|
||||
pub(crate) assets: Vec<EditorAssetLibraryAssetView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EditorAssetPreview {
|
||||
pub(crate) preview_url: String,
|
||||
}
|
||||
|
||||
fn bounded_agent_editor_asset_id(value: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty()
|
||||
@@ -3761,6 +3893,155 @@ async fn fetch_agent_editor_asset_library() -> Result<
|
||||
fetch_agent_editor_asset_records(None).await
|
||||
}
|
||||
|
||||
fn editor_asset_library_snapshot_from_records(
|
||||
records: &[AgentEditorAssetRecord],
|
||||
) -> EditorAssetLibrarySnapshot {
|
||||
let mut folders = BTreeMap::<String, String>::new();
|
||||
for record in records {
|
||||
let folder_id = record
|
||||
.folder_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("__uncategorized__")
|
||||
.to_string();
|
||||
let label = record
|
||||
.folder_label
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("未分类")
|
||||
.to_string();
|
||||
folders.entry(folder_id).or_insert(label);
|
||||
}
|
||||
let folders = folders
|
||||
.into_iter()
|
||||
.map(|(folder_id, label)| EditorAssetLibraryFolderView { folder_id, label })
|
||||
.collect();
|
||||
let assets = records
|
||||
.iter()
|
||||
.map(|record| EditorAssetLibraryAssetView {
|
||||
asset_id: record.asset_id.clone(),
|
||||
folder_id: record
|
||||
.folder_id
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "__uncategorized__".to_string()),
|
||||
label: record.label.clone(),
|
||||
asset_kind: record.asset_kind.clone(),
|
||||
width: record.width,
|
||||
height: record.height,
|
||||
size_bytes: record.size_bytes,
|
||||
preview_available: record.object_key.is_some()
|
||||
|| record
|
||||
.image_src
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.starts_with('/')),
|
||||
})
|
||||
.collect();
|
||||
EditorAssetLibrarySnapshot { folders, assets }
|
||||
}
|
||||
|
||||
fn editor_asset_read_url(
|
||||
access: &ExternalEditorBindingAccess<'_>,
|
||||
record: &AgentEditorAssetRecord,
|
||||
) -> Result<String, String> {
|
||||
let (field, value) = if let Some(object_key) = record
|
||||
.object_key
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
("objectKey", object_key)
|
||||
} else if let Some(image_src) = record
|
||||
.image_src
|
||||
.as_deref()
|
||||
.filter(|value| value.starts_with('/'))
|
||||
{
|
||||
("legacyPublicPath", image_src)
|
||||
} else {
|
||||
return Err("平台素材缺少稳定预览引用".to_string());
|
||||
};
|
||||
let route = access.api_route("/api/external/v1/assets/read-url");
|
||||
Ok(format!(
|
||||
"{}{}?{}={}",
|
||||
access.api_base_url(),
|
||||
route,
|
||||
field,
|
||||
percent_encode_query_component(value)
|
||||
))
|
||||
}
|
||||
|
||||
fn editor_asset_preview_data_url(media_type: &str, bytes: &[u8]) -> Result<String, String> {
|
||||
let media_type = media_type
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or(media_type)
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if !media_type.starts_with("image/") {
|
||||
return Err("平台素材预览不是受支持的图片类型".to_string());
|
||||
}
|
||||
if bytes.is_empty() || bytes.len() > EDITOR_ASSET_PREVIEW_MAX_BYTES {
|
||||
return Err("平台素材预览超过安全大小限制".to_string());
|
||||
}
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
Ok(format!("data:{media_type};base64,{encoded}"))
|
||||
}
|
||||
|
||||
/// 返回账户素材库给渲染层的安全展示投影;不返回 objectKey、imageSrc、签名 URL、
|
||||
/// access token 或本地绝对路径。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_editor_asset_library() -> Result<EditorAssetLibrarySnapshot, String> {
|
||||
let (_api_base_url, _bearer_token, _session, records) =
|
||||
fetch_agent_editor_asset_library().await?;
|
||||
Ok(editor_asset_library_snapshot_from_records(&records))
|
||||
}
|
||||
|
||||
/// 按当前登录账号的稳定 assetId 换取有界 data URL。渲染层不自行拼 read-url,
|
||||
/// 不接触对象键或 bearer token,也不会再由 WebView 直接请求签名地址;远程导入暂时
|
||||
/// 仍复用旧 downloadUrl 入参,但地址来自本命令返回的 data URL。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_editor_asset_preview(
|
||||
asset_id: String,
|
||||
) -> Result<EditorAssetPreview, String> {
|
||||
let asset_id = bounded_agent_editor_asset_id(&asset_id)?;
|
||||
let (api_base_url, bearer_token, frozen_session, records) =
|
||||
fetch_agent_editor_asset_library().await?;
|
||||
let record = records
|
||||
.into_iter()
|
||||
.find(|record| record.asset_id == asset_id)
|
||||
.ok_or_else(|| "账户素材不存在、已删除或不属于当前登录账号".to_string())?;
|
||||
let access =
|
||||
ExternalEditorBindingAccess::new(&api_base_url, &bearer_token, frozen_session.as_ref())?;
|
||||
let client = crate::http_client::agc_main_site_client_builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| format!("创建素材预览客户端失败:{error}"))?;
|
||||
access.validate_frozen_session()?;
|
||||
// Validate that this record has an owner-scoped stable read reference
|
||||
// before handing it to the bounded downloader below.
|
||||
let _ = editor_asset_read_url(&access, &record)?;
|
||||
let source = serde_json::json!({
|
||||
"objectKey": record.object_key.as_deref(),
|
||||
"imageSrc": record.image_src.as_deref(),
|
||||
});
|
||||
let read_url_route = access.api_route("/api/external/v1/assets/read-url");
|
||||
let download = crate::assets::resolve_canvas_resource_download_with_limit_route_and_fence(
|
||||
&client,
|
||||
access.api_base_url(),
|
||||
access.bearer_token(),
|
||||
&source,
|
||||
EDITOR_ASSET_PREVIEW_MAX_BYTES,
|
||||
&read_url_route,
|
||||
|| access.validate_frozen_session(),
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| "平台素材预览内容为空".to_string())?;
|
||||
let preview_url = editor_asset_preview_data_url(&download.media_type, &download.bytes)?;
|
||||
access.validate_frozen_session()?;
|
||||
Ok(EditorAssetPreview { preview_url })
|
||||
}
|
||||
|
||||
/// 给普通 Agent/Direct Codex 的账户素材安全投影。只返回业务 ID 与展示元数据,
|
||||
/// 不返回 objectKey、imageSrc、signedUrl、绝对路径、provider 或凭据。
|
||||
pub(crate) async fn list_account_editor_assets_for_agent() -> Result<serde_json::Value, String> {
|
||||
@@ -4714,6 +4995,28 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result<Ve
|
||||
if max_bytes == 0 {
|
||||
return Err("图片批次总量已达到服务端限制".to_string());
|
||||
}
|
||||
if let Some(data_url) = url.strip_prefix("data:") {
|
||||
let (header, encoded) = data_url
|
||||
.split_once(',')
|
||||
.ok_or_else(|| "平台素材 data URL 格式无效".to_string())?;
|
||||
let media_type = header
|
||||
.strip_suffix(";base64")
|
||||
.filter(|value| {
|
||||
matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"image/png" | "image/jpeg" | "image/webp"
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| "平台素材 data URL 类型不受支持".to_string())?;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|_| "平台素材 data URL 内容无效".to_string())?;
|
||||
if bytes.is_empty() || bytes.len() as u64 > max_bytes {
|
||||
return Err("平台素材超过当前图片下载限制".to_string());
|
||||
}
|
||||
let _ = media_type;
|
||||
return Ok(bytes);
|
||||
}
|
||||
let parsed = validate_external_asset_download_url(url, "", false)?;
|
||||
let client = build_external_asset_download_client(&parsed, "", false).await?;
|
||||
let mut response = client
|
||||
@@ -5760,7 +6063,17 @@ pub(crate) async fn subscribe_direct_project_thread(
|
||||
let thread_id = direct_thread_id_for_project(root);
|
||||
let mut bootstrap = subscribe_direct_thread(&thread_id);
|
||||
if bootstrap.last_completed_item_id.is_none() {
|
||||
bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?;
|
||||
match read_direct_project_last_item_id_at(root) {
|
||||
Ok(last_completed_item_id) => {
|
||||
bootstrap.last_completed_item_id = last_completed_item_id;
|
||||
}
|
||||
Err(error) => {
|
||||
// 订阅已经登记,后续锚点读取失败时也必须回收 native
|
||||
// subscriber;否则前端拿不到 subscriptionId,无法自行清理。
|
||||
unsubscribe_direct_thread(&bootstrap.subscription_id);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(bootstrap)
|
||||
})
|
||||
@@ -5775,6 +6088,12 @@ pub(crate) fn consume_direct_project_thread(
|
||||
consume_direct_thread(subscription_id.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn unsubscribe_direct_project_thread(subscription_id: String) -> Result<(), String> {
|
||||
unsubscribe_direct_thread(subscription_id.trim());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读一屏项目对话历史。
|
||||
///
|
||||
/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含
|
||||
@@ -5988,6 +6307,7 @@ pub(crate) async fn upload_local_project_game_package(
|
||||
api_base_url: api_base_url.trim(),
|
||||
access_token: access_token.trim(),
|
||||
idempotency_key: idempotency_key.trim(),
|
||||
session_identity: None,
|
||||
},
|
||||
move |received_bytes, total_bytes| {
|
||||
let _ = emit_handle.emit(
|
||||
|
||||
@@ -2,7 +2,9 @@ mod commands;
|
||||
mod notifications;
|
||||
mod queue;
|
||||
mod sanitize;
|
||||
mod submit;
|
||||
|
||||
pub use commands::{ack_error_reports, get_pending_error_reports, report_client_error};
|
||||
pub use notifications::initialize_notifications;
|
||||
pub use queue::{report_agent_runtime_error, report_diagnostic_error};
|
||||
pub use submit::submit_error_report;
|
||||
|
||||
@@ -91,6 +91,10 @@ mod tests {
|
||||
let sanitized = sanitize(value, 512);
|
||||
assert!(!sanitized.contains("secret"));
|
||||
assert!(!sanitized.contains("example.test"));
|
||||
// 这条断言专门钉住 URL 规则本身:没有它时,禁用 redact_urls 也能靠
|
||||
// PATH_PATTERN 的大小写不敏感盘符分支(`http://` 里的 `p:`)把 URL 吞掉,
|
||||
// 于是「URL 被替换成占位符」这件事其实没有被断言到。
|
||||
assert!(sanitized.contains("<url>"));
|
||||
assert!(!sanitized.contains("/home/alice"));
|
||||
assert!(!sanitized.contains("deadbeef12"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! 错误报告提交 facade。
|
||||
//!
|
||||
//! 渲染层只提交脱敏后的错误事件、可选的用户说明与诊断日志;平台会话、origin、
|
||||
//! 提交幂等标识、响应 envelope 解析和错误分类都在 Rust 内完成。React 不再读取
|
||||
//! access token、拼接远端 URL 或自行生成稳定 submissionId。
|
||||
|
||||
use crate::http_client::agc_main_site_client_builder;
|
||||
use crate::platform_session::{
|
||||
current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_contracts::error_reports::{CreateErrorReportBatchRequest, ErrorReportLogInput, Event};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// 与 api-server 的请求上限同口径,避免把明显超限的载荷发到网络层。
|
||||
const MAX_EVENTS: usize = 100;
|
||||
const MAX_LOGS: usize = 5;
|
||||
const MAX_DESCRIPTION_CHARS: usize = 2_000;
|
||||
const ERROR_REPORT_PATH: &[&str] = &["api", "error-reports"];
|
||||
const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope";
|
||||
const API_RESPONSE_ENVELOPE_VERSION: &str = "v1";
|
||||
const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client";
|
||||
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
|
||||
|
||||
fn require_session() -> Result<PlatformSessionSnapshot, String> {
|
||||
current_platform_session()
|
||||
.ok_or_else(|| "authentication-required: 请先登录后再提交错误报告".to_string())
|
||||
}
|
||||
|
||||
fn validate_session(snapshot: &PlatformSessionSnapshot) -> Result<(), String> {
|
||||
validate_platform_session_identity(&snapshot.identity())
|
||||
}
|
||||
|
||||
fn endpoint(snapshot: &PlatformSessionSnapshot) -> Result<String, String> {
|
||||
let mut url = Url::parse(&format!("{}/", snapshot.api_base_url.trim_end_matches('/')))
|
||||
.map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||||
for segment in ERROR_REPORT_PATH {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// 稳定 submissionId:只由本批事件的稳定 ID 决定。
|
||||
///
|
||||
/// 同一批事件反复提交必须命中服务端 `userId + submissionId` 幂等键;事件集合变化
|
||||
/// 就是另一批,不会误判成重放。该标识不进入任何用户可见文案。
|
||||
fn stable_submission_id(events: &[Event]) -> String {
|
||||
let mut ids = events
|
||||
.iter()
|
||||
.map(|event| event.event_id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
ids.sort_unstable();
|
||||
let mut digest = Sha256::new();
|
||||
for id in ids {
|
||||
digest.update(id.as_bytes());
|
||||
digest.update([0]);
|
||||
}
|
||||
let hex = format!("{:x}", digest.finalize());
|
||||
format!("agc-error-report-{}", &hex[..32])
|
||||
}
|
||||
|
||||
fn response_error(status: StatusCode, body: &str) -> String {
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string();
|
||||
}
|
||||
if status == StatusCode::FORBIDDEN {
|
||||
return "permission-denied: 当前账号没有提交错误报告的权限".to_string();
|
||||
}
|
||||
let detail = serde_json::from_str::<Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("error")
|
||||
.and_then(|error| error.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
format!("错误报告提交失败:{detail}")
|
||||
}
|
||||
|
||||
fn response_data(body: &str) -> Result<Value, String> {
|
||||
let value: Value = serde_json::from_str(body)
|
||||
.map_err(|_| "错误报告提交失败:响应不是合法 JSON".to_string())?;
|
||||
Ok(value.get("data").cloned().unwrap_or(value))
|
||||
}
|
||||
|
||||
fn validate_payload(
|
||||
events: &[Event],
|
||||
logs: &[ErrorReportLogInput],
|
||||
user_description: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
if events.is_empty() || events.len() > MAX_EVENTS {
|
||||
return Err(format!("报告事件数量必须在 1 到 {MAX_EVENTS} 之间"));
|
||||
}
|
||||
if logs.len() > MAX_LOGS {
|
||||
return Err(format!("日志附件最多 {MAX_LOGS} 个"));
|
||||
}
|
||||
if user_description.is_some_and(|value| value.chars().count() > MAX_DESCRIPTION_CHARS) {
|
||||
return Err(format!("补充说明不能超过 {MAX_DESCRIPTION_CHARS} 个字符"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn submit_error_report(
|
||||
events: Vec<Event>,
|
||||
logs: Vec<ErrorReportLogInput>,
|
||||
user_description: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
validate_payload(&events, &logs, user_description.as_deref())?;
|
||||
let snapshot = require_session()?;
|
||||
let payload = CreateErrorReportBatchRequest {
|
||||
schema_version: 1,
|
||||
submission_id: stable_submission_id(&events),
|
||||
events,
|
||||
user_description: user_description
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
logs,
|
||||
};
|
||||
let client = agc_main_site_client_builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|_| "创建错误报告客户端失败".to_string())?;
|
||||
validate_session(&snapshot)?;
|
||||
let response = client
|
||||
.post(endpoint(&snapshot)?)
|
||||
.bearer_auth(&snapshot.access_token)
|
||||
.header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE)
|
||||
.header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| "错误报告提交失败:无法连接登录服务,请稍后重试".to_string())?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|_| "错误报告提交失败:读取响应失败".to_string())?;
|
||||
validate_session(&snapshot)?;
|
||||
if !status.is_success() {
|
||||
return Err(response_error(status, &body));
|
||||
}
|
||||
response_data(&body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn event(event_id: &str) -> Event {
|
||||
Event {
|
||||
event_id: event_id.to_string(),
|
||||
fingerprint: format!("fingerprint-{event_id}"),
|
||||
source: "test".to_string(),
|
||||
message: "boom".to_string(),
|
||||
stack: None,
|
||||
occurred_at: "1".to_string(),
|
||||
count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submission_id_is_stable_and_order_independent() {
|
||||
let first = stable_submission_id(&[event("a"), event("b")]);
|
||||
let second = stable_submission_id(&[event("b"), event("a")]);
|
||||
assert_eq!(first, second);
|
||||
assert!(first.starts_with("agc-error-report-"));
|
||||
assert_ne!(first, stable_submission_id(&[event("a")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_bounds_are_rejected_before_network() {
|
||||
assert!(validate_payload(&[], &[], None).is_err());
|
||||
assert!(validate_payload(&[event("a")], &[], None).is_ok());
|
||||
let logs = (0..MAX_LOGS + 1)
|
||||
.map(|index| ErrorReportLogInput {
|
||||
name: format!("log-{index}.log"),
|
||||
content: String::new(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(validate_payload(&[event("a")], &logs, None).is_err());
|
||||
let long = "字".repeat(MAX_DESCRIPTION_CHARS + 1);
|
||||
assert!(validate_payload(&[event("a")], &[], Some(&long)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_keeps_origin_and_route() {
|
||||
let snapshot = PlatformSessionSnapshot {
|
||||
user_id: "user-1".to_string(),
|
||||
access_token: "token".to_string(),
|
||||
api_base_url: "https://dev.genarrative.world".to_string(),
|
||||
identity_generation: 1,
|
||||
revision: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
endpoint(&snapshot).expect("endpoint"),
|
||||
"https://dev.genarrative.world/api/error-reports"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_data_is_unwrapped_without_credentials() {
|
||||
let value =
|
||||
response_data(r#"{"ok":true,"data":{"batchId":"batch-1"}}"#).expect("envelope data");
|
||||
assert_eq!(value["batchId"], "batch-1");
|
||||
assert!(!value.to_string().contains("token"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,9 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::platform_session::{
|
||||
current_platform_session, validate_platform_session_identity, PlatformSessionIdentity,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -346,6 +349,20 @@ pub(crate) struct GamePackageUploadRequest<'a> {
|
||||
pub(crate) api_base_url: &'a str,
|
||||
pub(crate) access_token: &'a str,
|
||||
pub(crate) idempotency_key: &'a str,
|
||||
pub(crate) session_identity: Option<&'a PlatformSessionIdentity>,
|
||||
}
|
||||
|
||||
impl<'a> GamePackageUploadRequest<'a> {
|
||||
fn current_access_token(&self) -> Result<String, String> {
|
||||
let Some(expected) = self.session_identity else {
|
||||
return Ok(self.access_token.to_string());
|
||||
};
|
||||
let session = current_platform_session().ok_or_else(|| {
|
||||
"authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string()
|
||||
})?;
|
||||
validate_platform_session_identity(expected)?;
|
||||
Ok(session.access_token)
|
||||
}
|
||||
}
|
||||
|
||||
/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。
|
||||
@@ -360,11 +377,12 @@ pub(crate) async fn upload_staged_game_package(
|
||||
if total_bytes == 0 {
|
||||
return Err("发行包暂存文件为空,请重新导出试玩包".to_string());
|
||||
}
|
||||
let access_token = request.current_access_token()?;
|
||||
let state = read_upload_state(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
&access_token,
|
||||
)
|
||||
.await?;
|
||||
if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes {
|
||||
@@ -386,11 +404,12 @@ pub(crate) async fn upload_staged_game_package(
|
||||
let body = read_chunk(&mut file, plan)?;
|
||||
let mut attempt = 1_usize;
|
||||
loop {
|
||||
let access_token = request.current_access_token()?;
|
||||
match upload_chunk(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
&access_token,
|
||||
request.idempotency_key,
|
||||
plan,
|
||||
body.clone(),
|
||||
@@ -413,21 +432,23 @@ pub(crate) async fn upload_staged_game_package(
|
||||
}
|
||||
}
|
||||
// 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。
|
||||
let access_token = request.current_access_token()?;
|
||||
let authoritative = read_upload_state(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
&access_token,
|
||||
)
|
||||
.await?;
|
||||
received = authoritative.received_bytes.min(total_bytes);
|
||||
on_progress(received, total_bytes);
|
||||
}
|
||||
let access_token = request.current_access_token()?;
|
||||
let mut outcome = complete_upload(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
&access_token,
|
||||
request.idempotency_key,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
use crate::http_client::{agc_main_site_client_builder, with_agc_main_site_marker};
|
||||
use crate::platform_session::{
|
||||
current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER;
|
||||
use shared_contracts::llm::{LlmModelSummary, LlmModelsResponse};
|
||||
use std::time::Duration;
|
||||
|
||||
const API_RESPONSE_ENVELOPE_VERSION: &str = "v1";
|
||||
const MODEL_CATALOG_MAX_BYTES: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireModelSummary {
|
||||
id: String,
|
||||
display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireModelCatalog {
|
||||
default_model_id: String,
|
||||
models: Vec<WireModelSummary>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiErrorPayload {
|
||||
code: Option<String>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiEnvelope {
|
||||
ok: bool,
|
||||
data: Option<WireModelCatalog>,
|
||||
error: Option<ApiErrorPayload>,
|
||||
}
|
||||
|
||||
fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse {
|
||||
LlmModelsResponse {
|
||||
default_model_id: catalog.default_model_id,
|
||||
models: catalog
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|model| LlmModelSummary {
|
||||
id: model.id,
|
||||
display_name: model.display_name,
|
||||
})
|
||||
.collect(),
|
||||
revision: catalog.revision,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_catalog_payload(bytes: &[u8]) -> Result<LlmModelsResponse, String> {
|
||||
let value: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?;
|
||||
|
||||
// The platform API normally returns the versioned response envelope. Keep
|
||||
// accepting the raw payload for older dev proxies and local fixtures, but
|
||||
// never accept an error envelope as a successful catalog.
|
||||
if value.get("ok").is_some() {
|
||||
let envelope: ApiEnvelope = serde_json::from_value(value)
|
||||
.map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())?;
|
||||
if !envelope.ok {
|
||||
let message = envelope
|
||||
.error
|
||||
.as_ref()
|
||||
.and_then(|error| error.message.as_deref())
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.or_else(|| {
|
||||
envelope
|
||||
.error
|
||||
.as_ref()
|
||||
.and_then(|error| error.code.as_deref())
|
||||
.filter(|code| !code.trim().is_empty())
|
||||
})
|
||||
.unwrap_or("模型列表读取失败");
|
||||
return Err(message.to_string());
|
||||
}
|
||||
return envelope
|
||||
.data
|
||||
.map(map_catalog)
|
||||
.ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string());
|
||||
}
|
||||
|
||||
serde_json::from_value::<WireModelCatalog>(value)
|
||||
.map(map_catalog)
|
||||
.map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())
|
||||
}
|
||||
|
||||
async fn read_bounded_body(
|
||||
response: reqwest::Response,
|
||||
max_bytes: usize,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > max_bytes as u64)
|
||||
{
|
||||
return Err("模型列表响应超过 1 MiB 上限".to_string());
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream
|
||||
.next()
|
||||
.await
|
||||
.transpose()
|
||||
.map_err(|_| "读取模型列表响应失败或超时,请重试".to_string())?
|
||||
{
|
||||
if body.len().saturating_add(chunk.len()) > max_bytes {
|
||||
return Err("模型列表响应超过 1 MiB 上限".to_string());
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn fetch_game_creator_llm_models(
|
||||
client: &reqwest::Client,
|
||||
session: &PlatformSessionSnapshot,
|
||||
) -> Result<LlmModelsResponse, String> {
|
||||
let api_base_url = session.api_base_url.trim_end_matches('/');
|
||||
if api_base_url.is_empty() {
|
||||
return Err("authentication-required: 平台服务地址缺失,请重新登录".to_string());
|
||||
}
|
||||
let current_session = current_platform_session()
|
||||
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?;
|
||||
validate_platform_session_identity(&session.identity())?;
|
||||
let response = with_agc_main_site_marker(
|
||||
client
|
||||
.get(format!("{api_base_url}/api/llm/models"))
|
||||
.bearer_auth(¤t_session.access_token)
|
||||
.header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
"模型列表请求超时,请重试".to_string()
|
||||
} else {
|
||||
"无法连接模型服务,请检查网络后重试".to_string()
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body = read_bounded_body(response, MODEL_CATALOG_MAX_BYTES).await?;
|
||||
// A session switch during an in-flight request invalidates the old result;
|
||||
// token rotation within the same identity remains valid.
|
||||
validate_platform_session_identity(&session.identity())?;
|
||||
if !status.is_success() {
|
||||
let detail = parse_catalog_payload(&body).err();
|
||||
return Err(detail
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.map(|message| format!("模型列表读取失败(HTTP {}):{message}", status.as_u16()))
|
||||
.unwrap_or_else(|| format!("模型列表读取失败(HTTP {})", status.as_u16())));
|
||||
}
|
||||
parse_catalog_payload(&body)
|
||||
}
|
||||
|
||||
/// Read the official account-scoped model catalog in Rust.
|
||||
///
|
||||
/// React receives a typed snapshot through Tauri and does not hold the
|
||||
/// platform token or issue the HTTP request itself. Custom model catalogs are
|
||||
/// still handled by the local configuration path in llmModelCatalog.ts.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn load_game_creator_llm_models() -> Result<LlmModelsResponse, String> {
|
||||
let session = current_platform_session()
|
||||
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?;
|
||||
let client = agc_main_site_client_builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|_| "初始化模型列表请求失败".to_string())?;
|
||||
fetch_game_creator_llm_models(&client, &session).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::platform_session::{current_platform_session, install_test_platform_session};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
fn read_request(stream: &mut TcpStream) -> String {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.expect("set fixture timeout");
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let count = stream.read(&mut buffer).expect("read fixture request");
|
||||
assert!(count > 0, "fixture request closed before headers");
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if bytes.windows(4).any(|part| part == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
fn fixture_response(
|
||||
listener: TcpListener,
|
||||
status: &'static str,
|
||||
body: String,
|
||||
) -> thread::JoinHandle<String> {
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept fixture request");
|
||||
let request = read_request(&mut stream);
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write fixture response");
|
||||
request
|
||||
})
|
||||
}
|
||||
|
||||
fn test_client() -> reqwest::Client {
|
||||
agc_main_site_client_builder()
|
||||
.connect_timeout(Duration::from_secs(2))
|
||||
.timeout(Duration::from_secs(2))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("build fixture client")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_catalog_uses_platform_session_and_unwraps_envelope() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture");
|
||||
let address = listener.local_addr().expect("fixture address");
|
||||
let body = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": {
|
||||
"defaultModelId": "quality",
|
||||
"models": [{"id": "quality", "displayName": "高质量"}],
|
||||
"revision": 7
|
||||
},
|
||||
"error": null,
|
||||
"meta": {"apiVersion": "2026-06-16"}
|
||||
})
|
||||
.to_string();
|
||||
let server = fixture_response(listener, "200 OK", body);
|
||||
let _session = install_test_platform_session(
|
||||
"catalog-user",
|
||||
"catalog-token",
|
||||
&format!("http://{address}"),
|
||||
);
|
||||
|
||||
let session = current_platform_session().expect("test platform session");
|
||||
let catalog = fetch_game_creator_llm_models(&test_client(), &session)
|
||||
.await
|
||||
.expect("catalog request");
|
||||
assert_eq!(catalog.default_model_id, "quality");
|
||||
assert_eq!(catalog.revision, 7);
|
||||
assert_eq!(catalog.models[0].display_name, "高质量");
|
||||
let request = server.join().expect("join fixture");
|
||||
assert!(request.starts_with("GET /api/llm/models HTTP/1.1"));
|
||||
assert!(request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer catalog-token"));
|
||||
assert!(request
|
||||
.to_ascii_lowercase()
|
||||
.contains("x-genarrative-client: agc"));
|
||||
assert!(request
|
||||
.to_ascii_lowercase()
|
||||
.contains("x-genarrative-response-envelope: v1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture");
|
||||
let address = listener.local_addr().expect("fixture address");
|
||||
let body = serde_json::json!({
|
||||
"ok": false,
|
||||
"data": null,
|
||||
"error": {"code": "UNAUTHORIZED", "message": "登录态已失效"},
|
||||
"meta": {"apiVersion": "2026-06-16"}
|
||||
})
|
||||
.to_string();
|
||||
let server = fixture_response(listener, "401 Unauthorized", body);
|
||||
let _session = install_test_platform_session(
|
||||
"catalog-user",
|
||||
"catalog-token",
|
||||
&format!("http://{address}"),
|
||||
);
|
||||
|
||||
let session = current_platform_session().expect("test platform session");
|
||||
let error = fetch_game_creator_llm_models(&test_client(), &session)
|
||||
.await
|
||||
.expect_err("unauthorized catalog must fail");
|
||||
assert!(error.contains("HTTP 401"), "{error}");
|
||||
assert!(error.contains("登录态已失效"), "{error}");
|
||||
server.join().expect("join fixture");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_catalog_rejects_oversized_response() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture");
|
||||
let address = listener.local_addr().expect("fixture address");
|
||||
let body = "x".repeat(MODEL_CATALOG_MAX_BYTES + 1);
|
||||
let server = fixture_response(listener, "200 OK", body);
|
||||
let _session = install_test_platform_session(
|
||||
"catalog-user",
|
||||
"catalog-token",
|
||||
&format!("http://{address}"),
|
||||
);
|
||||
|
||||
let session = current_platform_session().expect("test platform session");
|
||||
let error = fetch_game_creator_llm_models(&test_client(), &session)
|
||||
.await
|
||||
.expect_err("oversized catalog must fail");
|
||||
assert!(error.contains("1 MiB"), "{error}");
|
||||
server.join().expect("join fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_platform_session_is_explicit() {
|
||||
let _session = crate::platform_session::clear_test_platform_session();
|
||||
let result = tauri::async_runtime::block_on(load_game_creator_llm_models());
|
||||
assert_eq!(
|
||||
result.expect_err("missing session must fail"),
|
||||
"authentication-required: 请先登录陶泥儿账号"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -107,11 +107,13 @@ fn register_non_canonical_asset_kind_reporter() {
|
||||
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
|
||||
include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs"));
|
||||
|
||||
mod account_api;
|
||||
mod agent;
|
||||
mod agent_native_tools;
|
||||
mod analytics;
|
||||
mod asset_generation_tasks;
|
||||
mod assets;
|
||||
mod auth_session;
|
||||
mod browser;
|
||||
mod builtin_plugins;
|
||||
mod cli;
|
||||
@@ -132,13 +134,16 @@ mod editor_adapter;
|
||||
mod editor_adapters;
|
||||
mod environment_check;
|
||||
pub mod error_report;
|
||||
mod game_distribution_publish;
|
||||
mod game_package_upload;
|
||||
mod git_inspect;
|
||||
mod goal;
|
||||
mod http_client;
|
||||
mod image_inspect;
|
||||
mod isolated_agent;
|
||||
mod llm_catalog;
|
||||
mod patchset;
|
||||
mod platform_asset_upload;
|
||||
mod platform_session;
|
||||
mod plugin_host;
|
||||
mod preview;
|
||||
@@ -156,12 +161,15 @@ mod template_library;
|
||||
mod tool_plan_handoff;
|
||||
mod user_input;
|
||||
mod windows;
|
||||
mod workspace_preferences;
|
||||
|
||||
use account_api::*;
|
||||
use agent::design_tools::*;
|
||||
use agent::*;
|
||||
use agent_native_tools::*;
|
||||
use asset_generation_tasks::*;
|
||||
use assets::*;
|
||||
use auth_session::*;
|
||||
use browser::*;
|
||||
use cli::*;
|
||||
use client_extensions::*;
|
||||
@@ -174,11 +182,14 @@ use config::*;
|
||||
use context_compaction::*;
|
||||
use delegation::*;
|
||||
use error_report::*;
|
||||
use game_distribution_publish::*;
|
||||
use git_inspect::*;
|
||||
use goal::*;
|
||||
use image_inspect::*;
|
||||
use isolated_agent::*;
|
||||
use llm_catalog::load_game_creator_llm_models;
|
||||
use patchset::*;
|
||||
use platform_asset_upload::upload_platform_media_asset;
|
||||
use platform_session::*;
|
||||
use plugin_host::{
|
||||
call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel,
|
||||
@@ -196,6 +207,7 @@ use runner::*;
|
||||
use template_library::*;
|
||||
use user_input::*;
|
||||
use windows::*;
|
||||
use workspace_preferences::*;
|
||||
#[tauri::command]
|
||||
async fn suggest_ui_design_semantic(
|
||||
project_path: String,
|
||||
@@ -2465,7 +2477,6 @@ fn main() {
|
||||
let app = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(context_menu::init())
|
||||
@@ -2582,6 +2593,8 @@ fn main() {
|
||||
setup_log.append("startup.runner.start.begin");
|
||||
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
|
||||
set_direct_thread_manager_app_handle(app.handle().clone());
|
||||
auth_session::initialize_auth_session(app.handle());
|
||||
set_asset_generation_task_app_handle(app.handle().clone());
|
||||
let manifest_event_sink =
|
||||
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink)
|
||||
@@ -2683,13 +2696,37 @@ fn main() {
|
||||
confirm_resume_game_creator_agent_runtime_tasks,
|
||||
schedule_game_creator_agent_ready_tasks,
|
||||
check_game_creator_llm_config,
|
||||
read_platform_account_session_state,
|
||||
install_platform_account_session,
|
||||
clear_platform_account_session,
|
||||
read_workspace_preferences,
|
||||
set_project_creation_directory,
|
||||
remember_recent_workspace,
|
||||
remove_recent_workspace,
|
||||
migrate_workspace_preferences,
|
||||
set_chat_prompt_polish_reminder_disabled,
|
||||
set_client_server_selection,
|
||||
read_profile_recharge_center,
|
||||
read_profile_wallet_ledger,
|
||||
create_profile_recharge_order,
|
||||
confirm_wechat_profile_recharge_order,
|
||||
redeem_profile_reward_code,
|
||||
read_client_auth_state,
|
||||
refresh_client_auth_session,
|
||||
send_client_phone_login_code,
|
||||
login_client_with_password,
|
||||
login_client_with_phone_code,
|
||||
logout_client_session,
|
||||
read_game_publish_availability,
|
||||
suggest_game_distribution_publish_metadata,
|
||||
read_game_cover_generation_price,
|
||||
generate_game_distribution_cover,
|
||||
publish_local_project_game,
|
||||
read_game_creator_app_config,
|
||||
write_game_creator_app_config,
|
||||
select_game_creator_model,
|
||||
load_game_creator_llm_models,
|
||||
discover_game_creator_llm_models,
|
||||
read_editor_asset_library,
|
||||
read_editor_asset_preview,
|
||||
upload_platform_media_asset,
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
create_ui_design_resource,
|
||||
@@ -2752,6 +2789,7 @@ fn main() {
|
||||
list_game_creator_direct_active_turns,
|
||||
subscribe_direct_project_thread,
|
||||
consume_direct_project_thread,
|
||||
unsubscribe_direct_project_thread,
|
||||
read_direct_project_history_slice,
|
||||
append_local_conversation_message,
|
||||
append_direct_project_conversation_message,
|
||||
@@ -2788,6 +2826,7 @@ fn main() {
|
||||
report_client_error,
|
||||
get_pending_error_reports,
|
||||
ack_error_reports,
|
||||
submit_error_report,
|
||||
sync_local_project_snapshot,
|
||||
read_local_project_snapshot_state,
|
||||
set_active_project_snapshot_workspace,
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! 平台素材直传 facade。
|
||||
//!
|
||||
//! 渲染层只提交用户选择的文件字节和结构化素材元数据。凭证申请、对象存储表单直传、
|
||||
//! confirm、会话身份围栏和错误分类全部在 Rust 内完成,避免 React 持有 token 或自行
|
||||
//! 发起平台网络请求。
|
||||
|
||||
use crate::http_client::agc_main_site_client_builder;
|
||||
use crate::platform_session::{
|
||||
current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot,
|
||||
};
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const MAX_UPLOAD_BYTES: usize = 6 * 1024 * 1024;
|
||||
const MAX_FILE_NAME_CHARS: usize = 255;
|
||||
const MAX_ASSET_KIND_CHARS: usize = 128;
|
||||
const MAX_ENTITY_ID_CHARS: usize = 256;
|
||||
const MAX_PATH_SEGMENTS: usize = 8;
|
||||
const MAX_PATH_SEGMENT_CHARS: usize = 128;
|
||||
const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope";
|
||||
const API_RESPONSE_ENVELOPE_VERSION: &str = "v1";
|
||||
const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client";
|
||||
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct PlatformAssetUploadInput {
|
||||
pub(crate) file_name: String,
|
||||
pub(crate) content_type: String,
|
||||
pub(crate) asset_kind: String,
|
||||
pub(crate) path_segments: Vec<String>,
|
||||
pub(crate) entity_id: String,
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
#[serde(default)]
|
||||
pub(crate) metadata: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PlatformAssetUploadResult {
|
||||
pub(crate) asset_object_id: String,
|
||||
pub(crate) object_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct UploadTicket {
|
||||
host: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
success_action_status: u16,
|
||||
form_fields: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
fn require_session() -> Result<PlatformSessionSnapshot, String> {
|
||||
current_platform_session()
|
||||
.ok_or_else(|| "authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string())
|
||||
}
|
||||
|
||||
fn validate_session(snapshot: &PlatformSessionSnapshot) -> Result<(), String> {
|
||||
validate_platform_session_identity(&snapshot.identity())
|
||||
}
|
||||
|
||||
fn current_scoped_session(
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
) -> Result<PlatformSessionSnapshot, String> {
|
||||
let current = require_session()?;
|
||||
if current.identity() != snapshot.identity() {
|
||||
return Err(
|
||||
"authentication-required: 陶泥儿登录态已变化,旧账号素材上传已停止,请重试".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
fn validate_text(value: &str, label: &str, max_chars: usize) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(format!("{label}不能为空"));
|
||||
}
|
||||
if value.chars().count() > max_chars || value.chars().any(char::is_control) {
|
||||
return Err(format!("{label}无效"));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn validate_input(input: PlatformAssetUploadInput) -> Result<PlatformAssetUploadInput, String> {
|
||||
if input.bytes.is_empty() {
|
||||
return Err("素材文件为空".to_string());
|
||||
}
|
||||
if input.bytes.len() > MAX_UPLOAD_BYTES {
|
||||
return Err(format!(
|
||||
"素材文件超过 {} MiB 限制",
|
||||
MAX_UPLOAD_BYTES / 1024 / 1024
|
||||
));
|
||||
}
|
||||
let file_name = validate_text(&input.file_name, "素材文件名", MAX_FILE_NAME_CHARS)?;
|
||||
if file_name.contains(['/', '\\']) || file_name == "." || file_name == ".." {
|
||||
return Err("素材文件名不能包含路径".to_string());
|
||||
}
|
||||
let content_type = validate_text(&input.content_type, "素材媒体类型", 255)?;
|
||||
let asset_kind = validate_text(&input.asset_kind, "素材类型", MAX_ASSET_KIND_CHARS)?;
|
||||
let entity_id = validate_text(&input.entity_id, "素材实体标识", MAX_ENTITY_ID_CHARS)?;
|
||||
if input.path_segments.is_empty() || input.path_segments.len() > MAX_PATH_SEGMENTS {
|
||||
return Err("素材上传路径无效".to_string());
|
||||
}
|
||||
let path_segments = input
|
||||
.path_segments
|
||||
.into_iter()
|
||||
.map(|segment| validate_text(&segment, "素材上传路径", MAX_PATH_SEGMENT_CHARS))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if path_segments
|
||||
.iter()
|
||||
.any(|segment| segment.contains(['/', '\\', '?', '#']))
|
||||
{
|
||||
return Err("素材上传路径无效".to_string());
|
||||
}
|
||||
let metadata = input
|
||||
.metadata
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
let key = validate_text(&key, "素材元数据键", 128)?;
|
||||
let value = validate_text(&value, "素材元数据值", 512)?;
|
||||
Ok((key, value))
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, String>, String>>()?;
|
||||
Ok(PlatformAssetUploadInput {
|
||||
file_name,
|
||||
content_type,
|
||||
asset_kind,
|
||||
path_segments,
|
||||
entity_id,
|
||||
bytes: input.bytes,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoint(snapshot: &PlatformSessionSnapshot, route: &str) -> Result<String, String> {
|
||||
let mut url = Url::parse(&format!("{}/", snapshot.api_base_url.trim_end_matches('/')))
|
||||
.map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||||
for segment in route.trim_start_matches('/').split('/') {
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn response_data(payload: Value) -> Value {
|
||||
payload.get("data").cloned().unwrap_or(payload)
|
||||
}
|
||||
|
||||
fn response_error(status: StatusCode, body: &str, action: &str) -> String {
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string();
|
||||
}
|
||||
if status == StatusCode::FORBIDDEN {
|
||||
return format!("permission-denied: {action}权限不足");
|
||||
}
|
||||
let detail = serde_json::from_str::<Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("error")
|
||||
.and_then(|error| error.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
format!("{action}失败:{detail}")
|
||||
}
|
||||
|
||||
fn upload_route() -> &'static str {
|
||||
"/api/assets/direct-upload-tickets"
|
||||
}
|
||||
|
||||
fn confirm_route() -> &'static str {
|
||||
"/api/assets/objects/confirm"
|
||||
}
|
||||
|
||||
async fn request_ticket(
|
||||
client: &reqwest::Client,
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
input: &PlatformAssetUploadInput,
|
||||
) -> Result<UploadTicket, String> {
|
||||
let current = current_scoped_session(snapshot)?;
|
||||
let url = endpoint(snapshot, upload_route())?;
|
||||
let mut metadata = input.metadata.clone();
|
||||
metadata.insert("asset_kind".to_string(), input.asset_kind.clone());
|
||||
let response = client
|
||||
.post(url)
|
||||
.bearer_auth(current.access_token)
|
||||
.header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE)
|
||||
.header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION)
|
||||
.json(&json!({
|
||||
"legacyPrefix": "generated-character-drafts",
|
||||
"pathSegments": input.path_segments,
|
||||
"fileName": input.file_name,
|
||||
"contentType": input.content_type,
|
||||
"access": "private",
|
||||
"maxSizeBytes": input.bytes.len(),
|
||||
"metadata": metadata,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| "创建素材上传凭证失败:无法连接登录服务,请稍后重试".to_string())?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|_| "创建素材上传凭证失败:读取响应失败".to_string())?;
|
||||
validate_session(snapshot)?;
|
||||
if !status.is_success() {
|
||||
return Err(response_error(status, &body, "创建素材上传凭证"));
|
||||
}
|
||||
let payload = serde_json::from_str::<Value>(&body)
|
||||
.map(response_data)
|
||||
.map_err(|_| "创建素材上传凭证失败:响应格式无效".to_string())?;
|
||||
let upload = payload
|
||||
.get("upload")
|
||||
.ok_or_else(|| "创建素材上传凭证失败:响应缺少 upload".to_string())?;
|
||||
let host = upload
|
||||
.get("host")
|
||||
.or_else(|| upload.get("endpoint"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "创建素材上传凭证失败:响应缺少 host".to_string())?
|
||||
.to_string();
|
||||
let bucket = upload
|
||||
.get("bucket")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "创建素材上传凭证失败:响应缺少 bucket".to_string())?
|
||||
.to_string();
|
||||
let object_key = upload
|
||||
.get("objectKey")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "创建素材上传凭证失败:响应缺少 objectKey".to_string())?
|
||||
.to_string();
|
||||
let success_action_status = upload
|
||||
.get("successActionStatus")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.unwrap_or(204);
|
||||
if !matches!(success_action_status, 200 | 201 | 204) {
|
||||
return Err("创建素材上传凭证失败:successActionStatus 无效".to_string());
|
||||
}
|
||||
let form_fields = upload
|
||||
.get("formFields")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| "创建素材上传凭证失败:响应缺少 formFields".to_string())?
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
value
|
||||
.as_str()
|
||||
.map(|value| (key.clone(), value.to_string()))
|
||||
.ok_or_else(|| "创建素材上传凭证失败:formFields 格式无效".to_string())
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, _>, _>>()?;
|
||||
Ok(UploadTicket {
|
||||
host,
|
||||
bucket,
|
||||
object_key,
|
||||
success_action_status,
|
||||
form_fields,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_upload_host(host: &str) -> Result<Url, String> {
|
||||
let url = Url::parse(host.trim()).map_err(|_| "素材上传地址无效".to_string())?;
|
||||
let hostname = url.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||
let is_local = url.scheme() == "http" && matches!(hostname.as_str(), "127.0.0.1" | "localhost");
|
||||
let is_oss = url.scheme() == "https" && hostname.ends_with(".aliyuncs.com");
|
||||
if !is_local && !is_oss {
|
||||
return Err("素材上传地址不属于平台素材存储,已终止上传".to_string());
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn upload_object(
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
ticket: &UploadTicket,
|
||||
input: &PlatformAssetUploadInput,
|
||||
) -> Result<(), String> {
|
||||
let upload_url = validate_upload_host(&ticket.host)?;
|
||||
validate_session(snapshot)?;
|
||||
let client = agc_main_site_client_builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|_| "创建素材上传客户端失败".to_string())?;
|
||||
let mut form = Form::new();
|
||||
for (key, value) in &ticket.form_fields {
|
||||
form = form.text(key.clone(), value.clone());
|
||||
}
|
||||
let part = Part::bytes(input.bytes.clone())
|
||||
.file_name(input.file_name.clone())
|
||||
.mime_str(&input.content_type)
|
||||
.map_err(|_| "素材媒体类型无效".to_string())?;
|
||||
let response = client
|
||||
.post(upload_url)
|
||||
.multipart(form.part("file", part))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| "上传素材失败:无法访问素材存储,请检查网络后重试".to_string())?;
|
||||
validate_session(snapshot)?;
|
||||
if response.status().as_u16() != ticket.success_action_status {
|
||||
return Err(format!(
|
||||
"上传素材到对象存储失败(HTTP {}),请重试",
|
||||
response.status().as_u16()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn confirm_object(
|
||||
client: &reqwest::Client,
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
ticket: &UploadTicket,
|
||||
input: &PlatformAssetUploadInput,
|
||||
) -> Result<PlatformAssetUploadResult, String> {
|
||||
let current = current_scoped_session(snapshot)?;
|
||||
let url = endpoint(snapshot, confirm_route())?;
|
||||
let response = client
|
||||
.post(url)
|
||||
.bearer_auth(current.access_token)
|
||||
.header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE)
|
||||
.header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION)
|
||||
.json(&json!({
|
||||
"bucket": ticket.bucket,
|
||||
"objectKey": ticket.object_key,
|
||||
"contentType": input.content_type,
|
||||
"contentLength": input.bytes.len(),
|
||||
"assetKind": input.asset_kind,
|
||||
"accessPolicy": "private",
|
||||
"entityId": input.entity_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| "确认素材资产失败:无法连接登录服务,请稍后重试".to_string())?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|_| "确认素材资产失败:读取响应失败".to_string())?;
|
||||
validate_session(snapshot)?;
|
||||
if !status.is_success() {
|
||||
return Err(response_error(status, &body, "确认素材资产"));
|
||||
}
|
||||
let payload = serde_json::from_str::<Value>(&body)
|
||||
.map(response_data)
|
||||
.map_err(|_| "确认素材资产失败:响应格式无效".to_string())?;
|
||||
let asset = payload
|
||||
.get("assetObject")
|
||||
.ok_or_else(|| "确认素材资产失败:响应缺少 assetObject".to_string())?;
|
||||
let asset_object_id = asset
|
||||
.get("assetObjectId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "确认素材资产失败:响应缺少 assetObjectId".to_string())?;
|
||||
let object_key = asset
|
||||
.get("objectKey")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "确认素材资产失败:响应缺少 objectKey".to_string())?;
|
||||
if object_key != ticket.object_key {
|
||||
return Err("result-unknown: 确认素材资产返回 objectKey 不一致".to_string());
|
||||
}
|
||||
Ok(PlatformAssetUploadResult {
|
||||
asset_object_id: asset_object_id.to_string(),
|
||||
object_key: object_key.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn upload_platform_media_asset(
|
||||
input: PlatformAssetUploadInput,
|
||||
) -> Result<PlatformAssetUploadResult, String> {
|
||||
let input = validate_input(input)?;
|
||||
let snapshot = require_session()?;
|
||||
let client = agc_main_site_client_builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|_| "创建素材上传客户端失败".to_string())?;
|
||||
let ticket = request_ticket(&client, &snapshot, &input).await?;
|
||||
upload_object(&snapshot, &ticket, &input).await?;
|
||||
confirm_object(&client, &snapshot, &ticket, &input).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_upload_hosts_without_accepting_arbitrary_origins() {
|
||||
assert!(validate_upload_host("http://127.0.0.1:9000/upload").is_ok());
|
||||
assert!(validate_upload_host("https://assets.oss-cn-shanghai.aliyuncs.com/upload").is_ok());
|
||||
assert!(validate_upload_host("https://evil.example.com/upload").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_paths_and_oversized_payloads_before_network() {
|
||||
let input = PlatformAssetUploadInput {
|
||||
file_name: "../cover.png".to_string(),
|
||||
content_type: "image/png".to_string(),
|
||||
asset_kind: "game_distribution_cover".to_string(),
|
||||
path_segments: vec!["game-distribution".to_string()],
|
||||
entity_id: "cover".to_string(),
|
||||
bytes: vec![1],
|
||||
metadata: BTreeMap::new(),
|
||||
};
|
||||
assert!(validate_input(input).is_err());
|
||||
let input = PlatformAssetUploadInput {
|
||||
file_name: "cover.png".to_string(),
|
||||
content_type: "image/png".to_string(),
|
||||
asset_kind: "game_distribution_cover".to_string(),
|
||||
path_segments: vec!["game-distribution".to_string()],
|
||||
entity_id: "cover".to_string(),
|
||||
bytes: vec![0; MAX_UPLOAD_BYTES + 1],
|
||||
metadata: BTreeMap::new(),
|
||||
};
|
||||
assert!(validate_input(input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_data_envelope_without_exposing_credentials() {
|
||||
let payload = response_data(json!({ "data": { "assetObjectId": "asset-1" } }));
|
||||
assert_eq!(payload["assetObjectId"], "asset-1");
|
||||
assert!(!payload.to_string().contains("token"));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tauri::{Manager, State};
|
||||
use tauri::{Emitter, Manager, State};
|
||||
|
||||
use crate::editor_adapter::{EditorAdapter, EditorConnectionInfo};
|
||||
|
||||
@@ -39,6 +39,7 @@ const RPC_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_RPC_BYTES: usize = 2 * 1024 * 1024;
|
||||
pub(crate) const AGC_PLUGIN_STATE_CHANGED_EVENT: &str = "game-creator-plugin-state-changed";
|
||||
|
||||
const KNOWN_PERMISSIONS: &[&str] = &[
|
||||
"events.subscribe",
|
||||
@@ -1997,6 +1998,12 @@ pub(crate) fn list_agc_extensions(
|
||||
host.list_extensions()
|
||||
}
|
||||
|
||||
fn emit_plugin_state_changed(app: &tauri::AppHandle, host: &PluginHost) {
|
||||
if let Ok(extensions) = host.list_extensions() {
|
||||
let _ = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn refresh_agc_plugins(
|
||||
host: State<'_, PluginHost>,
|
||||
@@ -2008,24 +2015,39 @@ pub(crate) fn refresh_agc_plugins(
|
||||
pub(crate) fn start_agc_plugin(
|
||||
id: String,
|
||||
host: State<'_, PluginHost>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<PluginSummary, String> {
|
||||
host.start(id.trim())
|
||||
let result = host.start(id.trim());
|
||||
if result.is_ok() {
|
||||
emit_plugin_state_changed(&app, &host);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn stop_agc_plugin(
|
||||
id: String,
|
||||
host: State<'_, PluginHost>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<PluginSummary, String> {
|
||||
host.stop(id.trim())
|
||||
let result = host.stop(id.trim());
|
||||
if result.is_ok() {
|
||||
emit_plugin_state_changed(&app, &host);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn reload_agc_plugin(
|
||||
id: String,
|
||||
host: State<'_, PluginHost>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<PluginSummary, String> {
|
||||
host.reload(id.trim())
|
||||
let result = host.reload(id.trim());
|
||||
if result.is_ok() {
|
||||
emit_plugin_state_changed(&app, &host);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2033,8 +2055,13 @@ pub(crate) fn set_agc_plugin_enabled(
|
||||
id: String,
|
||||
enabled: bool,
|
||||
host: State<'_, PluginHost>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<Vec<PluginSummary>, String> {
|
||||
host.set_enabled(id.trim(), enabled)
|
||||
let result = host.set_enabled(id.trim(), enabled);
|
||||
if result.is_ok() {
|
||||
emit_plugin_state_changed(&app, &host);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2069,7 +2096,12 @@ pub(crate) async fn set_agc_plugin_project_path(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
app.state::<PluginHost>().set_active_project(project_path)
|
||||
let host = app.state::<PluginHost>();
|
||||
let result = host.set_active_project(project_path);
|
||||
if result.is_ok() {
|
||||
emit_plugin_state_changed(&app, &host);
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "切换插件项目上下文任务失败".to_string())?
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
-19
@@ -108,7 +108,6 @@
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
"@tauri-apps/plugin-updater": "2.11.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
@@ -8094,15 +8093,6 @@
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-http": {
|
||||
"version": "2.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
|
||||
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
@@ -26547,7 +26537,6 @@
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
"@tauri-apps/plugin-updater": "2.11.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -28304,14 +28293,6 @@
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"@tauri-apps/plugin-http": {
|
||||
"version": "2.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
|
||||
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
|
||||
"requires": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
|
||||
Reference in New Issue
Block a user