Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81a35e0bff |
@@ -16,9 +16,5 @@
|
|||||||
"retryBackoffMs": 500
|
"retryBackoffMs": 500
|
||||||
},
|
},
|
||||||
"agentLlm": {},
|
"agentLlm": {},
|
||||||
"editorApi": {
|
|
||||||
"baseUrl": "http://127.0.0.1:8082",
|
|
||||||
"apiKey": ""
|
|
||||||
},
|
|
||||||
"mcpServers": {}
|
"mcpServers": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1207,9 +1207,9 @@ for (const [agentId, agentConfig] of Object.entries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultAppConfig.editorApi?.apiKey !== '') {
|
if (defaultAppConfig.editorApi !== undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AI game creator shell default editorApi.apiKey must stay empty',
|
'AI game creator shell ordinary default config must not contain editorApi',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGr
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn editor_api_key_is_configured() -> bool {
|
pub(crate) fn editor_api_key_is_configured() -> bool {
|
||||||
|
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||||
|
return platform_session_is_available();
|
||||||
|
}
|
||||||
load_game_creator_app_config()
|
load_game_creator_app_config()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|config| trim_config_string(&config.editor_api.api_key))
|
.and_then(|config| trim_config_string(&config.editor_api.api_key))
|
||||||
@@ -383,6 +386,19 @@ pub(crate) fn classify_external_generation_initial_response(
|
|||||||
match status {
|
match status {
|
||||||
reqwest::StatusCode::OK => {
|
reqwest::StatusCode::OK => {
|
||||||
let generated = external_editor_response_data(payload);
|
let generated = external_editor_response_data(payload);
|
||||||
|
if let Some(queue_state) = generated
|
||||||
|
.get("queueState")
|
||||||
|
.filter(|queue_state| queue_state.is_object())
|
||||||
|
{
|
||||||
|
if let Some(operation_id) = json_string_field(queue_state, "operationId") {
|
||||||
|
return Ok(ExternalGenerationInitialResponse::AsyncSubmission(
|
||||||
|
serde_json::json!({
|
||||||
|
"operationId": operation_id,
|
||||||
|
"pollAfterMs": 2_000,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
if !external_generation_result_has_download_reference(generated) {
|
if !external_generation_result_has_download_reference(generated) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台旧同步图片生成响应缺少可下载结果"
|
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台旧同步图片生成响应缺少可下载结果"
|
||||||
@@ -467,9 +483,10 @@ pub(crate) async fn wait_for_external_generation_result(
|
|||||||
let submission = external_editor_response_data(submission_payload);
|
let submission = external_editor_response_data(submission_payload);
|
||||||
let operation_id = json_string_field(submission, "operationId")
|
let operation_id = json_string_field(submission, "operationId")
|
||||||
.ok_or_else(|| "外部图片生成提交响应缺少 operationId".to_string())?;
|
.ok_or_else(|| "外部图片生成提交响应缺少 operationId".to_string())?;
|
||||||
let operation_id_path =
|
let status_url = format!(
|
||||||
url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::<String>();
|
"{api_base_url}{}",
|
||||||
let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id_path}");
|
resolve_platform_generation_status_route(&operation_id)
|
||||||
|
);
|
||||||
let started_at = tokio::time::Instant::now();
|
let started_at = tokio::time::Instant::now();
|
||||||
let mut poll_after_ms = external_generation_poll_after_ms(submission_payload);
|
let mut poll_after_ms = external_generation_poll_after_ms(submission_payload);
|
||||||
|
|
||||||
@@ -504,7 +521,7 @@ pub(crate) async fn wait_for_external_generation_result(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let generation = external_editor_response_data(&payload);
|
let generation = platform_generation_status_data(&payload);
|
||||||
match json_string_field(generation, "status").as_deref() {
|
match json_string_field(generation, "status").as_deref() {
|
||||||
Some("completed") => {
|
Some("completed") => {
|
||||||
let result = generation
|
let result = generation
|
||||||
@@ -556,12 +573,32 @@ pub(crate) async fn submit_external_generation_request(
|
|||||||
idempotency_key: &str,
|
idempotency_key: &str,
|
||||||
request_body_json: &str,
|
request_body_json: &str,
|
||||||
) -> Result<reqwest::Response, String> {
|
) -> Result<reqwest::Response, String> {
|
||||||
|
let mut request_body = serde_json::from_str::<serde_json::Value>(request_body_json)
|
||||||
|
.map_err(|error| format!("解析平台图片生成请求失败:{error}"))?;
|
||||||
|
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||||
|
let inputs = request_body.as_object_mut().and_then(|body| {
|
||||||
|
body.entry("generationInputs")
|
||||||
|
.or_insert_with(|| serde_json::json!({}))
|
||||||
|
.as_object_mut()
|
||||||
|
});
|
||||||
|
if let Some(inputs) = inputs {
|
||||||
|
inputs.insert(
|
||||||
|
"source".to_string(),
|
||||||
|
serde_json::Value::String("ai-game-creator-client".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let request_body_json = serde_json::to_string(&request_body)
|
||||||
|
.map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?;
|
||||||
client
|
client
|
||||||
.post(format!("{api_base_url}{endpoint}"))
|
.post(format!(
|
||||||
|
"{api_base_url}{}",
|
||||||
|
resolve_platform_editor_api_route(endpoint)
|
||||||
|
))
|
||||||
.bearer_auth(api_key)
|
.bearer_auth(api_key)
|
||||||
.header("Idempotency-Key", idempotency_key)
|
.header("Idempotency-Key", idempotency_key)
|
||||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
.body(request_body_json.to_string())
|
.body(request_body_json)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
@@ -652,7 +689,10 @@ pub(crate) async fn prepare_external_canvas_generation_context(
|
|||||||
};
|
};
|
||||||
let projects_payload = external_editor_json_request(
|
let projects_payload = external_editor_json_request(
|
||||||
client
|
client
|
||||||
.get(format!("{api_base_url}/api/external/v1/editor/projects"))
|
.get(format!(
|
||||||
|
"{api_base_url}{}",
|
||||||
|
resolve_platform_editor_api_route("/api/external/v1/editor/projects")
|
||||||
|
))
|
||||||
.bearer_auth(api_key),
|
.bearer_auth(api_key),
|
||||||
"读取外部画布项目",
|
"读取外部画布项目",
|
||||||
)
|
)
|
||||||
@@ -670,7 +710,10 @@ pub(crate) async fn prepare_external_canvas_generation_context(
|
|||||||
None => {
|
None => {
|
||||||
let payload = external_editor_json_request(
|
let payload = external_editor_json_request(
|
||||||
client
|
client
|
||||||
.post(format!("{api_base_url}/api/external/v1/editor/projects"))
|
.post(format!(
|
||||||
|
"{api_base_url}{}",
|
||||||
|
resolve_platform_editor_api_route("/api/external/v1/editor/projects")
|
||||||
|
))
|
||||||
.bearer_auth(api_key)
|
.bearer_auth(api_key)
|
||||||
.json(&serde_json::json!({ "title": canvas_name })),
|
.json(&serde_json::json!({ "title": canvas_name })),
|
||||||
"创建外部画布项目",
|
"创建外部画布项目",
|
||||||
@@ -686,7 +729,8 @@ pub(crate) async fn prepare_external_canvas_generation_context(
|
|||||||
let library_payload = external_editor_json_request(
|
let library_payload = external_editor_json_request(
|
||||||
client
|
client
|
||||||
.get(format!(
|
.get(format!(
|
||||||
"{api_base_url}/api/external/v1/editor/assets/library"
|
"{api_base_url}{}",
|
||||||
|
resolve_platform_editor_api_route("/api/external/v1/editor/assets/library")
|
||||||
))
|
))
|
||||||
.bearer_auth(api_key),
|
.bearer_auth(api_key),
|
||||||
"读取外部素材库",
|
"读取外部素材库",
|
||||||
@@ -707,7 +751,8 @@ pub(crate) async fn prepare_external_canvas_generation_context(
|
|||||||
let payload = external_editor_json_request(
|
let payload = external_editor_json_request(
|
||||||
client
|
client
|
||||||
.post(format!(
|
.post(format!(
|
||||||
"{api_base_url}/api/external/v1/editor/assets/folders"
|
"{api_base_url}{}",
|
||||||
|
resolve_platform_editor_api_route("/api/external/v1/editor/assets/folders")
|
||||||
))
|
))
|
||||||
.bearer_auth(api_key)
|
.bearer_auth(api_key)
|
||||||
.json(&serde_json::json!({ "label": canvas_name })),
|
.json(&serde_json::json!({ "label": canvas_name })),
|
||||||
|
|||||||
+5
-1
@@ -134,7 +134,11 @@ fn request_body_json_and_sha256(
|
|||||||
|
|
||||||
pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: &str) -> String {
|
pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: &str) -> String {
|
||||||
let normalized_base_url = api_base_url.trim().trim_end_matches('/');
|
let normalized_base_url = api_base_url.trim().trim_end_matches('/');
|
||||||
format!("{:x}", Sha256::digest(normalized_base_url.as_bytes()))
|
let account_identity = platform_session_service_identity().unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"{:x}",
|
||||||
|
Sha256::digest(format!("{normalized_base_url}\n{account_identity}").as_bytes())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn platform_art_generation_external_service_origin(
|
pub(crate) fn platform_art_generation_external_service_origin(
|
||||||
|
|||||||
@@ -247,9 +247,12 @@ pub(crate) async fn sync_canvas_project_assets_at(
|
|||||||
let api_key = resolve_canvas_sync_api_key(api_key)?;
|
let api_key = resolve_canvas_sync_api_key(api_key)?;
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let project_url = format!(
|
let project_url = format!(
|
||||||
"{}/api/external/v1/editor/projects/{}",
|
"{}{}",
|
||||||
api_base_url,
|
api_base_url,
|
||||||
percent_encode_query_component(canvas_project_id)
|
resolve_platform_editor_api_route(&format!(
|
||||||
|
"/api/external/v1/editor/projects/{}",
|
||||||
|
percent_encode_query_component(canvas_project_id)
|
||||||
|
))
|
||||||
);
|
);
|
||||||
let project_response = client
|
let project_response = client
|
||||||
.get(project_url)
|
.get(project_url)
|
||||||
@@ -615,7 +618,7 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit(
|
|||||||
api_key,
|
api_key,
|
||||||
resource,
|
resource,
|
||||||
max_bytes,
|
max_bytes,
|
||||||
"/api/external/v1/assets/read-url",
|
&resolve_platform_editor_api_route("/api/external/v1/assets/read-url"),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -776,6 +779,11 @@ pub(crate) async fn resolve_external_asset_signed_url(
|
|||||||
pub(crate) fn resolve_canvas_sync_api_base_url(
|
pub(crate) fn resolve_canvas_sync_api_base_url(
|
||||||
api_base_url: Option<String>,
|
api_base_url: Option<String>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||||
|
return current_platform_session()
|
||||||
|
.map(|session| session.api_base_url)
|
||||||
|
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string());
|
||||||
|
}
|
||||||
let config = load_game_creator_app_config()?;
|
let config = load_game_creator_app_config()?;
|
||||||
let value = trim_optional_string(api_base_url)
|
let value = trim_optional_string(api_base_url)
|
||||||
.or_else(|| trim_config_string(&config.editor_api.base_url))
|
.or_else(|| trim_config_string(&config.editor_api.base_url))
|
||||||
@@ -789,17 +797,113 @@ pub(crate) fn resolve_canvas_sync_api_base_url(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve_canvas_sync_api_key(api_key: Option<String>) -> Result<String, String> {
|
pub(crate) fn resolve_canvas_sync_api_key(api_key: Option<String>) -> Result<String, String> {
|
||||||
|
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||||
|
return current_platform_session()
|
||||||
|
.map(|session| session.access_token)
|
||||||
|
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string());
|
||||||
|
}
|
||||||
let config = load_game_creator_app_config()?;
|
let config = load_game_creator_app_config()?;
|
||||||
trim_optional_string(api_key)
|
trim_optional_string(api_key)
|
||||||
.or_else(|| trim_config_string(&config.editor_api.api_key))
|
.or_else(|| trim_config_string(&config.editor_api.api_key))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"画板同步需要在 {} 的 editorApi.apiKey 中设置 API Key",
|
"请在 {} 的 editorApi.apiKey 中设置开发者 API Key",
|
||||||
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
|
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_canvas_sync_api_credentials(
|
||||||
|
api_base_url: Option<String>,
|
||||||
|
api_key: Option<String>,
|
||||||
|
) -> Result<(String, String, Option<PlatformSessionSnapshot>), String> {
|
||||||
|
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||||
|
let session = current_platform_session()
|
||||||
|
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?;
|
||||||
|
return Ok((
|
||||||
|
session.api_base_url.clone(),
|
||||||
|
session.access_token.clone(),
|
||||||
|
Some(session),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
resolve_canvas_sync_api_base_url(api_base_url)?,
|
||||||
|
resolve_canvas_sync_api_key(api_key)?,
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_platform_editor_api_route(external_route: &str) -> String {
|
||||||
|
resolve_platform_editor_api_route_for_mode(
|
||||||
|
external_route,
|
||||||
|
editor_api_mode() == EditorApiMode::PlatformAccount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_platform_editor_api_route_for_mode(
|
||||||
|
external_route: &str,
|
||||||
|
platform_session_available: bool,
|
||||||
|
) -> String {
|
||||||
|
if !platform_session_available {
|
||||||
|
return external_route.to_string();
|
||||||
|
}
|
||||||
|
if let Some(suffix) = external_route.strip_prefix("/api/external/v1/editor/") {
|
||||||
|
return format!("/api/editor/{suffix}");
|
||||||
|
}
|
||||||
|
if let Some(suffix) = external_route.strip_prefix("/api/external/v1/assets/") {
|
||||||
|
return format!("/api/assets/{suffix}");
|
||||||
|
}
|
||||||
|
external_route.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_platform_generation_status_route(operation_id: &str) -> String {
|
||||||
|
resolve_platform_generation_status_route_for_mode(
|
||||||
|
operation_id,
|
||||||
|
editor_api_mode() == EditorApiMode::PlatformAccount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn editor_api_authentication_error() -> String {
|
||||||
|
match editor_api_mode() {
|
||||||
|
EditorApiMode::PlatformAccount => {
|
||||||
|
"authentication-required: 陶泥儿登录已失效,请重新登录".to_string()
|
||||||
|
}
|
||||||
|
EditorApiMode::ExternalDeveloper => {
|
||||||
|
"authentication-required: External Editor API Key 无效或权限不足".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn editor_api_authorization_error() -> String {
|
||||||
|
match editor_api_mode() {
|
||||||
|
EditorApiMode::PlatformAccount => {
|
||||||
|
"permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string()
|
||||||
|
}
|
||||||
|
EditorApiMode::ExternalDeveloper => {
|
||||||
|
"permission-denied: External Editor API Key 权限不足".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_platform_generation_status_route_for_mode(
|
||||||
|
operation_id: &str,
|
||||||
|
platform_session_available: bool,
|
||||||
|
) -> String {
|
||||||
|
let operation_id = percent_encode_query_component(operation_id);
|
||||||
|
if platform_session_available {
|
||||||
|
format!("/api/runtime/external-generation/jobs/{operation_id}")
|
||||||
|
} else {
|
||||||
|
format!("/api/external/v1/generations/{operation_id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn platform_generation_status_data(payload: &serde_json::Value) -> &serde_json::Value {
|
||||||
|
let data = payload.get("data").unwrap_or(payload);
|
||||||
|
data.get("job")
|
||||||
|
.filter(|job| job.is_object())
|
||||||
|
.unwrap_or(data)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn json_string_field(value: &serde_json::Value, field: &str) -> Option<String> {
|
pub(crate) fn json_string_field(value: &serde_json::Value, field: &str) -> Option<String> {
|
||||||
value
|
value
|
||||||
.get(field)
|
.get(field)
|
||||||
@@ -1110,6 +1214,51 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_session_maps_external_shaped_calls_to_first_party_routes() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", true,),
|
||||||
|
"/api/editor/projects"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_platform_editor_api_route_for_mode("/api/external/v1/assets/read-url", true,),
|
||||||
|
"/api/assets/read-url"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_platform_generation_status_route_for_mode("operation/a b", true),
|
||||||
|
"/api/runtime/external-generation/jobs/operation%2Fa%20b"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_platform_generation_status_route_for_mode("operation/a b", false),
|
||||||
|
"/api/external/v1/generations/operation%2Fa%20b"
|
||||||
|
);
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"data": {
|
||||||
|
"job": {
|
||||||
|
"operationId": "operation-1",
|
||||||
|
"status": "completed",
|
||||||
|
"result": { "objectKey": "generated/result.png" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
platform_generation_status_data(&payload)["result"]["objectKey"],
|
||||||
|
"generated/result.png"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_external_mode_keeps_external_routes_without_platform_session() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", false,),
|
||||||
|
"/api/external/v1/editor/projects"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_platform_generation_status_route_for_mode("operation-1", false),
|
||||||
|
"/api/external/v1/generations/operation-1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
|
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
|
||||||
stream
|
stream
|
||||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||||
|
|||||||
@@ -1270,6 +1270,30 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
|
|||||||
check_game_creator_llm_config_from_config()
|
check_game_creator_llm_config_from_config()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn install_platform_account_session(
|
||||||
|
user_id: String,
|
||||||
|
access_token: String,
|
||||||
|
api_base_url: String,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
install_platform_session(&user_id, &access_token, &api_base_url, generation)?;
|
||||||
|
let _ = install_external_agent_runner_platform_session(
|
||||||
|
&user_id,
|
||||||
|
&access_token,
|
||||||
|
&api_base_url,
|
||||||
|
generation,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> {
|
||||||
|
clear_platform_session(generation);
|
||||||
|
let _ = clear_external_agent_runner_platform_session(generation);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView, String> {
|
pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView, String> {
|
||||||
game_creator_app_config_view(load_game_creator_app_config()?)
|
game_creator_app_config_view(load_game_creator_app_config()?)
|
||||||
@@ -1281,8 +1305,7 @@ pub(crate) fn write_game_creator_app_config(
|
|||||||
) -> Result<GameCreatorAppConfigView, String> {
|
) -> Result<GameCreatorAppConfigView, String> {
|
||||||
let config = normalize_game_creator_app_config(config)?;
|
let config = normalize_game_creator_app_config(config)?;
|
||||||
let path = writable_game_creator_config_path()?;
|
let path = writable_game_creator_config_path()?;
|
||||||
let content = serde_json::to_string_pretty(&config)
|
let content = serialize_game_creator_app_config_for_renderer_write(&config)?;
|
||||||
.map_err(|error| format!("序列化客户端配置失败:{error}"))?;
|
|
||||||
write_game_creator_config_atomically(&path, &format!("{content}\n"))?;
|
write_game_creator_config_atomically(&path, &format!("{content}\n"))?;
|
||||||
game_creator_app_config_view(load_game_creator_app_config()?)
|
game_creator_app_config_view(load_game_creator_app_config()?)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1328,14 +1328,31 @@ pub(crate) fn load_game_creator_app_config() -> Result<GameCreatorAppConfig, Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn game_creator_app_config_view(
|
pub(crate) fn game_creator_app_config_view(
|
||||||
config: GameCreatorAppConfig,
|
mut config: GameCreatorAppConfig,
|
||||||
) -> Result<GameCreatorAppConfigView, String> {
|
) -> Result<GameCreatorAppConfigView, String> {
|
||||||
|
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||||
|
config.editor_api = GameCreatorEditorApiConfig::default();
|
||||||
|
}
|
||||||
Ok(GameCreatorAppConfigView {
|
Ok(GameCreatorAppConfigView {
|
||||||
path: writable_game_creator_config_path()?.display().to_string(),
|
path: writable_game_creator_config_path()?.display().to_string(),
|
||||||
config,
|
config,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn serialize_game_creator_app_config_for_renderer_write(
|
||||||
|
config: &GameCreatorAppConfig,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut value =
|
||||||
|
serde_json::to_value(config).map_err(|error| format!("序列化客户端配置失败:{error}"))?;
|
||||||
|
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||||
|
value
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| "客户端配置必须是 JSON object".to_string())?
|
||||||
|
.remove("editorApi");
|
||||||
|
}
|
||||||
|
serde_json::to_string_pretty(&value).map_err(|error| format!("序列化客户端配置失败:{error}"))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn writable_game_creator_config_path() -> Result<PathBuf, String> {
|
pub(crate) fn writable_game_creator_config_path() -> Result<PathBuf, String> {
|
||||||
if let Some(config_dir) = game_creator_runtime_config_dir() {
|
if let Some(config_dir) = game_creator_runtime_config_dir() {
|
||||||
return Ok(config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME));
|
return Ok(config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME));
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ mod image_inspect;
|
|||||||
mod isolated_agent;
|
mod isolated_agent;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod patchset;
|
mod patchset;
|
||||||
|
mod platform_session;
|
||||||
mod preview;
|
mod preview;
|
||||||
mod process_session;
|
mod process_session;
|
||||||
mod process_session_bridge;
|
mod process_session_bridge;
|
||||||
@@ -101,6 +102,7 @@ use image_inspect::*;
|
|||||||
use isolated_agent::*;
|
use isolated_agent::*;
|
||||||
use mcp::*;
|
use mcp::*;
|
||||||
use patchset::*;
|
use patchset::*;
|
||||||
|
use platform_session::*;
|
||||||
use preview::*;
|
use preview::*;
|
||||||
use process_session::*;
|
use process_session::*;
|
||||||
use project::*;
|
use project::*;
|
||||||
@@ -1239,7 +1241,7 @@ fn default_game_creator_llm_auto_compact_token_limit() -> u64 {
|
|||||||
fn default_game_creator_llm_tool_output_token_limit() -> u64 {
|
fn default_game_creator_llm_tool_output_token_limit() -> u64 {
|
||||||
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
|
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
|
||||||
}
|
}
|
||||||
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082";
|
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "https://dev.genarrative.world";
|
||||||
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
|
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
|
||||||
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
|
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
|
||||||
const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800;
|
const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800;
|
||||||
@@ -2214,6 +2216,8 @@ fn main() {
|
|||||||
confirm_resume_game_creator_agent_runtime_tasks,
|
confirm_resume_game_creator_agent_runtime_tasks,
|
||||||
schedule_game_creator_agent_ready_tasks,
|
schedule_game_creator_agent_ready_tasks,
|
||||||
check_game_creator_llm_config,
|
check_game_creator_llm_config,
|
||||||
|
install_platform_account_session,
|
||||||
|
clear_platform_account_session,
|
||||||
read_game_creator_app_config,
|
read_game_creator_app_config,
|
||||||
write_game_creator_app_config,
|
write_game_creator_app_config,
|
||||||
read_game_creator_mcp_catalog,
|
read_game_creator_mcp_catalog,
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) struct PlatformSessionSnapshot {
|
||||||
|
pub(crate) user_id: String,
|
||||||
|
pub(crate) access_token: String,
|
||||||
|
pub(crate) api_base_url: String,
|
||||||
|
pub(crate) generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum EditorApiMode {
|
||||||
|
PlatformAccount,
|
||||||
|
ExternalDeveloper,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn editor_api_mode_for_build(
|
||||||
|
debug_assertions: bool,
|
||||||
|
game_chat_release_feature: bool,
|
||||||
|
) -> EditorApiMode {
|
||||||
|
if !debug_assertions && game_chat_release_feature {
|
||||||
|
EditorApiMode::ExternalDeveloper
|
||||||
|
} else {
|
||||||
|
EditorApiMode::PlatformAccount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn editor_api_mode() -> EditorApiMode {
|
||||||
|
editor_api_mode_for_build(cfg!(debug_assertions), cfg!(feature = "game-chat-release"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PlatformSessionState {
|
||||||
|
generation: u64,
|
||||||
|
snapshot: Option<PlatformSessionSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static PLATFORM_SESSION: OnceLock<Mutex<PlatformSessionState>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn platform_session() -> &'static Mutex<PlatformSessionState> {
|
||||||
|
PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_platform_session_in(
|
||||||
|
current: &mut PlatformSessionState,
|
||||||
|
user_id: &str,
|
||||||
|
access_token: &str,
|
||||||
|
api_base_url: &str,
|
||||||
|
generation: u64,
|
||||||
|
) {
|
||||||
|
if generation < current.generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if generation == current.generation {
|
||||||
|
if current.snapshot.as_ref().is_some_and(|snapshot| {
|
||||||
|
snapshot.user_id == user_id
|
||||||
|
&& snapshot.access_token == access_token
|
||||||
|
&& snapshot.api_base_url == api_base_url
|
||||||
|
}) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Equal-generation retries may only repeat the exact committed snapshot. In
|
||||||
|
// particular, a late install cannot revive a generation that was cleared.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.generation = generation;
|
||||||
|
current.snapshot = Some(PlatformSessionSnapshot {
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
access_token: access_token.to_string(),
|
||||||
|
api_base_url: api_base_url.to_string(),
|
||||||
|
generation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) {
|
||||||
|
if generation < current.generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.generation = generation;
|
||||||
|
current.snapshot = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_platform_session(
|
||||||
|
user_id: &str,
|
||||||
|
access_token: &str,
|
||||||
|
api_base_url: &str,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if editor_api_mode() == EditorApiMode::ExternalDeveloper {
|
||||||
|
return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string());
|
||||||
|
}
|
||||||
|
let user_id = user_id.trim();
|
||||||
|
let access_token = access_token.trim();
|
||||||
|
let api_base_url = normalize_platform_api_base_url(api_base_url)?;
|
||||||
|
if user_id.is_empty() || user_id.len() > 256 {
|
||||||
|
return Err("陶泥儿登录用户身份无效".to_string());
|
||||||
|
}
|
||||||
|
if access_token.is_empty() || access_token.len() > 16 * 1024 {
|
||||||
|
return Err("陶泥儿登录凭据无效".to_string());
|
||||||
|
}
|
||||||
|
let mut current = platform_session()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
install_platform_session_in(
|
||||||
|
&mut current,
|
||||||
|
user_id,
|
||||||
|
access_token,
|
||||||
|
&api_base_url,
|
||||||
|
generation,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
|
||||||
|
let value = value.trim().trim_end_matches('/');
|
||||||
|
let parsed = url::Url::parse(value).map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https")
|
||||||
|
|| !parsed.username().is_empty()
|
||||||
|
|| parsed.password().is_some()
|
||||||
|
|| parsed.query().is_some()
|
||||||
|
|| parsed.fragment().is_some()
|
||||||
|
|| parsed.path() != "/"
|
||||||
|
{
|
||||||
|
return Err("陶泥儿服务地址必须是纯 HTTP(S) origin".to_string());
|
||||||
|
}
|
||||||
|
let host = parsed
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?;
|
||||||
|
let production = matches!(host, "www.genarrative.world" | "dev.genarrative.world");
|
||||||
|
let loopback = host == "localhost"
|
||||||
|
|| host == "127.0.0.1"
|
||||||
|
|| host
|
||||||
|
.parse::<std::net::IpAddr>()
|
||||||
|
.is_ok_and(|ip| ip.is_loopback());
|
||||||
|
if !production && !(cfg!(debug_assertions) && loopback && parsed.scheme() == "http") {
|
||||||
|
return Err("陶泥儿服务地址不在受信任白名单内".to_string());
|
||||||
|
}
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_platform_session(generation: u64) {
|
||||||
|
let mut current = platform_session()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
clear_platform_session_in(&mut current, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
|
||||||
|
platform_session()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.snapshot
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_platform_session_snapshot(
|
||||||
|
expected: &PlatformSessionSnapshot,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(
|
||||||
|
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platform_session_snapshot_matches(
|
||||||
|
current: Option<&PlatformSessionSnapshot>,
|
||||||
|
expected: &PlatformSessionSnapshot,
|
||||||
|
) -> bool {
|
||||||
|
current == Some(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn platform_session_is_available() -> bool {
|
||||||
|
current_platform_session().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn platform_session_service_identity() -> Option<String> {
|
||||||
|
current_platform_session()
|
||||||
|
.map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleared_generation_rejects_late_install_and_older_clear() {
|
||||||
|
let mut state = PlatformSessionState::default();
|
||||||
|
install_platform_session_in(
|
||||||
|
&mut state,
|
||||||
|
"user-a",
|
||||||
|
"token-a",
|
||||||
|
"https://dev.genarrative.world",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
clear_platform_session_in(&mut state, 2);
|
||||||
|
install_platform_session_in(
|
||||||
|
&mut state,
|
||||||
|
"user-a",
|
||||||
|
"late-token-a",
|
||||||
|
"https://dev.genarrative.world",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
install_platform_session_in(
|
||||||
|
&mut state,
|
||||||
|
"user-a",
|
||||||
|
"same-generation-token",
|
||||||
|
"https://dev.genarrative.world",
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert!(state.snapshot.is_none());
|
||||||
|
assert_eq!(state.generation, 2);
|
||||||
|
|
||||||
|
install_platform_session_in(
|
||||||
|
&mut state,
|
||||||
|
"user-b",
|
||||||
|
"token-b",
|
||||||
|
"https://dev.genarrative.world",
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
clear_platform_session_in(&mut state, 2);
|
||||||
|
assert_eq!(
|
||||||
|
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
||||||
|
Some("user-b")
|
||||||
|
);
|
||||||
|
assert_eq!(state.generation, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equal_generation_only_accepts_the_exact_idempotent_snapshot() {
|
||||||
|
let mut state = PlatformSessionState::default();
|
||||||
|
install_platform_session_in(
|
||||||
|
&mut state,
|
||||||
|
"user-a",
|
||||||
|
"token-a",
|
||||||
|
"https://dev.genarrative.world",
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
install_platform_session_in(
|
||||||
|
&mut state,
|
||||||
|
"user-a",
|
||||||
|
"token-a",
|
||||||
|
"https://dev.genarrative.world",
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
install_platform_session_in(
|
||||||
|
&mut state,
|
||||||
|
"user-b",
|
||||||
|
"token-b",
|
||||||
|
"https://dev.genarrative.world",
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
||||||
|
Some("user-a")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.snapshot
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.access_token.as_str()),
|
||||||
|
Some("token-a")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() {
|
||||||
|
assert_eq!(
|
||||||
|
editor_api_mode_for_build(true, false),
|
||||||
|
EditorApiMode::PlatformAccount
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
editor_api_mode_for_build(true, true),
|
||||||
|
EditorApiMode::PlatformAccount
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
editor_api_mode_for_build(false, false),
|
||||||
|
EditorApiMode::PlatformAccount
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
editor_api_mode_for_build(false, true),
|
||||||
|
EditorApiMode::ExternalDeveloper
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() {
|
||||||
|
let expected = PlatformSessionSnapshot {
|
||||||
|
user_id: "user-a".to_string(),
|
||||||
|
access_token: "token-a".to_string(),
|
||||||
|
api_base_url: "https://dev.genarrative.world".to_string(),
|
||||||
|
generation: 4,
|
||||||
|
};
|
||||||
|
assert!(platform_session_snapshot_matches(
|
||||||
|
Some(&expected),
|
||||||
|
&expected
|
||||||
|
));
|
||||||
|
|
||||||
|
for current in [
|
||||||
|
None,
|
||||||
|
Some(PlatformSessionSnapshot {
|
||||||
|
user_id: "user-b".to_string(),
|
||||||
|
..expected.clone()
|
||||||
|
}),
|
||||||
|
Some(PlatformSessionSnapshot {
|
||||||
|
access_token: "token-b".to_string(),
|
||||||
|
generation: 5,
|
||||||
|
..expected.clone()
|
||||||
|
}),
|
||||||
|
] {
|
||||||
|
assert!(!platform_session_snapshot_matches(
|
||||||
|
current.as_ref(),
|
||||||
|
&expected
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,10 @@ mod state;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub(crate) use client::{
|
pub(crate) use client::{
|
||||||
attach_external_agent_runner_gui_owner, cancel_external_agent_runner_goal,
|
attach_external_agent_runner_gui_owner, cancel_external_agent_runner_goal,
|
||||||
compact_external_agent_runner_context, configure_external_agent_runner,
|
clear_external_agent_runner_platform_session, compact_external_agent_runner_context,
|
||||||
configure_external_agent_runner_read_only, continue_external_agent_runner_action,
|
configure_external_agent_runner, configure_external_agent_runner_read_only,
|
||||||
ensure_external_agent_runner_started, ensure_external_agent_runner_started_for_gui,
|
continue_external_agent_runner_action, ensure_external_agent_runner_started,
|
||||||
|
ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session,
|
||||||
interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner,
|
interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner,
|
||||||
pause_external_agent_runner, read_external_agent_runner_mcp_catalog,
|
pause_external_agent_runner, read_external_agent_runner_mcp_catalog,
|
||||||
read_external_agent_runner_status,
|
read_external_agent_runner_status,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ fn redact_url_queries(line: &str) -> String {
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String {
|
pub(super) fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String {
|
||||||
let lowercase = line.to_ascii_lowercase();
|
let lowercase = line.to_ascii_lowercase();
|
||||||
if [
|
if [
|
||||||
"authorization",
|
"authorization",
|
||||||
@@ -123,6 +123,9 @@ fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String {
|
|||||||
"set-cookie",
|
"set-cookie",
|
||||||
"secret",
|
"secret",
|
||||||
"access_token",
|
"access_token",
|
||||||
|
"accesstoken",
|
||||||
|
"platform_access_token",
|
||||||
|
"platformaccesstoken",
|
||||||
"refresh_token",
|
"refresh_token",
|
||||||
"\"token\"",
|
"\"token\"",
|
||||||
"'token'",
|
"'token'",
|
||||||
@@ -993,18 +996,125 @@ pub(crate) fn attach_external_agent_runner_gui_owner(
|
|||||||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||||||
let config_dir = external_agent_runner_config_dir()
|
let config_dir = external_agent_runner_config_dir()
|
||||||
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
|
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
|
||||||
|
let platform_session = crate::current_platform_session();
|
||||||
register_external_agent_runner_gui_owner_attachment(
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
external_agent_runner_gui_owner_attachment_state(),
|
external_agent_runner_gui_owner_attachment_state(),
|
||||||
&config_dir,
|
&config_dir,
|
||||||
ExternalAgentRunnerRequestParams {
|
ExternalAgentRunnerRequestParams {
|
||||||
event_sink_port: Some(event_sink.port),
|
event_sink_port: Some(event_sink.port),
|
||||||
event_sink_token: Some(event_sink.token.clone()),
|
event_sink_token: Some(event_sink.token.clone()),
|
||||||
|
platform_user_id: platform_session
|
||||||
|
.as_ref()
|
||||||
|
.map(|session| session.user_id.clone()),
|
||||||
|
platform_access_token: platform_session
|
||||||
|
.as_ref()
|
||||||
|
.map(|session| session.access_token.clone()),
|
||||||
|
platform_api_base_url: platform_session
|
||||||
|
.as_ref()
|
||||||
|
.map(|session| session.api_base_url.clone()),
|
||||||
|
platform_auth_generation: platform_session.map(|session| session.generation),
|
||||||
..ExternalAgentRunnerRequestParams::default()
|
..ExternalAgentRunnerRequestParams::default()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ensure_external_agent_runner(&config_dir).map(|_| ())
|
ensure_external_agent_runner(&config_dir).map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_external_agent_runner_platform_session(
|
||||||
|
user_id: &str,
|
||||||
|
access_token: &str,
|
||||||
|
api_base_url: &str,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
remember_external_agent_runner_platform_session(
|
||||||
|
external_agent_runner_gui_owner_attachment_state(),
|
||||||
|
Some((user_id, access_token, api_base_url)),
|
||||||
|
generation,
|
||||||
|
);
|
||||||
|
let config_dir = external_agent_runner_config_dir()
|
||||||
|
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?;
|
||||||
|
let endpoint = {
|
||||||
|
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||||||
|
ensure_external_agent_runner(&config_dir)?
|
||||||
|
};
|
||||||
|
send_external_agent_runner_request(
|
||||||
|
&endpoint,
|
||||||
|
"platform.session.install",
|
||||||
|
ExternalAgentRunnerRequestParams {
|
||||||
|
platform_user_id: Some(user_id.to_string()),
|
||||||
|
platform_access_token: Some(access_token.to_string()),
|
||||||
|
platform_api_base_url: Some(api_base_url.to_string()),
|
||||||
|
platform_auth_generation: Some(generation),
|
||||||
|
..ExternalAgentRunnerRequestParams::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> {
|
||||||
|
remember_external_agent_runner_platform_session(
|
||||||
|
external_agent_runner_gui_owner_attachment_state(),
|
||||||
|
None,
|
||||||
|
generation,
|
||||||
|
);
|
||||||
|
let Some(config_dir) = external_agent_runner_config_dir() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let endpoint_path = external_agent_runner_endpoint_path(&config_dir);
|
||||||
|
let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
send_external_agent_runner_request(
|
||||||
|
&endpoint,
|
||||||
|
"platform.session.clear",
|
||||||
|
ExternalAgentRunnerRequestParams {
|
||||||
|
platform_auth_generation: Some(generation),
|
||||||
|
..ExternalAgentRunnerRequestParams::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remember_external_agent_runner_platform_session(
|
||||||
|
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||||
|
session: Option<(&str, &str, &str)>,
|
||||||
|
generation: u64,
|
||||||
|
) {
|
||||||
|
let mut state = lock_unpoisoned(state);
|
||||||
|
let Some(registration) = state.registration.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let current_generation = registration.params.platform_auth_generation.unwrap_or(0);
|
||||||
|
if generation < current_generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if generation == current_generation {
|
||||||
|
match session {
|
||||||
|
Some((user_id, access_token, api_base_url))
|
||||||
|
if registration.params.platform_user_id.as_deref() == Some(user_id)
|
||||||
|
&& registration.params.platform_access_token.as_deref()
|
||||||
|
== Some(access_token)
|
||||||
|
&& registration.params.platform_api_base_url.as_deref()
|
||||||
|
== Some(api_base_url) =>
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(_) => return,
|
||||||
|
None if registration.params.platform_user_id.is_none()
|
||||||
|
&& registration.params.platform_access_token.is_none() =>
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string());
|
||||||
|
registration.params.platform_access_token =
|
||||||
|
session.map(|(_, access_token, _)| access_token.to_string());
|
||||||
|
registration.params.platform_api_base_url =
|
||||||
|
session.map(|(_, _, api_base_url)| api_base_url.to_string());
|
||||||
|
registration.params.platform_auth_generation = Some(generation);
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn validate_external_agent_runner_gui_owner_attachment_result(
|
pub(super) fn validate_external_agent_runner_gui_owner_attachment_result(
|
||||||
result: &Value,
|
result: &Value,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -1295,6 +1405,10 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
|
|||||||
steer_id: steer_id.map(str::to_string),
|
steer_id: steer_id.map(str::to_string),
|
||||||
event_sink_port: None,
|
event_sink_port: None,
|
||||||
event_sink_token: None,
|
event_sink_token: None,
|
||||||
|
platform_user_id: None,
|
||||||
|
platform_access_token: None,
|
||||||
|
platform_api_base_url: None,
|
||||||
|
platform_auth_generation: None,
|
||||||
};
|
};
|
||||||
match stable_identity {
|
match stable_identity {
|
||||||
Some(stable_identity) => {
|
Some(stable_identity) => {
|
||||||
|
|||||||
@@ -620,6 +620,30 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
|
|||||||
redact_runner_secret(&error, &token),
|
redact_runner_secret(&error, &token),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let (
|
||||||
|
Some(user_id),
|
||||||
|
Some(access_token),
|
||||||
|
Some(api_base_url),
|
||||||
|
Some(generation),
|
||||||
|
) = (
|
||||||
|
request.params.platform_user_id.as_deref(),
|
||||||
|
request.params.platform_access_token.as_deref(),
|
||||||
|
request.params.platform_api_base_url.as_deref(),
|
||||||
|
request.params.platform_auth_generation,
|
||||||
|
) {
|
||||||
|
if let Err(error) = crate::install_platform_session(
|
||||||
|
user_id,
|
||||||
|
access_token,
|
||||||
|
api_base_url,
|
||||||
|
generation,
|
||||||
|
) {
|
||||||
|
return ExternalAgentRunnerResponse::failure(
|
||||||
|
&request.request_id,
|
||||||
|
"platform-session-invalid",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
state.gui_owner_attached.store(true, Ordering::Release);
|
state.gui_owner_attached.store(true, Ordering::Release);
|
||||||
ExternalAgentRunnerResponse::success(
|
ExternalAgentRunnerResponse::success(
|
||||||
&request.request_id,
|
&request.request_id,
|
||||||
@@ -638,6 +662,41 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"platform.session.install" => {
|
||||||
|
let result = request
|
||||||
|
.params
|
||||||
|
.platform_user_id
|
||||||
|
.as_deref()
|
||||||
|
.zip(request.params.platform_access_token.as_deref())
|
||||||
|
.zip(request.params.platform_api_base_url.as_deref())
|
||||||
|
.zip(request.params.platform_auth_generation)
|
||||||
|
.ok_or_else(|| "平台登录态同步参数不完整".to_string())
|
||||||
|
.and_then(|(((user_id, access_token), api_base_url), generation)| {
|
||||||
|
crate::install_platform_session(user_id, access_token, api_base_url, generation)
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(()) => ExternalAgentRunnerResponse::success(
|
||||||
|
&request.request_id,
|
||||||
|
json!({ "installed": true }),
|
||||||
|
),
|
||||||
|
Err(error) => ExternalAgentRunnerResponse::failure(
|
||||||
|
&request.request_id,
|
||||||
|
"platform-session-invalid",
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"platform.session.clear" => {
|
||||||
|
let Some(generation) = request.params.platform_auth_generation else {
|
||||||
|
return ExternalAgentRunnerResponse::failure(
|
||||||
|
&request.request_id,
|
||||||
|
"platform-session-invalid",
|
||||||
|
"平台登录态清除缺少 authGeneration",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
crate::clear_platform_session(generation);
|
||||||
|
ExternalAgentRunnerResponse::success(&request.request_id, json!({ "cleared": true }))
|
||||||
|
}
|
||||||
"runner.shutdown" | "shutdown" => {
|
"runner.shutdown" | "shutdown" => {
|
||||||
let provider_requests_interrupted =
|
let provider_requests_interrupted =
|
||||||
request_external_agent_runner_forced_shutdown(state);
|
request_external_agent_runner_forced_shutdown(state);
|
||||||
@@ -865,6 +924,8 @@ pub(super) fn handle_external_agent_runner_request(
|
|||||||
| "runtime.cancel"
|
| "runtime.cancel"
|
||||||
| "runtime.compact"
|
| "runtime.compact"
|
||||||
| "runner.attach_gui_owner"
|
| "runner.attach_gui_owner"
|
||||||
|
| "platform.session.install"
|
||||||
|
| "platform.session.clear"
|
||||||
| "runner.shutdown"
|
| "runner.shutdown"
|
||||||
| "shutdown"
|
| "shutdown"
|
||||||
| "runner.shutdown_for_client_exit"
|
| "runner.shutdown_for_client_exit"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64};
|
|||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5;
|
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 6;
|
||||||
|
|
||||||
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
|
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
|
||||||
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
|
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
|
||||||
@@ -268,6 +268,14 @@ pub(super) struct ExternalAgentRunnerRequestParams {
|
|||||||
pub(super) event_sink_port: Option<u16>,
|
pub(super) event_sink_port: Option<u16>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub(super) event_sink_token: Option<String>,
|
pub(super) event_sink_token: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) platform_user_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) platform_access_token: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) platform_api_base_url: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) platform_auth_generation: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m
|
|||||||
assert!(
|
assert!(
|
||||||
external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
|
external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
|
||||||
);
|
);
|
||||||
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5);
|
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint {
|
fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint {
|
||||||
@@ -639,6 +639,80 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gui_owner_registration_replays_only_the_latest_platform_session() {
|
||||||
|
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||||||
|
let config_dir = PathBuf::from("platform-session-replay-appdata");
|
||||||
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
|
&state,
|
||||||
|
&config_dir,
|
||||||
|
ExternalAgentRunnerRequestParams {
|
||||||
|
event_sink_port: Some(31_330),
|
||||||
|
event_sink_token: Some("f".repeat(64)),
|
||||||
|
..ExternalAgentRunnerRequestParams::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
remember_external_agent_runner_platform_session(
|
||||||
|
&state,
|
||||||
|
Some(("user-a", "token-a", "https://dev.genarrative.world")),
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
remember_external_agent_runner_platform_session(&state, None, 5);
|
||||||
|
remember_external_agent_runner_platform_session(
|
||||||
|
&state,
|
||||||
|
Some(("user-a", "late-token-a", "https://dev.genarrative.world")),
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
remember_external_agent_runner_platform_session(
|
||||||
|
&state,
|
||||||
|
Some((
|
||||||
|
"user-a",
|
||||||
|
"same-generation-token",
|
||||||
|
"https://dev.genarrative.world",
|
||||||
|
)),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
remember_external_agent_runner_platform_session(
|
||||||
|
&state,
|
||||||
|
Some(("user-b", "token-b", "https://dev.genarrative.world")),
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
|
||||||
|
let endpoint = test_endpoint(
|
||||||
|
"platform-session-replay-runner-token",
|
||||||
|
"platform-session-replay-boot",
|
||||||
|
31_330,
|
||||||
|
);
|
||||||
|
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||||||
|
&state,
|
||||||
|
&config_dir,
|
||||||
|
&endpoint,
|
||||||
|
|_, params| {
|
||||||
|
assert_eq!(params.platform_user_id.as_deref(), Some("user-b"));
|
||||||
|
assert_eq!(params.platform_access_token.as_deref(), Some("token-b"));
|
||||||
|
assert_eq!(
|
||||||
|
params.platform_api_base_url.as_deref(),
|
||||||
|
Some("https://dev.genarrative.world")
|
||||||
|
);
|
||||||
|
assert_eq!(params.platform_auth_generation, Some(6));
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("latest platform session is replayed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runner_output_redacts_camel_case_platform_access_token_fields() {
|
||||||
|
let config_dir = Path::new("private-appdata");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_agent_runner_output(
|
||||||
|
r#"request={"platformAccessToken":"must-not-leak"}"#,
|
||||||
|
config_dir,
|
||||||
|
),
|
||||||
|
"<sensitive runner output redacted>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() {
|
fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() {
|
||||||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||||||
|
|||||||
@@ -606,8 +606,12 @@ fn app_config_commands_write_runtime_config_file() {
|
|||||||
Some(false)
|
Some(false)
|
||||||
);
|
);
|
||||||
assert!(!saved.config.agent_llm.contains_key("generator"));
|
assert!(!saved.config.agent_llm.contains_key("generator"));
|
||||||
assert_eq!(saved.config.editor_api.api_key, "editor-key");
|
assert_eq!(saved.config.editor_api.api_key, "");
|
||||||
assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file());
|
assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file());
|
||||||
|
let persisted = fs::read_to_string(root.join(GAME_CREATOR_CONFIG_FILE_NAME))
|
||||||
|
.expect("read persisted runtime config");
|
||||||
|
assert!(!persisted.contains("editorApi"));
|
||||||
|
assert!(!persisted.contains("editor-key"));
|
||||||
|
|
||||||
let read_back = read_game_creator_app_config().expect("read runtime config");
|
let read_back = read_game_creator_app_config().expect("read runtime config");
|
||||||
assert_eq!(read_back.config.llm.model, "runtime-model");
|
assert_eq!(read_back.config.llm.model, "runtime-model");
|
||||||
|
|||||||
@@ -549,15 +549,18 @@ export function WorkspaceLauncher(props: WorkspaceLauncherProps) {
|
|||||||
export function GameChatReleaseApp({
|
export function GameChatReleaseApp({
|
||||||
initialProjectPath = '',
|
initialProjectPath = '',
|
||||||
initialSupervisorMessage = '',
|
initialSupervisorMessage = '',
|
||||||
|
allowAdvancedExternalEditorConfig = false,
|
||||||
}: {
|
}: {
|
||||||
initialProjectPath?: string;
|
initialProjectPath?: string;
|
||||||
initialSupervisorMessage?: string;
|
initialSupervisorMessage?: string;
|
||||||
|
allowAdvancedExternalEditorConfig?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<App
|
<App
|
||||||
initialProjectPath={initialProjectPath}
|
initialProjectPath={initialProjectPath}
|
||||||
projectSupervisorOnly
|
projectSupervisorOnly
|
||||||
gameChatOnly
|
gameChatOnly
|
||||||
|
allowAdvancedExternalEditorConfig={allowAdvancedExternalEditorConfig}
|
||||||
initialSupervisorMessage={initialSupervisorMessage}
|
initialSupervisorMessage={initialSupervisorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -570,6 +573,7 @@ type AppProps = {
|
|||||||
projectSupervisorOnly?: boolean;
|
projectSupervisorOnly?: boolean;
|
||||||
supervisorChatOnly?: boolean;
|
supervisorChatOnly?: boolean;
|
||||||
gameChatOnly?: boolean;
|
gameChatOnly?: boolean;
|
||||||
|
allowAdvancedExternalEditorConfig?: boolean;
|
||||||
initialSupervisorMessage?: string;
|
initialSupervisorMessage?: string;
|
||||||
onManifestChange?: (
|
onManifestChange?: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -590,6 +594,7 @@ export function App({
|
|||||||
projectSupervisorOnly = false,
|
projectSupervisorOnly = false,
|
||||||
supervisorChatOnly = false,
|
supervisorChatOnly = false,
|
||||||
gameChatOnly = false,
|
gameChatOnly = false,
|
||||||
|
allowAdvancedExternalEditorConfig = false,
|
||||||
initialSupervisorMessage = '',
|
initialSupervisorMessage = '',
|
||||||
onManifestChange,
|
onManifestChange,
|
||||||
onPreviewChange,
|
onPreviewChange,
|
||||||
@@ -10971,49 +10976,61 @@ export function App({
|
|||||||
if (projectSupervisorOnly && gameChatOnly) {
|
if (projectSupervisorOnly && gameChatOnly) {
|
||||||
const gameChatProjectPath = localProject?.projectPath ?? '';
|
const gameChatProjectPath = localProject?.projectPath ?? '';
|
||||||
return (
|
return (
|
||||||
<SupervisorChatOnlyView
|
<>
|
||||||
chatAgentBusy={chatAgentBusy}
|
<SupervisorChatOnlyView
|
||||||
chatInput={chatInput}
|
chatAgentBusy={chatAgentBusy}
|
||||||
gameChatMode
|
chatInput={chatInput}
|
||||||
messagesRef={supervisorChatMessagesRef}
|
gameChatMode
|
||||||
onCancelConfirmation={cancelUiCommandConfirmation}
|
messagesRef={supervisorChatMessagesRef}
|
||||||
onChatInputChange={setChatInput}
|
onCancelConfirmation={cancelUiCommandConfirmation}
|
||||||
onCloseRuntimeConfig={() => setRuntimeConfigOpen(false)}
|
onChatInputChange={setChatInput}
|
||||||
onConfirmConfirmation={confirmUiCommand}
|
onCloseRuntimeConfig={() => setRuntimeConfigOpen(false)}
|
||||||
onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)}
|
onConfirmConfirmation={confirmUiCommand}
|
||||||
onProjectPick={() => void handleGameChatProjectPick()}
|
onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)}
|
||||||
onScroll={handleSupervisorChatScroll}
|
onProjectPick={() => void handleGameChatProjectPick()}
|
||||||
onShowEarlierMessages={showEarlierConversationMessages}
|
onScroll={handleSupervisorChatScroll}
|
||||||
onSubmit={handleProjectSupervisorOnlySubmit}
|
onShowEarlierMessages={showEarlierConversationMessages}
|
||||||
onToolAction={handleProjectSupervisorToolAction}
|
onSubmit={handleProjectSupervisorOnlySubmit}
|
||||||
onUserInput={handleProjectSupervisorUserInput}
|
onToolAction={handleProjectSupervisorToolAction}
|
||||||
pendingConfirmation={pendingUiConfirmation}
|
onUserInput={handleProjectSupervisorUserInput}
|
||||||
pendingCommand={pendingCommand}
|
pendingConfirmation={pendingUiConfirmation}
|
||||||
onCancelPendingCommand={handlePendingCommandCancel}
|
pendingCommand={pendingCommand}
|
||||||
onConfirmPendingCommand={() => void handlePendingCommandConfirm()}
|
onCancelPendingCommand={handlePendingCommandCancel}
|
||||||
pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate}
|
onConfirmPendingCommand={() => void handlePendingCommandConfirm()}
|
||||||
onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder}
|
pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate}
|
||||||
onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder}
|
onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder}
|
||||||
preview={preview}
|
onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder}
|
||||||
previewRevision={gameChatPreviewRevision}
|
preview={preview}
|
||||||
previewStatus={previewStatus}
|
previewRevision={gameChatPreviewRevision}
|
||||||
projectPath={gameChatProjectPath}
|
previewStatus={previewStatus}
|
||||||
projectReady={Boolean(localProject)}
|
projectPath={gameChatProjectPath}
|
||||||
projectSelectionBusy={gameChatProjectSelectionBusy}
|
projectReady={Boolean(localProject)}
|
||||||
runtime={projectSupervisorRuntime}
|
projectSelectionBusy={gameChatProjectSelectionBusy}
|
||||||
runtimeByAgentId={agentRuntimeById}
|
runtime={projectSupervisorRuntime}
|
||||||
manifest={manifest}
|
runtimeByAgentId={agentRuntimeById}
|
||||||
runtimeConfigOpen={runtimeConfigOpen}
|
manifest={manifest}
|
||||||
runtimeError={projectSupervisorRuntimeError}
|
runtimeConfigOpen={
|
||||||
transientReply={projectSupervisorTransientReply}
|
allowAdvancedExternalEditorConfig ? false : runtimeConfigOpen
|
||||||
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
|
}
|
||||||
hasConversationControls={projectSupervisorHasConversationControls}
|
runtimeError={projectSupervisorRuntimeError}
|
||||||
hiddenConversationCount={hiddenConversationCount}
|
transientReply={projectSupervisorTransientReply}
|
||||||
needsUserInput={projectSupervisorNeedsUserInput}
|
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
|
||||||
visibleMessages={visibleMessages}
|
hasConversationControls={projectSupervisorHasConversationControls}
|
||||||
workspaceStatus={workspaceStatus}
|
hiddenConversationCount={hiddenConversationCount}
|
||||||
expectedRunId={projectSupervisorExpectedRunId}
|
needsUserInput={projectSupervisorNeedsUserInput}
|
||||||
/>
|
visibleMessages={visibleMessages}
|
||||||
|
workspaceStatus={workspaceStatus}
|
||||||
|
expectedRunId={projectSupervisorExpectedRunId}
|
||||||
|
/>
|
||||||
|
{runtimeConfigOpen && allowAdvancedExternalEditorConfig ? (
|
||||||
|
<RuntimeConfigDialog
|
||||||
|
allowAdvancedExternalEditorConfig
|
||||||
|
projectPath={gameChatProjectPath}
|
||||||
|
onClose={() => setRuntimeConfigOpen(false)}
|
||||||
|
onLog={(entry) => setCommandLog((current) => [...current, entry])}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,16 @@ import {
|
|||||||
loginClientWithPhoneCode,
|
loginClientWithPhoneCode,
|
||||||
logoutClientAuthSession,
|
logoutClientAuthSession,
|
||||||
normalizeAuthPhoneInput,
|
normalizeAuthPhoneInput,
|
||||||
refreshClientAuthAccessToken,
|
|
||||||
sendClientPhoneLoginCode,
|
sendClientPhoneLoginCode,
|
||||||
} from '../services/clientAuth';
|
} from '../services/clientAuth';
|
||||||
|
import {
|
||||||
|
beginPlatformSessionTransition,
|
||||||
|
clearCommittedPlatformSession,
|
||||||
|
commitAuthenticatedPlatformSession,
|
||||||
|
currentPlatformSessionGeneration,
|
||||||
|
refreshPlatformSessionForGeneration,
|
||||||
|
subscribePlatformSessionRefresh,
|
||||||
|
} from '../services/platformSession';
|
||||||
|
|
||||||
type ClientRuntimeErrorBoundaryProps = {
|
type ClientRuntimeErrorBoundaryProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -92,15 +99,30 @@ export function AuthenticatedClient({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
async function hydrateAuth() {
|
async function hydrateAuth() {
|
||||||
|
const hydrationGeneration = currentPlatformSessionGeneration();
|
||||||
try {
|
try {
|
||||||
if (!getStoredAuthAccessToken()) {
|
if (!getStoredAuthAccessToken()) {
|
||||||
await refreshClientAuthAccessToken();
|
const refreshed =
|
||||||
|
await refreshPlatformSessionForGeneration(hydrationGeneration);
|
||||||
|
if (!refreshed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const user = await getCurrentClientAuthUser();
|
const user = await getCurrentClientAuthUser();
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user) {
|
if (user) {
|
||||||
|
const committedGeneration = await commitAuthenticatedPlatformSession(
|
||||||
|
user,
|
||||||
|
hydrationGeneration,
|
||||||
|
);
|
||||||
|
if (committedGeneration === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAuthUser(user);
|
setAuthUser(user);
|
||||||
setAuthStatus('authenticated');
|
setAuthStatus('authenticated');
|
||||||
return;
|
return;
|
||||||
@@ -123,12 +145,27 @@ export function AuthenticatedClient({
|
|||||||
}
|
}
|
||||||
if (getStoredAuthAccessToken()) {
|
if (getStoredAuthAccessToken()) {
|
||||||
try {
|
try {
|
||||||
await refreshClientAuthAccessToken();
|
const refreshed =
|
||||||
|
await refreshPlatformSessionForGeneration(hydrationGeneration);
|
||||||
|
if (!refreshed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const user = await getCurrentClientAuthUser();
|
const user = await getCurrentClientAuthUser();
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user) {
|
if (user) {
|
||||||
|
const committedGeneration =
|
||||||
|
await commitAuthenticatedPlatformSession(
|
||||||
|
user,
|
||||||
|
hydrationGeneration,
|
||||||
|
);
|
||||||
|
if (committedGeneration === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAuthUser(user);
|
setAuthUser(user);
|
||||||
setAuthStatus('authenticated');
|
setAuthStatus('authenticated');
|
||||||
return;
|
return;
|
||||||
@@ -159,6 +196,25 @@ export function AuthenticatedClient({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribePlatformSessionRefresh((result) => {
|
||||||
|
if (result.status === 'refreshed') {
|
||||||
|
setAuthUser((current) =>
|
||||||
|
current?.id === result.user.id ? result.user : current,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
clearStoredAuthAccessToken();
|
||||||
|
setAuthUser(null);
|
||||||
|
setAuthStatus('unauthenticated');
|
||||||
|
setLoginStatus('登录已失效,请重新登录');
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (codeCooldownSeconds <= 0) {
|
if (codeCooldownSeconds <= 0) {
|
||||||
return;
|
return;
|
||||||
@@ -211,11 +267,19 @@ export function AuthenticatedClient({
|
|||||||
}
|
}
|
||||||
setLoginBusy(true);
|
setLoginBusy(true);
|
||||||
setLoginStatus('正在登录');
|
setLoginStatus('正在登录');
|
||||||
|
const loginGeneration = beginPlatformSessionTransition();
|
||||||
try {
|
try {
|
||||||
const user =
|
const user =
|
||||||
loginMode === 'code'
|
loginMode === 'code'
|
||||||
? await loginClientWithPhoneCode(normalizedPhone, code)
|
? await loginClientWithPhoneCode(normalizedPhone, code)
|
||||||
: await loginClientWithPassword(normalizedPhone, password);
|
: await loginClientWithPassword(normalizedPhone, password);
|
||||||
|
const committedGeneration = await commitAuthenticatedPlatformSession(
|
||||||
|
user,
|
||||||
|
loginGeneration,
|
||||||
|
);
|
||||||
|
if (committedGeneration === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAuthUser(user);
|
setAuthUser(user);
|
||||||
setAuthStatus('authenticated');
|
setAuthStatus('authenticated');
|
||||||
setCode('');
|
setCode('');
|
||||||
@@ -228,11 +292,13 @@ export function AuthenticatedClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
const logoutGeneration = beginPlatformSessionTransition();
|
||||||
try {
|
try {
|
||||||
await logoutClientAuthSession();
|
await logoutClientAuthSession();
|
||||||
} catch {
|
} catch {
|
||||||
clearStoredAuthAccessToken();
|
clearStoredAuthAccessToken();
|
||||||
}
|
}
|
||||||
|
await clearCommittedPlatformSession(logoutGeneration);
|
||||||
setAuthUser(null);
|
setAuthUser(null);
|
||||||
setAuthStatus('unauthenticated');
|
setAuthStatus('unauthenticated');
|
||||||
setLoginStatus('已退出登录');
|
setLoginStatus('已退出登录');
|
||||||
|
|||||||
+30
-2
@@ -16,6 +16,7 @@ import type {
|
|||||||
GameCreationAppAssetManifestEntry,
|
GameCreationAppAssetManifestEntry,
|
||||||
GameCreationAppManifest,
|
GameCreationAppManifest,
|
||||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||||
|
import { requestPlatformSessionRefresh } from '../../services/platformSession';
|
||||||
|
|
||||||
const MAX_SAFE_REVISION = Number.MAX_SAFE_INTEGER;
|
const MAX_SAFE_REVISION = Number.MAX_SAFE_INTEGER;
|
||||||
export const LOCAL_ASSET_COMMITTED_EVENT =
|
export const LOCAL_ASSET_COMMITTED_EVENT =
|
||||||
@@ -190,6 +191,14 @@ function failure(error: unknown): ImageCanvasHostResult<never> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAuthenticationRequiredError(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
message.includes('authentication-required') ||
|
||||||
|
message.includes('登录已失效')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function conflict(
|
function conflict(
|
||||||
kind: 'project-identity' | 'host-revision' | 'draft-revision',
|
kind: 'project-identity' | 'host-revision' | 'draft-revision',
|
||||||
draft: ImageCanvasDraft | null,
|
draft: ImageCanvasDraft | null,
|
||||||
@@ -237,6 +246,25 @@ export function createTauriImageCanvasHostAdapter(input: {
|
|||||||
}): TauriImageCanvasHostAdapter {
|
}): TauriImageCanvasHostAdapter {
|
||||||
const invokeInput = <T>(command: string, value: Record<string, unknown>) =>
|
const invokeInput = <T>(command: string, value: Record<string, unknown>) =>
|
||||||
input.invoke<T>(command, { input: value });
|
input.invoke<T>(command, { input: value });
|
||||||
|
const invokeAuthenticatedInput = async <T>(
|
||||||
|
command: string,
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
return await invokeInput<T>(command, value);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAuthenticationRequiredError(error)) throw error;
|
||||||
|
const refresh = await requestPlatformSessionRefresh();
|
||||||
|
if (refresh.status !== 'refreshed') {
|
||||||
|
throw new Error(
|
||||||
|
refresh.status === 'stale'
|
||||||
|
? '登录账号已变化,原生成操作已停止'
|
||||||
|
: '登录已失效,请重新登录',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return invokeInput<T>(command, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
const baseScope = (scope: ImageCanvasHostScope) => {
|
const baseScope = (scope: ImageCanvasHostScope) => {
|
||||||
if (scope.projectId !== input.expectedProjectId) {
|
if (scope.projectId !== input.expectedProjectId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -286,7 +314,7 @@ export function createTauriImageCanvasHostAdapter(input: {
|
|||||||
generationInput.generationId,
|
generationInput.generationId,
|
||||||
generationInput.onProgress,
|
generationInput.onProgress,
|
||||||
);
|
);
|
||||||
const result = await invokeInput<GenerationCommandResult>(
|
const result = await invokeAuthenticatedInput<GenerationCommandResult>(
|
||||||
'generate_local_project_asset_canvas_image',
|
'generate_local_project_asset_canvas_image',
|
||||||
{
|
{
|
||||||
...baseScope(generationInput.scope),
|
...baseScope(generationInput.scope),
|
||||||
@@ -326,7 +354,7 @@ export function createTauriImageCanvasHostAdapter(input: {
|
|||||||
null,
|
null,
|
||||||
recoverInput.onProgress,
|
recoverInput.onProgress,
|
||||||
);
|
);
|
||||||
const result = await invokeInput<{
|
const result = await invokeAuthenticatedInput<{
|
||||||
resumedGenerationIds: string[];
|
resumedGenerationIds: string[];
|
||||||
serviceIdentityConfirmations: ImageCanvasGenerationServiceIdentityConfirmation[];
|
serviceIdentityConfirmations: ImageCanvasGenerationServiceIdentityConfirmation[];
|
||||||
}>(
|
}>(
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
|
|||||||
},
|
},
|
||||||
agentLlm: {},
|
agentLlm: {},
|
||||||
editorApi: {
|
editorApi: {
|
||||||
baseUrl: 'http://127.0.0.1:8082',
|
baseUrl: 'https://dev.genarrative.world',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
},
|
},
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
@@ -501,6 +501,7 @@ function normalizeRuntimeMcpServerConfig(
|
|||||||
|
|
||||||
function normalizeRuntimeConfigDraft(
|
function normalizeRuntimeConfigDraft(
|
||||||
config: GameCreatorAppConfig,
|
config: GameCreatorAppConfig,
|
||||||
|
allowAdvancedExternalEditorConfig: boolean,
|
||||||
): GameCreatorAppConfig {
|
): GameCreatorAppConfig {
|
||||||
const apiKind: GameCreatorLlmApiKind = [
|
const apiKind: GameCreatorLlmApiKind = [
|
||||||
'openai_responses',
|
'openai_responses',
|
||||||
@@ -564,16 +565,21 @@ function normalizeRuntimeConfigDraft(
|
|||||||
retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1),
|
retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1),
|
||||||
},
|
},
|
||||||
agentLlm,
|
agentLlm,
|
||||||
|
editorApi: allowAdvancedExternalEditorConfig
|
||||||
|
? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi }
|
||||||
|
: { ...defaultRuntimeConfigDraft.editorApi },
|
||||||
mcpServers,
|
mcpServers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RuntimeConfigDialog({
|
export function RuntimeConfigDialog({
|
||||||
projectPath,
|
projectPath,
|
||||||
|
allowAdvancedExternalEditorConfig = false,
|
||||||
onClose,
|
onClose,
|
||||||
onLog,
|
onLog,
|
||||||
}: {
|
}: {
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
allowAdvancedExternalEditorConfig?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onLog?: (entry: string) => void;
|
onLog?: (entry: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -694,18 +700,6 @@ export function RuntimeConfigDialog({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRuntimeEditorConfig<
|
|
||||||
K extends keyof GameCreatorAppConfig['editorApi'],
|
|
||||||
>(key: K, value: GameCreatorAppConfig['editorApi'][K]) {
|
|
||||||
setRuntimeConfigDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
editorApi: {
|
|
||||||
...current.editorApi,
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRuntimeMcpServer<K extends keyof GameCreatorMcpServerConfig>(
|
function updateRuntimeMcpServer<K extends keyof GameCreatorMcpServerConfig>(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
key: K,
|
key: K,
|
||||||
@@ -801,7 +795,10 @@ export function RuntimeConfigDialog({
|
|||||||
'read_game_creator_app_config',
|
'read_game_creator_app_config',
|
||||||
);
|
);
|
||||||
setRuntimeConfigPath(result.path);
|
setRuntimeConfigPath(result.path);
|
||||||
const config = normalizeRuntimeConfigDraft(result.config);
|
const config = normalizeRuntimeConfigDraft(
|
||||||
|
result.config,
|
||||||
|
allowAdvancedExternalEditorConfig,
|
||||||
|
);
|
||||||
setRuntimeConfigDraft(config);
|
setRuntimeConfigDraft(config);
|
||||||
setMcpStructuredDrafts(runtimeMcpStructuredDrafts(config.mcpServers));
|
setMcpStructuredDrafts(runtimeMcpStructuredDrafts(config.mcpServers));
|
||||||
setMcpCatalog(null);
|
setMcpCatalog(null);
|
||||||
@@ -832,19 +829,28 @@ export function RuntimeConfigDialog({
|
|||||||
setRuntimeConfigBusy(true);
|
setRuntimeConfigBusy(true);
|
||||||
setRuntimeConfigStatus('正在保存');
|
setRuntimeConfigStatus('正在保存');
|
||||||
try {
|
try {
|
||||||
const config = normalizeRuntimeConfigDraft({
|
const config = normalizeRuntimeConfigDraft(
|
||||||
...runtimeConfigDraft,
|
{
|
||||||
mcpServers: materializeRuntimeMcpServers(
|
...runtimeConfigDraft,
|
||||||
runtimeConfigDraft.mcpServers,
|
editorApi: allowAdvancedExternalEditorConfig
|
||||||
mcpStructuredDrafts,
|
? runtimeConfigDraft.editorApi
|
||||||
),
|
: defaultRuntimeConfigDraft.editorApi,
|
||||||
});
|
mcpServers: materializeRuntimeMcpServers(
|
||||||
|
runtimeConfigDraft.mcpServers,
|
||||||
|
mcpStructuredDrafts,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
allowAdvancedExternalEditorConfig,
|
||||||
|
);
|
||||||
const result = await invoke<GameCreatorAppConfigView>(
|
const result = await invoke<GameCreatorAppConfigView>(
|
||||||
'write_game_creator_app_config',
|
'write_game_creator_app_config',
|
||||||
{ config },
|
{ config },
|
||||||
);
|
);
|
||||||
setRuntimeConfigPath(result.path);
|
setRuntimeConfigPath(result.path);
|
||||||
const savedConfig = normalizeRuntimeConfigDraft(result.config);
|
const savedConfig = normalizeRuntimeConfigDraft(
|
||||||
|
result.config,
|
||||||
|
allowAdvancedExternalEditorConfig,
|
||||||
|
);
|
||||||
setRuntimeConfigDraft(savedConfig);
|
setRuntimeConfigDraft(savedConfig);
|
||||||
setMcpStructuredDrafts(
|
setMcpStructuredDrafts(
|
||||||
runtimeMcpStructuredDrafts(savedConfig.mcpServers),
|
runtimeMcpStructuredDrafts(savedConfig.mcpServers),
|
||||||
@@ -1546,43 +1552,49 @@ export function RuntimeConfigDialog({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{activeSection === 'connections' ? (
|
|
||||||
<>
|
|
||||||
<label>
|
|
||||||
External Editor Base URL
|
|
||||||
<input
|
|
||||||
aria-label="External Editor Base URL"
|
|
||||||
value={runtimeConfigDraft.editorApi.baseUrl}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateRuntimeEditorConfig(
|
|
||||||
'baseUrl',
|
|
||||||
event.currentTarget.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
External Editor API Key
|
|
||||||
<input
|
|
||||||
aria-label="External Editor API Key"
|
|
||||||
autoComplete="off"
|
|
||||||
type="password"
|
|
||||||
value={runtimeConfigDraft.editorApi.apiKey}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateRuntimeEditorConfig(
|
|
||||||
'apiKey',
|
|
||||||
event.currentTarget.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{activeSection === 'connections' ? (
|
{activeSection === 'connections' ? (
|
||||||
<section
|
<section
|
||||||
className="runtime-mcp-section"
|
className="runtime-mcp-section"
|
||||||
aria-label="MCP servers"
|
aria-label="MCP servers"
|
||||||
>
|
>
|
||||||
|
{allowAdvancedExternalEditorConfig ? (
|
||||||
|
<div className="runtime-mcp-grid">
|
||||||
|
<label className="runtime-mcp-field-wide">
|
||||||
|
External Editor Base URL
|
||||||
|
<input
|
||||||
|
aria-label="External Editor Base URL"
|
||||||
|
value={runtimeConfigDraft.editorApi.baseUrl}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRuntimeConfigDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
editorApi: {
|
||||||
|
...current.editorApi,
|
||||||
|
baseUrl: event.currentTarget.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="runtime-mcp-field-wide">
|
||||||
|
External Editor API Key
|
||||||
|
<input
|
||||||
|
aria-label="External Editor API Key"
|
||||||
|
autoComplete="off"
|
||||||
|
type="password"
|
||||||
|
value={runtimeConfigDraft.editorApi.apiKey}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRuntimeConfigDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
editorApi: {
|
||||||
|
...current.editorApi,
|
||||||
|
apiKey: event.currentTarget.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<header className="runtime-mcp-header">
|
<header className="runtime-mcp-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>MCP servers</h3>
|
<h3>MCP servers</h3>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const gameChatApp = (
|
|||||||
<GameChatReleaseApp
|
<GameChatReleaseApp
|
||||||
initialProjectPath={supervisorChatProjectPath}
|
initialProjectPath={supervisorChatProjectPath}
|
||||||
initialSupervisorMessage={initialGameChatMessage}
|
initialSupervisorMessage={initialGameChatMessage}
|
||||||
|
allowAdvancedExternalEditorConfig={gameChatReleaseMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
||||||
|
import { resolveTauriInvoke } from '../app/tauri';
|
||||||
|
import {
|
||||||
|
getCurrentClientAuthUser,
|
||||||
|
getStoredAuthAccessToken,
|
||||||
|
refreshClientAuthAccessToken,
|
||||||
|
} from './clientAuth';
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||||
|
|
||||||
|
type CommittedPlatformSession = {
|
||||||
|
user: AuthUser;
|
||||||
|
accessToken: string;
|
||||||
|
apiBaseUrl: string;
|
||||||
|
generation: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlatformSessionRefreshResult =
|
||||||
|
| { status: 'refreshed'; user: AuthUser; generation: number }
|
||||||
|
| { status: 'stale' }
|
||||||
|
| { status: 'failed'; error: unknown };
|
||||||
|
|
||||||
|
type PlatformSessionRefreshListener = (
|
||||||
|
result: PlatformSessionRefreshResult,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
let platformAuthGeneration = 0;
|
||||||
|
let committedPlatformSession: CommittedPlatformSession | null = null;
|
||||||
|
let platformSessionRefreshPromise: Promise<PlatformSessionRefreshResult> | null =
|
||||||
|
null;
|
||||||
|
const platformSessionRefreshListeners =
|
||||||
|
new Set<PlatformSessionRefreshListener>();
|
||||||
|
|
||||||
|
function restoreCommittedAccessToken() {
|
||||||
|
if (committedPlatformSession?.accessToken) {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
ACCESS_TOKEN_STORAGE_KEY,
|
||||||
|
committedPlatformSession.accessToken,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) {
|
||||||
|
for (const listener of platformSessionRefreshListeners) {
|
||||||
|
listener(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installCommittedPlatformSession(
|
||||||
|
session: CommittedPlatformSession,
|
||||||
|
) {
|
||||||
|
const invoke = resolveTauriInvoke();
|
||||||
|
if (!invoke) return;
|
||||||
|
await invoke('install_platform_account_session', {
|
||||||
|
userId: session.user.id,
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
apiBaseUrl: session.apiBaseUrl,
|
||||||
|
generation: session.generation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePlatformApiBaseUrl() {
|
||||||
|
if (!import.meta.env.DEV || import.meta.env.MODE === 'test') {
|
||||||
|
return 'https://dev.genarrative.world';
|
||||||
|
}
|
||||||
|
const response = await fetch('/__agc_dev_server.json', {
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('无法读取本地陶泥儿 API 服务地址');
|
||||||
|
}
|
||||||
|
const marker = (await response.json()) as { apiTarget?: unknown };
|
||||||
|
const apiBaseUrl =
|
||||||
|
typeof marker.apiTarget === 'string' ? marker.apiTarget.trim() : '';
|
||||||
|
if (!/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/u.test(apiBaseUrl)) {
|
||||||
|
throw new Error('本地陶泥儿 API 服务地址不在受信任白名单内');
|
||||||
|
}
|
||||||
|
return apiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitPlatformSession(
|
||||||
|
user: AuthUser,
|
||||||
|
expectedGeneration: number,
|
||||||
|
): Promise<CommittedPlatformSession | null> {
|
||||||
|
const accessToken = getStoredAuthAccessToken();
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('陶泥儿登录凭据缺失,请重新登录');
|
||||||
|
}
|
||||||
|
if (platformAuthGeneration !== expectedGeneration) {
|
||||||
|
if (
|
||||||
|
committedPlatformSession?.user.id === user.id &&
|
||||||
|
committedPlatformSession.accessToken === accessToken
|
||||||
|
) {
|
||||||
|
return committedPlatformSession;
|
||||||
|
}
|
||||||
|
restoreCommittedAccessToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
platformAuthGeneration += 1;
|
||||||
|
const apiBaseUrl = await resolvePlatformApiBaseUrl();
|
||||||
|
committedPlatformSession = {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
apiBaseUrl,
|
||||||
|
generation: platformAuthGeneration,
|
||||||
|
};
|
||||||
|
return committedPlatformSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentPlatformSessionGeneration() {
|
||||||
|
return platformAuthGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginPlatformSessionTransition() {
|
||||||
|
platformAuthGeneration += 1;
|
||||||
|
committedPlatformSession = null;
|
||||||
|
return platformAuthGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitAuthenticatedPlatformSession(
|
||||||
|
user: AuthUser,
|
||||||
|
expectedGeneration: number,
|
||||||
|
) {
|
||||||
|
const session = await commitPlatformSession(user, expectedGeneration);
|
||||||
|
if (!session) return null;
|
||||||
|
await installCommittedPlatformSession(session);
|
||||||
|
return session.generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshPlatformSessionForGeneration(
|
||||||
|
expectedGeneration: number,
|
||||||
|
) {
|
||||||
|
const token = await refreshClientAuthAccessToken();
|
||||||
|
if (platformAuthGeneration !== expectedGeneration) {
|
||||||
|
restoreCommittedAccessToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestPlatformSessionRefresh(expectedUserId?: string) {
|
||||||
|
if (platformSessionRefreshPromise) return platformSessionRefreshPromise;
|
||||||
|
|
||||||
|
const expectedGeneration = platformAuthGeneration;
|
||||||
|
const expectedSessionUserId =
|
||||||
|
expectedUserId?.trim() || committedPlatformSession?.user.id || '';
|
||||||
|
platformSessionRefreshPromise =
|
||||||
|
(async (): Promise<PlatformSessionRefreshResult> => {
|
||||||
|
try {
|
||||||
|
const refreshed =
|
||||||
|
await refreshPlatformSessionForGeneration(expectedGeneration);
|
||||||
|
if (!refreshed) return { status: 'stale' };
|
||||||
|
const user = await getCurrentClientAuthUser();
|
||||||
|
if (
|
||||||
|
platformAuthGeneration !== expectedGeneration ||
|
||||||
|
!user ||
|
||||||
|
(expectedSessionUserId && user.id !== expectedSessionUserId)
|
||||||
|
) {
|
||||||
|
restoreCommittedAccessToken();
|
||||||
|
return { status: 'stale' };
|
||||||
|
}
|
||||||
|
const session = await commitPlatformSession(user, expectedGeneration);
|
||||||
|
if (!session) return { status: 'stale' };
|
||||||
|
await installCommittedPlatformSession(session);
|
||||||
|
return {
|
||||||
|
status: 'refreshed',
|
||||||
|
user,
|
||||||
|
generation: session.generation,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (platformAuthGeneration === expectedGeneration) {
|
||||||
|
const clearGeneration = beginPlatformSessionTransition();
|
||||||
|
await clearCommittedPlatformSession(clearGeneration);
|
||||||
|
}
|
||||||
|
return { status: 'failed', error };
|
||||||
|
}
|
||||||
|
})().then((result) => {
|
||||||
|
notifyPlatformSessionRefresh(result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
platformSessionRefreshPromise.finally(() => {
|
||||||
|
platformSessionRefreshPromise = null;
|
||||||
|
});
|
||||||
|
return platformSessionRefreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribePlatformSessionRefresh(
|
||||||
|
listener: PlatformSessionRefreshListener,
|
||||||
|
) {
|
||||||
|
platformSessionRefreshListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
platformSessionRefreshListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCommittedPlatformSession(generation: number) {
|
||||||
|
restoreCommittedAccessToken();
|
||||||
|
const invoke = resolveTauriInvoke();
|
||||||
|
if (!invoke) return;
|
||||||
|
await invoke('clear_platform_account_session', { generation });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPlatformSessionStateForTests() {
|
||||||
|
platformAuthGeneration = 0;
|
||||||
|
committedPlatformSession = null;
|
||||||
|
platformSessionRefreshPromise = null;
|
||||||
|
platformSessionRefreshListeners.clear();
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
LocalGamePreviewFrame,
|
LocalGamePreviewFrame,
|
||||||
resolveEmbeddedPreviewUrl,
|
resolveEmbeddedPreviewUrl,
|
||||||
} from '../../features/project-workspace/LocalGamePreviewFrame';
|
} from '../../features/project-workspace/LocalGamePreviewFrame';
|
||||||
|
import { requestPlatformSessionRefresh } from '../../services/platformSession';
|
||||||
import {
|
import {
|
||||||
type ProjectManifestSnapshotMetadata,
|
type ProjectManifestSnapshotMetadata,
|
||||||
resolveResourceFocusIntent,
|
resolveResourceFocusIntent,
|
||||||
@@ -118,6 +119,31 @@ type ResourceSortMode = ProjectResourceCanvasLayoutMode;
|
|||||||
type WorkbenchMode = 'resources' | 'run';
|
type WorkbenchMode = 'resources' | 'run';
|
||||||
type ApprovalMode = 'strict' | 'risk' | 'none';
|
type ApprovalMode = 'strict' | 'risk' | 'none';
|
||||||
|
|
||||||
|
function isPlatformAuthenticationRequired(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
message.includes('authentication-required') ||
|
||||||
|
message.includes('登录已失效')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withPlatformSessionRefresh<T>(operation: () => Promise<T>) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPlatformAuthenticationRequired(error)) throw error;
|
||||||
|
const refresh = await requestPlatformSessionRefresh();
|
||||||
|
if (refresh.status !== 'refreshed') {
|
||||||
|
throw new Error(
|
||||||
|
refresh.status === 'stale'
|
||||||
|
? '登录账号已变化,原资源操作已停止'
|
||||||
|
: '登录已失效,请重新登录',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return operation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type WebKitGestureEvent = Event & {
|
type WebKitGestureEvent = Event & {
|
||||||
clientX?: number;
|
clientX?: number;
|
||||||
clientY?: number;
|
clientY?: number;
|
||||||
@@ -1395,7 +1421,8 @@ export default function ProjectDevelopmentView({
|
|||||||
}
|
}
|
||||||
const viewport = resolveViewport(event);
|
const viewport = resolveViewport(event);
|
||||||
const category = viewport?.dataset.resourceSectionScroll as
|
const category = viewport?.dataset.resourceSectionScroll as
|
||||||
ResourceCategory | undefined;
|
| ResourceCategory
|
||||||
|
| undefined;
|
||||||
if (!viewport || !category) {
|
if (!viewport || !category) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1414,7 +1441,8 @@ export default function ProjectDevelopmentView({
|
|||||||
const event = rawEvent as WebKitGestureEvent;
|
const event = rawEvent as WebKitGestureEvent;
|
||||||
const viewport = resolveViewport(event);
|
const viewport = resolveViewport(event);
|
||||||
const category = viewport?.dataset.resourceSectionScroll as
|
const category = viewport?.dataset.resourceSectionScroll as
|
||||||
ResourceCategory | undefined;
|
| ResourceCategory
|
||||||
|
| undefined;
|
||||||
if (!viewport || !category) {
|
if (!viewport || !category) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1502,7 +1530,8 @@ export default function ProjectDevelopmentView({
|
|||||||
.querySelectorAll<HTMLElement>('[data-resource-section-scroll]')
|
.querySelectorAll<HTMLElement>('[data-resource-section-scroll]')
|
||||||
.forEach((viewport) => {
|
.forEach((viewport) => {
|
||||||
const category = viewport.dataset.resourceSectionScroll as
|
const category = viewport.dataset.resourceSectionScroll as
|
||||||
ResourceCategory | undefined;
|
| ResourceCategory
|
||||||
|
| undefined;
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1522,7 +1551,8 @@ export default function ProjectDevelopmentView({
|
|||||||
?.querySelectorAll<HTMLElement>('[data-resource-section-scroll]')
|
?.querySelectorAll<HTMLElement>('[data-resource-section-scroll]')
|
||||||
.forEach((viewport) => {
|
.forEach((viewport) => {
|
||||||
const category = viewport.dataset.resourceSectionScroll as
|
const category = viewport.dataset.resourceSectionScroll as
|
||||||
ResourceCategory | undefined;
|
| ResourceCategory
|
||||||
|
| undefined;
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2290,15 +2320,17 @@ export default function ProjectDevelopmentView({
|
|||||||
setPendingResourceEditsError('');
|
setPendingResourceEditsError('');
|
||||||
setAssetCanvasNotice(`正在继续“${pending.assetName}”的原生成 operation…`);
|
setAssetCanvasNotice(`正在继续“${pending.assetName}”的原生成 operation…`);
|
||||||
try {
|
try {
|
||||||
const result = await invoke<DeriveLocalProjectResourceResult>(
|
const result = await withPlatformSessionRefresh(() =>
|
||||||
'resume_local_project_resource_edit',
|
invoke<DeriveLocalProjectResourceResult>(
|
||||||
{
|
'resume_local_project_resource_edit',
|
||||||
input: {
|
{
|
||||||
projectPath: actionProject.projectPath,
|
input: {
|
||||||
expectedProjectId: actionProject.projectId,
|
projectPath: actionProject.projectPath,
|
||||||
operationId: pending.operationId,
|
expectedProjectId: actionProject.projectId,
|
||||||
|
operationId: pending.operationId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
if (!isCurrentRecoveryProject(actionProject)) return;
|
if (!isCurrentRecoveryProject(actionProject)) return;
|
||||||
if (result.manifest.projectId !== actionProject.projectId) {
|
if (result.manifest.projectId !== actionProject.projectId) {
|
||||||
@@ -2595,30 +2627,32 @@ export default function ProjectDevelopmentView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const resource = route.resource;
|
const resource = route.resource;
|
||||||
const result = await invoke<DeriveLocalProjectResourceResult>(
|
const result = await withPlatformSessionRefresh(() =>
|
||||||
'derive_local_project_resource',
|
invoke<DeriveLocalProjectResourceResult>(
|
||||||
{
|
'derive_local_project_resource',
|
||||||
input: {
|
{
|
||||||
projectPath,
|
input: {
|
||||||
expectedProjectId: manifest.projectId,
|
projectPath,
|
||||||
expectedProjectRevision,
|
expectedProjectId: manifest.projectId,
|
||||||
operationId: route.operationId,
|
expectedProjectRevision,
|
||||||
idempotencyKey: route.idempotencyKey,
|
operationId: route.operationId,
|
||||||
editKind: route.capability.editKind,
|
idempotencyKey: route.idempotencyKey,
|
||||||
sourceResourceId: resource.id,
|
editKind: route.capability.editKind,
|
||||||
sourceAssetId: resource.manifestAssetId,
|
sourceResourceId: resource.id,
|
||||||
sourcePath:
|
sourceAssetId: resource.manifestAssetId,
|
||||||
resource.version || resource.subtype === 'agent-result'
|
sourcePath:
|
||||||
? null
|
resource.version || resource.subtype === 'agent-result'
|
||||||
: resource.path,
|
? null
|
||||||
sourceMediaType: route.capability.sourceMediaType,
|
: resource.path,
|
||||||
sourceSubtype: resource.subtype,
|
sourceMediaType: route.capability.sourceMediaType,
|
||||||
producerTaskId: resource.producerTaskId,
|
sourceSubtype: resource.subtype,
|
||||||
sourceVersionId: resource.version?.versionId ?? null,
|
producerTaskId: resource.producerTaskId,
|
||||||
prompt,
|
sourceVersionId: resource.version?.versionId ?? null,
|
||||||
assetName,
|
prompt,
|
||||||
|
assetName,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
result.manifest.projectId !== manifest.projectId ||
|
result.manifest.projectId !== manifest.projectId ||
|
||||||
@@ -2816,16 +2850,16 @@ export default function ProjectDevelopmentView({
|
|||||||
dependencyLayoutSettled: dependencyLayout.settled,
|
dependencyLayoutSettled: dependencyLayout.settled,
|
||||||
dependencyPositioned: Boolean(
|
dependencyPositioned: Boolean(
|
||||||
intent.resourceId &&
|
intent.resourceId &&
|
||||||
dependencyLayout.layout.positions.some(
|
dependencyLayout.layout.positions.some(
|
||||||
(position) => position.resourceId === intent.resourceId,
|
(position) => position.resourceId === intent.resourceId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
typeLayoutSettled: typeLayout.settled,
|
typeLayoutSettled: typeLayout.settled,
|
||||||
typePositioned: Boolean(
|
typePositioned: Boolean(
|
||||||
intent.resourceId &&
|
intent.resourceId &&
|
||||||
typeLayout.layout.positions.some(
|
typeLayout.layout.positions.some(
|
||||||
(position) => position.resourceId === intent.resourceId,
|
(position) => position.resourceId === intent.resourceId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
visible: targetVisible,
|
visible: targetVisible,
|
||||||
domRendered: Boolean(card),
|
domRendered: Boolean(card),
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
import { afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
beginPlatformSessionTransition,
|
||||||
|
commitAuthenticatedPlatformSession,
|
||||||
|
currentPlatformSessionGeneration,
|
||||||
|
requestPlatformSessionRefresh,
|
||||||
|
resetPlatformSessionStateForTests,
|
||||||
|
} from '../../src/services/platformSession';
|
||||||
import {
|
import {
|
||||||
AuthenticatedClient,
|
AuthenticatedClient,
|
||||||
expect,
|
expect,
|
||||||
@@ -8,9 +17,182 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
testAuthUser,
|
testAuthUser,
|
||||||
vi,
|
vi,
|
||||||
|
waitFor,
|
||||||
} from './harness';
|
} from './harness';
|
||||||
|
|
||||||
export function registerAuthTests() {
|
export function registerAuthTests() {
|
||||||
|
afterEach(() => {
|
||||||
|
resetPlatformSessionStateForTests();
|
||||||
|
delete window.__TAURI__;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the client at login when the native platform session cannot be installed', async () => {
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: {
|
||||||
|
invoke: vi.fn(async (command: string) => {
|
||||||
|
if (command === 'install_platform_account_session') {
|
||||||
|
throw new Error('runner unavailable');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||||
|
async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === '/api/auth/refresh') {
|
||||||
|
return new Response('', { status: 401 });
|
||||||
|
}
|
||||||
|
if (url === '/api/auth/phone/login') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
token: 'phone-token',
|
||||||
|
user: { ...testAuthUser, loginMethod: 'phone' },
|
||||||
|
created: false,
|
||||||
|
referral: null,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch ${url}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
React.createElement(AuthenticatedClient, null, ({ user }) =>
|
||||||
|
React.createElement('main', { 'aria-label': '已登录' }, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await screen.findByRole('main', { name: '登录' });
|
||||||
|
fireEvent.change(screen.getByLabelText('手机号'), {
|
||||||
|
target: { value: '13800000000' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('验证码'), {
|
||||||
|
target: { value: '123456' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('runner unavailable')).not.toBeNull();
|
||||||
|
expect(screen.queryByLabelText('已登录')).toBeNull();
|
||||||
|
expect(
|
||||||
|
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
||||||
|
).toBe('phone-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('singleflights 401 refresh, installs the new token, and rejects a late old-account refresh', async () => {
|
||||||
|
const invoke = vi.fn(async () => null);
|
||||||
|
window.__TAURI__ = { core: { invoke } };
|
||||||
|
const initialGeneration = beginPlatformSessionTransition();
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'genarrative.auth.access-token.v1',
|
||||||
|
'account-a-token',
|
||||||
|
);
|
||||||
|
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
|
||||||
|
|
||||||
|
let resolveRefresh: ((response: Response) => void) | null = null;
|
||||||
|
let refreshCalls = 0;
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||||
|
async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === '/api/auth/refresh') {
|
||||||
|
refreshCalls += 1;
|
||||||
|
return await new Promise<Response>((resolve) => {
|
||||||
|
resolveRefresh = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/auth/me') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
user: testAuthUser,
|
||||||
|
availableLoginMethods: ['password'],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch ${url}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = requestPlatformSessionRefresh(testAuthUser.id);
|
||||||
|
const second = requestPlatformSessionRefresh(testAuthUser.id);
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(refreshCalls).toBe(1);
|
||||||
|
|
||||||
|
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
||||||
|
const accountBGeneration = beginPlatformSessionTransition();
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'genarrative.auth.access-token.v1',
|
||||||
|
'account-b-token',
|
||||||
|
);
|
||||||
|
await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
|
||||||
|
resolveRefresh?.(
|
||||||
|
new Response(JSON.stringify({ token: 'late-account-a-token' }), {
|
||||||
|
status: 200,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(first).resolves.toEqual({ status: 'stale' });
|
||||||
|
expect(
|
||||||
|
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
||||||
|
).toBe('account-b-token');
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith(
|
||||||
|
'install_platform_account_session',
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 'user-b',
|
||||||
|
accessToken: 'account-b-token',
|
||||||
|
generation: currentPlatformSessionGeneration(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes the current account once and synchronizes the replacement token', async () => {
|
||||||
|
const invoke = vi.fn(async () => null);
|
||||||
|
window.__TAURI__ = { core: { invoke } };
|
||||||
|
const generation = beginPlatformSessionTransition();
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'genarrative.auth.access-token.v1',
|
||||||
|
'expired-token',
|
||||||
|
);
|
||||||
|
await commitAuthenticatedPlatformSession(testAuthUser, generation);
|
||||||
|
let refreshCalls = 0;
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||||
|
async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === '/api/auth/refresh') {
|
||||||
|
refreshCalls += 1;
|
||||||
|
return new Response(JSON.stringify({ token: 'replacement-token' }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/auth/me') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
user: testAuthUser,
|
||||||
|
availableLoginMethods: ['password'],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch ${url}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await requestPlatformSessionRefresh(testAuthUser.id);
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({ status: 'refreshed', user: testAuthUser }),
|
||||||
|
);
|
||||||
|
expect(refreshCalls).toBe(1);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith(
|
||||||
|
'install_platform_account_session',
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: testAuthUser.id,
|
||||||
|
accessToken: 'replacement-token',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => {
|
it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => {
|
||||||
const fetchSpy = vi
|
const fetchSpy = vi
|
||||||
.spyOn(globalThis, 'fetch')
|
.spyOn(globalThis, 'fetch')
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user