Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fca3df964c | |||
| 70157673b6 | |||
| ac8a51ca79 | |||
| 492e9e63af | |||
| 57fae7037a | |||
| 1b010fb1f8 | |||
| 2bcfa10647 | |||
| e777236817 | |||
| d11c74212d | |||
| 82b2f853e7 | |||
| 31f83a751a | |||
| a1cafde7e9 | |||
| 825ea76b7b | |||
| 0594a90bdd | |||
| 910862fd06 | |||
| aee862532c | |||
| 42be8ea060 | |||
| b48293fb1f | |||
| e08171fd2e | |||
| 479120d368 | |||
| 4182979a19 | |||
| 737a2266b9 | |||
| 262deaf9b7 | |||
| c36a5170f8 | |||
| 9663bbf911 | |||
| b3e9d0a906 | |||
| a60328623d | |||
| 9e63b76991 | |||
| a98ebcf68f | |||
| 576ff07a5e | |||
| 5aa616134c | |||
| 187b66c3b1 |
@@ -1 +1,4 @@
|
||||
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
|
||||
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
|
||||
npm run format:staged
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
|
||||
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
|
||||
npm run check:pre-push-master -- "$@"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"schemaVersion": "game-creator-config.v2",
|
||||
"agentMode": "codex_app_server",
|
||||
"llm": {
|
||||
"customEnabled": false,
|
||||
"visibleModels": [],
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||
"model": "gpt-6-astra",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.29",
|
||||
"version": "0.1.47",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
|
||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.29"
|
||||
version = "0.1.47"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.29"
|
||||
version = "0.1.47"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -131,7 +131,6 @@ impl CodexAppServerCredential {
|
||||
) -> Option<(&'a str, &'a str)> {
|
||||
match self {
|
||||
Self::PlatformSession { .. } => None,
|
||||
#[cfg(test)]
|
||||
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
|
||||
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
|
||||
#[cfg(test)]
|
||||
@@ -139,7 +138,7 @@ impl CodexAppServerCredential {
|
||||
.as_deref()
|
||||
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
|
||||
#[cfg(not(test))]
|
||||
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
|
||||
Self::AuthBridge { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +195,20 @@ impl CodexAppServerStderrSummary {
|
||||
}
|
||||
}
|
||||
|
||||
/// 派发 app-server 的收尾与中断任务。
|
||||
///
|
||||
/// 这个入口会被**没有 tokio runtime 上下文的线程**调用:`cancel_direct_codex_turn`
|
||||
/// 是同步 Tauri 命令,直接跑在 IPC 回调线程(Windows 上是 WebView2 的 UI 线程);
|
||||
/// [`CodexThreadLease`] 与 [`CodexTurnGuard`] 的 `Drop` 也在调用方线程上执行。
|
||||
/// `tokio::spawn` 在那样的线程上会经 `Handle::current()` panic("there is no reactor
|
||||
/// running"),而 panic 跨不过 Tauri 的 IPC 回调边界,整个进程会以 `0xC0000409`
|
||||
/// (FAST_FAIL_FATAL_APP_EXIT)abort——现场就是"点终止,App 闪退"(2026-09-16 的 WER
|
||||
/// 记录:`genarrative-ai-game-creator-shell.exe`,异常代码 `0xc0000409`,fail-fast
|
||||
/// 参数 `7`)。一律走 Tauri 的全局异步 runtime:`main` 已把深栈 runtime 装进去。
|
||||
fn spawn_codex_app_server_task(task: impl std::future::Future<Output = ()> + Send + 'static) {
|
||||
tauri::async_runtime::spawn(task);
|
||||
}
|
||||
|
||||
struct CodexTurnStartCancellation {
|
||||
inner: Weak<CodexAppServerInner>,
|
||||
thread_id: String,
|
||||
@@ -257,7 +270,7 @@ impl CodexTurnStartCancellation {
|
||||
};
|
||||
let connection = CodexAppServerConnection { inner };
|
||||
let thread_id = self.thread_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
let _ = connection
|
||||
.request(
|
||||
"turn/interrupt",
|
||||
@@ -2018,7 +2031,13 @@ impl CodexAppServerConnection {
|
||||
let codex_cli_version = game_creator_codex_cli_version_identity()
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
let mut effective_llm = llm.clone();
|
||||
let credential = if game_creator_official_llm_route_locked() {
|
||||
let credential = if llm.custom_enabled {
|
||||
crate::config::validate_custom_llm_connection(llm)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())),
|
||||
}
|
||||
} else if game_creator_official_llm_route_locked() {
|
||||
let session = current_platform_session().ok_or_else(|| {
|
||||
platform_llm::LlmError::InvalidConfig(
|
||||
"authentication-required: 请先登录陶泥儿账号".to_string(),
|
||||
@@ -2186,7 +2205,8 @@ impl CodexAppServerConnection {
|
||||
true,
|
||||
),
|
||||
_ => (
|
||||
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
(llm.custom_enabled
|
||||
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
.then(|| credential.direct_provider_route(llm))
|
||||
.flatten()
|
||||
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
|
||||
@@ -3560,7 +3580,7 @@ impl Drop for CodexThreadLease {
|
||||
let connection = self.connection.clone();
|
||||
let key = self.key.clone();
|
||||
let thread_id = self.thread_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
let mut threads = connection.inner.threads.lock().await;
|
||||
if let Some(entry) = threads.get_mut(&key) {
|
||||
if entry.thread_id == thread_id {
|
||||
@@ -3587,7 +3607,7 @@ impl Drop for CodexTurnGuard {
|
||||
let connection = self.connection.clone();
|
||||
let thread_id = self.thread_id.clone();
|
||||
let turn_id = self.turn_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
connection.inner.turns.lock().await.remove(&turn_id);
|
||||
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
||||
let _ = connection
|
||||
@@ -4483,6 +4503,19 @@ mod tests {
|
||||
assert!(table.select(&key, None).is_err());
|
||||
}
|
||||
|
||||
/// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio
|
||||
/// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个
|
||||
/// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime
|
||||
/// 上下文的线程上,回退到 `tokio::spawn` 时它会失败。
|
||||
#[test]
|
||||
fn codex_app_server_task_dispatch_needs_no_tokio_runtime_context() {
|
||||
let joined = std::thread::spawn(|| spawn_codex_app_server_task(async {}));
|
||||
assert!(
|
||||
joined.join().is_ok(),
|
||||
"没有 tokio runtime 上下文的线程也必须能派发 app-server 收尾任务"
|
||||
);
|
||||
}
|
||||
|
||||
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
|
||||
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
||||
#[test]
|
||||
@@ -4743,6 +4776,8 @@ mod tests {
|
||||
|
||||
fn test_llm() -> GameCreatorLlmConfig {
|
||||
GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "fixture-secret".to_string(),
|
||||
base_url: "https://example.invalid/v1".to_string(),
|
||||
model: "fixture-model".to_string(),
|
||||
@@ -5528,6 +5563,59 @@ mod tests {
|
||||
assert_ne!(command_token, provider_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() {
|
||||
let mut llm = test_llm();
|
||||
llm.custom_enabled = true;
|
||||
llm.api_key = "custom-upstream-fixture-secret".into();
|
||||
llm.base_url = "http://127.0.0.1:9/v1".into();
|
||||
llm.model = "vendor/model.v1:latest".into();
|
||||
llm.visible_models = vec![llm.model.clone()];
|
||||
let credential = CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: "custom-fixture".into(),
|
||||
};
|
||||
let (base, key) = credential
|
||||
.direct_provider_route(&llm)
|
||||
.expect("custom route");
|
||||
assert_eq!(base, llm.base_url);
|
||||
assert_eq!(key, llm.api_key);
|
||||
let proxy = start_codex_provider_proxy(base, key, false).await.unwrap();
|
||||
for mode in [
|
||||
CodexAppServerWorkspaceMode::DirectProject,
|
||||
CodexAppServerWorkspaceMode::ToolHost,
|
||||
] {
|
||||
let mut command = tokio::process::Command::new("fixture");
|
||||
configure_game_creator_codex_app_server_command_for_mode(
|
||||
&mut command,
|
||||
&llm,
|
||||
mode,
|
||||
Some(&proxy),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let arguments = command
|
||||
.as_std()
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let params = codex_app_server_thread_start_params(
|
||||
&llm.model,
|
||||
std::path::Path::new("fixture-workspace"),
|
||||
mode,
|
||||
String::new(),
|
||||
true,
|
||||
);
|
||||
assert_eq!(params["model"], "vendor/model.v1:latest");
|
||||
assert!(!arguments.contains(&llm.api_key));
|
||||
assert!(!arguments.contains("/api/llm"));
|
||||
for (_, value) in command.as_std().get_envs() {
|
||||
assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
|
||||
|
||||
@@ -482,9 +482,38 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
|
||||
}
|
||||
|
||||
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
|
||||
read_direct_project_history_entries_filtered_at(root, None, false)
|
||||
}
|
||||
|
||||
fn is_direct_project_chat_message(item: &Value) -> bool {
|
||||
matches!(
|
||||
item.get("role").and_then(Value::as_str),
|
||||
Some("user" | "assistant")
|
||||
) && item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| {
|
||||
parts.iter().any(|part| {
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| !text.is_empty())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。
|
||||
fn read_direct_project_history_entries_filtered_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
messages_only: bool,
|
||||
) -> Result<Vec<(Value, u64)>, String> {
|
||||
let path = history_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
|
||||
return Ok(Vec::new());
|
||||
return if before_item_id.is_some() {
|
||||
Err("DirectProject 历史游标对应的文件已不存在".to_string())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
};
|
||||
}
|
||||
let file = File::open(&path)
|
||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
@@ -519,6 +548,12 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
continue;
|
||||
}
|
||||
if before_item_id.is_some_and(|id| item.get("id").and_then(Value::as_str) == Some(id)) {
|
||||
return Ok(items);
|
||||
}
|
||||
if messages_only && !is_direct_project_chat_message(&item) {
|
||||
continue;
|
||||
}
|
||||
items.push((
|
||||
item,
|
||||
parsed
|
||||
@@ -527,7 +562,45 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
|
||||
.unwrap_or(0),
|
||||
));
|
||||
}
|
||||
Ok(items)
|
||||
match before_item_id {
|
||||
Some(item_id) => Err(format!("DirectProject 历史中不存在 item:{item_id}")),
|
||||
None => Ok(items),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_chat_items_slice_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
|
||||
let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?;
|
||||
let mut start = entries.len().saturating_sub(limit.clamp(1, 200));
|
||||
// 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。
|
||||
while start > 0
|
||||
&& entries[start]
|
||||
.0
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(str::is_empty)
|
||||
{
|
||||
start -= 1;
|
||||
}
|
||||
let timestamps = entries[start..]
|
||||
.iter()
|
||||
.filter_map(|(item, at)| {
|
||||
let id = item.get("id").and_then(Value::as_str)?;
|
||||
(*at > 0).then(|| (id.to_string(), *at))
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
entries
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.map(|(item, _)| item)
|
||||
.collect(),
|
||||
start > 0,
|
||||
timestamps,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_history_items_slice_at(
|
||||
@@ -640,6 +713,165 @@ mod tests {
|
||||
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
|
||||
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
|
||||
|
||||
fn write_items(root: &std::path::Path, items: &[Value]) {
|
||||
let lines = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index})
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
write_history_lines(root, &lines.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() {
|
||||
let root = init_history_project("message-pages");
|
||||
let mut raw = Vec::new();
|
||||
let mut expected = Vec::new();
|
||||
for n in 0..44 {
|
||||
let item = json!({
|
||||
"id": format!("message-{n}"), "type": "message",
|
||||
"role": if n == 0 || n == 38 { "user" } else { "assistant" },
|
||||
"content": [{"type": "output_text", "text": format!("消息 {n}")}],
|
||||
});
|
||||
expected.push(item.clone());
|
||||
raw.push(item);
|
||||
for tool in 0..25 {
|
||||
raw.push(json!({
|
||||
"id": format!("tool-{n}-{tool}"), "type": "function_call_output",
|
||||
"output": "工具结果不应占聊天页名额",
|
||||
}));
|
||||
}
|
||||
}
|
||||
write_items(root.path(), &raw);
|
||||
let path = history_path(root.path());
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let (old_page, _, _) =
|
||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert!(old_page
|
||||
.iter()
|
||||
.all(|item| item["type"] == "function_call_output"));
|
||||
let mut cursor = None;
|
||||
let mut all = Vec::new();
|
||||
let mut sizes = Vec::new();
|
||||
loop {
|
||||
let (mut page, more, timestamps) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
|
||||
.unwrap();
|
||||
sizes.push(page.len());
|
||||
for item in &page {
|
||||
let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap();
|
||||
assert_eq!(
|
||||
timestamps[item["id"].as_str().unwrap()],
|
||||
1000 + index as u64
|
||||
);
|
||||
}
|
||||
let next = page
|
||||
.first()
|
||||
.and_then(|item| item["id"].as_str())
|
||||
.map(str::to_string);
|
||||
page.append(&mut all);
|
||||
all = page;
|
||||
if !more {
|
||||
break;
|
||||
}
|
||||
assert_ne!(next, cursor);
|
||||
cursor = next;
|
||||
assert!(sizes.len() < 10);
|
||||
}
|
||||
assert_eq!(sizes, vec![20, 20, 4]);
|
||||
assert_eq!(all, expected);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_pages_handle_empty_content_internal_context_and_missing_ids() {
|
||||
let root = init_history_project("message-page-boundary");
|
||||
write_items(
|
||||
root.path(),
|
||||
&[
|
||||
json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}),
|
||||
json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}),
|
||||
json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}),
|
||||
json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}),
|
||||
json!({"id":"internal", "role":"user", "content":[{"text":"<environment_context>内部</environment_context>"}]}),
|
||||
json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}),
|
||||
],
|
||||
);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap();
|
||||
assert_eq!(page[0]["id"], "a");
|
||||
assert!(more);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap();
|
||||
assert_eq!(page.len(), 2);
|
||||
assert_eq!(page[0]["id"], "u");
|
||||
assert!(page[1].get("id").is_none());
|
||||
assert!(!more);
|
||||
assert!(
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20)
|
||||
.is_err()
|
||||
);
|
||||
write_items(
|
||||
root.path(),
|
||||
&[json!({"id":"tool", "type":"function_call", "arguments":"{}"})],
|
||||
);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert!(page.is_empty());
|
||||
assert!(!more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"]
|
||||
fn replay_external_chat_history_pages_without_mutating_source() {
|
||||
let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source");
|
||||
let before = std::fs::read(&source).expect("read source");
|
||||
let root = init_history_project("external-history-replay");
|
||||
let path = history_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, &before).unwrap();
|
||||
let expected =
|
||||
super::read_direct_project_history_entries_filtered_at(root.path(), None, true)
|
||||
.expect("read messages");
|
||||
let mut cursor = None;
|
||||
let mut all = Vec::new();
|
||||
let mut pages = 0;
|
||||
loop {
|
||||
let (mut items, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
|
||||
.expect("read page");
|
||||
let next = items
|
||||
.first()
|
||||
.and_then(|item| item["id"].as_str())
|
||||
.map(str::to_string);
|
||||
items.append(&mut all);
|
||||
all = items;
|
||||
pages += 1;
|
||||
if !more {
|
||||
break;
|
||||
}
|
||||
assert!(next.is_some() && next != cursor, "cursor must advance");
|
||||
assert!(pages <= expected.len() + 1, "pagination must terminate");
|
||||
cursor = next;
|
||||
}
|
||||
assert!(
|
||||
all.iter().eq(expected.iter().map(|(item, _)| item)),
|
||||
"message order and content must match"
|
||||
);
|
||||
assert!(
|
||||
std::fs::read(&source).unwrap() == before,
|
||||
"source must remain unchanged"
|
||||
);
|
||||
eprintln!(
|
||||
"history replay: messages={}, pages={pages}, users={}",
|
||||
all.len(),
|
||||
all.iter().filter(|item| item["role"] == "user").count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
|
||||
let root = init_history_project("history-time");
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -59,6 +59,7 @@ pub(crate) struct DirectThreadHistorySlice {
|
||||
pub(crate) items: Vec<Value>,
|
||||
pub(crate) has_more: bool,
|
||||
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
|
||||
pub(crate) oldest_item_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -49,7 +49,7 @@ struct ExternalMcpHttpState {
|
||||
root: PathBuf,
|
||||
token: String,
|
||||
session_user_id: String,
|
||||
session_generation: u64,
|
||||
session_identity_generation: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
|
||||
@@ -1241,7 +1241,8 @@ fn external_mcp_session_id(root: &Path) -> String {
|
||||
material.push('\0');
|
||||
material.push_str(&session.user_id);
|
||||
material.push('\0');
|
||||
material.push_str(&session.generation.to_string());
|
||||
// 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。
|
||||
material.push_str(&session.identity_generation.to_string());
|
||||
}
|
||||
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
|
||||
}
|
||||
@@ -1759,7 +1760,9 @@ async fn handle_external_mcp_http_request(
|
||||
let Some(session) = current_platform_session() else {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
};
|
||||
if session.user_id != state.session_user_id || session.generation != state.session_generation {
|
||||
if session.user_id != state.session_user_id
|
||||
|| session.identity_generation != state.session_identity_generation
|
||||
{
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let response = EXTERNAL_MCP_BRIDGE_URL
|
||||
@@ -1794,7 +1797,7 @@ pub(crate) async fn start_external_mcp_loopback(
|
||||
root,
|
||||
token: token.clone(),
|
||||
session_user_id: session.user_id,
|
||||
session_generation: session.generation,
|
||||
session_identity_generation: session.identity_generation,
|
||||
};
|
||||
let app = Router::new()
|
||||
.route(&route, post(handle_external_mcp_http_request))
|
||||
|
||||
@@ -11,6 +11,10 @@ use super::external_generation_state::{
|
||||
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
|
||||
};
|
||||
use super::*;
|
||||
use crate::platform_session::{
|
||||
acquire_platform_session_identity_lease, validate_platform_session_identity,
|
||||
PlatformSessionIdentity,
|
||||
};
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
|
||||
@@ -1510,10 +1514,7 @@ struct PreparedPlatformArtAssetSlice {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PreparedPlatformSessionFence {
|
||||
user_id: String,
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
access_token_sha256: String,
|
||||
identity: PlatformSessionIdentity,
|
||||
}
|
||||
|
||||
impl PreparedPlatformSessionFence {
|
||||
@@ -1521,41 +1522,17 @@ impl PreparedPlatformSessionFence {
|
||||
access
|
||||
.frozen_platform_session()
|
||||
.map(|session| PreparedPlatformSessionFence {
|
||||
user_id: session.user_id.clone(),
|
||||
api_base_url: session.api_base_url.clone(),
|
||||
generation: session.generation,
|
||||
access_token_sha256: format!(
|
||||
"{:x}",
|
||||
Sha256::digest(session.access_token.as_bytes())
|
||||
),
|
||||
identity: session.identity(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
let matches = current_platform_session().is_some_and(|session| {
|
||||
session.user_id == self.user_id
|
||||
&& session.api_base_url == self.api_base_url
|
||||
&& session.generation == self.generation
|
||||
&& format!("{:x}", Sha256::digest(session.access_token.as_bytes()))
|
||||
== self.access_token_sha256
|
||||
});
|
||||
if matches {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
// 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。
|
||||
validate_platform_session_identity(&self.identity)
|
||||
}
|
||||
|
||||
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
|
||||
acquire_validated_platform_session_fingerprint(
|
||||
&self.user_id,
|
||||
&self.api_base_url,
|
||||
self.generation,
|
||||
&self.access_token_sha256,
|
||||
)
|
||||
acquire_platform_session_identity_lease(&self.identity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10636,7 +10613,7 @@ mod canvas_generation_tests {
|
||||
}
|
||||
drop(owner_a_access);
|
||||
drop(frozen_owner_a);
|
||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2)
|
||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2)
|
||||
.expect("switch to owner B");
|
||||
|
||||
let error = match request_platform_art_asset_with_runtime_options_at(
|
||||
@@ -10761,8 +10738,14 @@ mod canvas_generation_tests {
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.expect("wait for accepted response");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2)
|
||||
.expect("switch platform account after accepted response");
|
||||
install_platform_session(
|
||||
"post-202-user-b",
|
||||
"post-202-token-b",
|
||||
&switch_base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch platform account after accepted response");
|
||||
});
|
||||
let runtime_context = PlatformArtGenerationRuntimeContext {
|
||||
agent_id: "art-director".to_string(),
|
||||
|
||||
+2
-1
@@ -1431,12 +1431,13 @@ mod external_generation_state_tests {
|
||||
base_url,
|
||||
);
|
||||
let frozen_a = current_platform_session().expect("freeze owner A");
|
||||
validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch");
|
||||
validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch");
|
||||
replace_platform_session_for_gui_owner(
|
||||
"fingerprint-owner-b",
|
||||
"fingerprint-token-b",
|
||||
base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch global session to owner B");
|
||||
let current_b = current_platform_session().expect("owner B is current after switch");
|
||||
|
||||
+18
@@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
|
||||
response_stream_fixture("finalization-tool-plan-repair-chain-run");
|
||||
let root = project.path();
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "finalization-tool-plan-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "finalization-tool-plan-model".to_string(),
|
||||
@@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
||||
let root = project.path();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-provider-model".to_string(),
|
||||
@@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
||||
LlmMessage::user("修复格式"),
|
||||
]);
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-tool-plan-provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-tool-plan-model".to_string(),
|
||||
@@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
|
||||
response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
|
||||
let root = project.path();
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-generic-retry-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-generic-retry-model".to_string(),
|
||||
@@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
||||
response_stream_fixture("tool-plan-capacity-preflight-run");
|
||||
let root = project.path();
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "tool-plan-capacity-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "tool-plan-capacity-model".to_string(),
|
||||
@@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
||||
LlmMessage::user("修复格式"),
|
||||
]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "durable-control-tool-plan-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "durable-control-tool-plan-model".to_string(),
|
||||
@@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "tool-plan-cleanup-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "tool-plan-cleanup-model".to_string(),
|
||||
@@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "terminal-handoff-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "terminal-handoff-model".to_string(),
|
||||
@@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
||||
let root = project.path();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "provider-model".to_string(),
|
||||
|
||||
@@ -2,8 +2,9 @@ use super::*;
|
||||
|
||||
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
|
||||
OnceLock::new();
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
|
||||
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
|
||||
/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock<
|
||||
std::sync::Mutex<Vec<GameCreatorManifestInvalidationEventSink>>,
|
||||
> = OnceLock::new();
|
||||
#[cfg(test)]
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> =
|
||||
@@ -293,8 +294,8 @@ pub(crate) use entrypoints::{
|
||||
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
|
||||
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
|
||||
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
|
||||
install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at,
|
||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
||||
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
|
||||
read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink,
|
||||
set_game_creator_agent_runtime_update_app_handle,
|
||||
start_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use super::*;
|
||||
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
|
||||
|
||||
fn lock_game_creator_manifest_invalidation_event_sink(
|
||||
) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
|
||||
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
fn lock_game_creator_manifest_invalidation_event_sinks(
|
||||
) -> std::sync::MutexGuard<'static, Vec<GameCreatorManifestInvalidationEventSink>> {
|
||||
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS
|
||||
.get_or_init(|| Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
@@ -219,7 +220,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
|
||||
token: &str,
|
||||
) -> Result<(), String> {
|
||||
let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?;
|
||||
install_game_creator_manifest_invalidation_event_sink(sink);
|
||||
register_game_creator_manifest_invalidation_event_sink(sink);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -240,10 +241,29 @@ pub(crate) fn validate_game_creator_manifest_invalidation_event_sink(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn install_game_creator_manifest_invalidation_event_sink(
|
||||
/// 登记一个界面窗口的事件接收端。
|
||||
///
|
||||
/// 同一窗口重复 attach 用同一个 token,按 token 覆盖旧登记;不同窗口各自持有
|
||||
/// 自己的 token,注册表按登记顺序保留,最多 `GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX` 个。
|
||||
pub(crate) fn register_game_creator_manifest_invalidation_event_sink(
|
||||
sink: GameCreatorManifestInvalidationEventSink,
|
||||
) {
|
||||
*lock_game_creator_manifest_invalidation_event_sink() = Some(sink);
|
||||
let mut sinks = lock_game_creator_manifest_invalidation_event_sinks();
|
||||
if let Some(existing) = sinks
|
||||
.iter_mut()
|
||||
.find(|existing| existing.token == sink.token)
|
||||
{
|
||||
*existing = sink;
|
||||
return;
|
||||
}
|
||||
if sinks.len() >= GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX {
|
||||
sinks.remove(0);
|
||||
}
|
||||
sinks.push(sink);
|
||||
}
|
||||
|
||||
fn remove_game_creator_manifest_invalidation_event_sink(token: &str) {
|
||||
lock_game_creator_manifest_invalidation_event_sinks().retain(|sink| sink.token != token);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -258,14 +278,20 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard {
|
||||
}
|
||||
|
||||
pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> {
|
||||
lock_game_creator_manifest_invalidation_event_sink().clone()
|
||||
lock_game_creator_manifest_invalidation_event_sinks()
|
||||
.first()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn configured_sinks(&self) -> Vec<GameCreatorManifestInvalidationEventSink> {
|
||||
lock_game_creator_manifest_invalidation_event_sinks().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard {
|
||||
fn drop(&mut self) {
|
||||
*lock_game_creator_manifest_invalidation_event_sink() = None;
|
||||
lock_game_creator_manifest_invalidation_event_sinks().clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,16 +307,43 @@ pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard(
|
||||
}
|
||||
|
||||
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
|
||||
let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
|
||||
let Some(sink) = sink else {
|
||||
let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone();
|
||||
if sinks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let event = GameCreatorManifestInvalidatedEvent {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
agent_id: agent_id.to_string(),
|
||||
};
|
||||
let mut failed_tokens = Vec::new();
|
||||
let mut last_error = None;
|
||||
for sink in &sinks {
|
||||
match relay_game_creator_manifest_invalidation_to_sink(sink, &event) {
|
||||
Ok(()) => {}
|
||||
Err(error) => {
|
||||
// 窗口已退出或接收端已释放时只淘汰该接收端,不能影响其它窗口。
|
||||
failed_tokens.push(sink.token.clone());
|
||||
last_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !failed_tokens.is_empty() {
|
||||
lock_game_creator_manifest_invalidation_event_sinks()
|
||||
.retain(|sink| !failed_tokens.contains(&sink.token));
|
||||
}
|
||||
match last_error {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn relay_game_creator_manifest_invalidation_to_sink(
|
||||
sink: &GameCreatorManifestInvalidationEventSink,
|
||||
event: &GameCreatorManifestInvalidatedEvent,
|
||||
) -> Result<(), String> {
|
||||
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
|
||||
token: sink.token,
|
||||
event: GameCreatorManifestInvalidatedEvent {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
agent_id: agent_id.to_string(),
|
||||
},
|
||||
token: sink.token.clone(),
|
||||
event: event.clone(),
|
||||
};
|
||||
let payload = serde_json::to_vec(&envelope)
|
||||
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
|
||||
|
||||
@@ -1939,8 +1939,9 @@ pub(crate) async fn polish_local_project_prompt(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_platform_account_session_generation() -> u64 {
|
||||
current_platform_session_generation()
|
||||
pub(crate) fn read_platform_account_session_state(
|
||||
) -> crate::platform_session::PlatformSessionWriteState {
|
||||
crate::platform_session::current_platform_session_write_state()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1948,28 +1949,45 @@ pub(crate) async fn install_platform_account_session(
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
identity_generation: u64,
|
||||
revision: u64,
|
||||
) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
|
||||
validate_platform_session_input(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
identity_generation,
|
||||
revision,
|
||||
)?;
|
||||
install_external_agent_runner_platform_session(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
generation,
|
||||
identity_generation,
|
||||
revision,
|
||||
)?;
|
||||
install_platform_session(&user_id, &access_token, &api_base_url, generation)
|
||||
install_platform_session(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
identity_generation,
|
||||
revision,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> {
|
||||
pub(crate) async fn clear_platform_account_session(
|
||||
identity_generation: u64,
|
||||
revision: u64,
|
||||
) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
shutdown_game_creator_codex_app_servers()?;
|
||||
clear_external_agent_runner_platform_session(generation)?;
|
||||
clear_platform_session(generation);
|
||||
clear_external_agent_runner_platform_session(identity_generation, revision)?;
|
||||
clear_platform_session(identity_generation, revision);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
@@ -1991,6 +2009,8 @@ pub(crate) fn write_game_creator_app_config(
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
let (current, overlays) = load_game_creator_app_config_for_write()?;
|
||||
// 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。
|
||||
config.llm.custom_enabled = current.llm.custom_enabled;
|
||||
config.selected_model_id = current.selected_model_id;
|
||||
config.selected_model_is_default = current.selected_model_is_default;
|
||||
persist_game_creator_app_config(config, overlays, false)
|
||||
@@ -2027,7 +2047,12 @@ pub(crate) fn select_game_creator_model(
|
||||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
if model_id.is_empty()
|
||||
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
|
||||
if config.llm.custom_enabled {
|
||||
if !config.llm.visible_models.contains(&model_id) {
|
||||
return Err("所选模型未勾选或已移除,请刷新模型列表".into());
|
||||
}
|
||||
} else if model_id.is_empty()
|
||||
|| model_id.len() > 64
|
||||
|| !model_id
|
||||
.bytes()
|
||||
@@ -2035,12 +2060,21 @@ pub(crate) fn select_game_creator_model(
|
||||
{
|
||||
return Err("模型标识无效".into());
|
||||
}
|
||||
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
|
||||
config.selected_model_id = model_id;
|
||||
config.selected_model_is_default = is_default;
|
||||
persist_game_creator_app_config(config, overlays, true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn discover_game_creator_llm_models(
|
||||
llm: GameCreatorLlmConfig,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if !load_game_creator_app_config()?.llm.custom_enabled {
|
||||
return Err("请先在本地配置中开启 llm.customEnabled".to_string());
|
||||
}
|
||||
fetch_custom_llm_models(&llm).await
|
||||
}
|
||||
|
||||
fn persist_game_creator_app_config(
|
||||
config: GameCreatorAppConfig,
|
||||
overlays: Vec<(PathBuf, serde_json::Value)>,
|
||||
@@ -2056,8 +2090,8 @@ fn persist_game_creator_app_config(
|
||||
let previous = overlay.clone();
|
||||
if let Some(fields) = overlay.as_object_mut() {
|
||||
for (key, value) in fields.iter_mut() {
|
||||
if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
|
||||
== model_only
|
||||
if !model_only
|
||||
|| matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
|
||||
{
|
||||
if let Some(saved_value) = saved.get(key) {
|
||||
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
|
||||
@@ -4231,14 +4265,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
|
||||
access.validate_frozen_session()?;
|
||||
let _platform_session_lease = frozen_session
|
||||
.as_ref()
|
||||
.map(|session| {
|
||||
acquire_validated_platform_session_fingerprint(
|
||||
&session.user_id,
|
||||
&session.api_base_url,
|
||||
session.generation,
|
||||
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
|
||||
)
|
||||
})
|
||||
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
|
||||
.transpose()?;
|
||||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||||
access.validate_frozen_session()?;
|
||||
@@ -5008,7 +5035,21 @@ pub(crate) fn read_local_project_text_preview_at(
|
||||
return Err("只能读取当前项目已登记的文档资源".to_string());
|
||||
}
|
||||
cancellation.check()?;
|
||||
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
|
||||
let mut preview =
|
||||
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)?;
|
||||
if normalized_path.to_ascii_lowercase().ends_with(".json") {
|
||||
preview.ui_design_asset_id = manifest.assets.iter().find_map(|asset| {
|
||||
(asset.local_path == normalized_path
|
||||
&& ui_editor::persistence::is_valid_ui_design_json(
|
||||
&preview.content,
|
||||
&manifest.project_id,
|
||||
&asset.id,
|
||||
))
|
||||
.then(|| asset.id.clone())
|
||||
});
|
||||
}
|
||||
cancellation.check()?;
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -5374,19 +5415,29 @@ pub(crate) async fn read_direct_project_history_slice(
|
||||
project_path: String,
|
||||
before_item_id: Option<String>,
|
||||
limit: Option<usize>,
|
||||
messages_only: Option<bool>,
|
||||
) -> Result<DirectThreadHistorySlice, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
|
||||
root,
|
||||
before_item_id.as_deref(),
|
||||
limit.unwrap_or(20),
|
||||
)?;
|
||||
let read_slice = if messages_only.unwrap_or(false) {
|
||||
read_direct_project_chat_items_slice_at
|
||||
} else {
|
||||
read_direct_project_history_items_slice_at
|
||||
};
|
||||
let (items, has_more, item_timestamps) =
|
||||
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?;
|
||||
let oldest_item_id = items
|
||||
.first()
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string);
|
||||
Ok(DirectThreadHistorySlice {
|
||||
items,
|
||||
has_more,
|
||||
item_timestamps,
|
||||
oldest_item_id,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1084,6 +1084,10 @@ struct GameCreatorAppConfigFile {
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorLlmConfigFile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
custom_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
visible_models: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
api_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1139,6 +1143,10 @@ struct GameCreatorAppConfig {
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorLlmConfig {
|
||||
#[serde(default)]
|
||||
custom_enabled: bool,
|
||||
#[serde(default)]
|
||||
visible_models: Vec<String>,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
@@ -1655,6 +1663,8 @@ impl Default for GameCreatorAppConfig {
|
||||
impl Default for GameCreatorLlmConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: String::new(),
|
||||
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
|
||||
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
|
||||
@@ -2209,6 +2219,8 @@ fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent)
|
||||
enum GameCreatorGuiRunnerShutdownOutcome {
|
||||
NotRequested,
|
||||
Requested,
|
||||
/// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。
|
||||
Retained,
|
||||
Failed(GameCreatorGuiRunnerShutdownFailure),
|
||||
}
|
||||
|
||||
@@ -2246,7 +2258,8 @@ fn classify_game_creator_gui_runner_shutdown_error(
|
||||
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
|
||||
} else if error.contains("当前平台不支持") || error.contains("macOS 不提供") {
|
||||
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
|
||||
} else if error.contains("实例锁") || error.contains("owner 锁") {
|
||||
} else if error.contains("实例锁") || error.contains("参与锁") || error.contains("owner 锁")
|
||||
{
|
||||
GameCreatorGuiRunnerShutdownFailure::LockTimeout
|
||||
} else if error.contains("endpoint") {
|
||||
GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable
|
||||
@@ -2262,13 +2275,14 @@ fn resolve_game_creator_gui_runner_shutdown<F>(
|
||||
shutdown: F,
|
||||
) -> GameCreatorGuiRunnerShutdownOutcome
|
||||
where
|
||||
F: FnOnce() -> Result<(), String>,
|
||||
F: FnOnce() -> Result<bool, String>,
|
||||
{
|
||||
if !game_creator_gui_run_event_requests_runner_shutdown(event) {
|
||||
return GameCreatorGuiRunnerShutdownOutcome::NotRequested;
|
||||
}
|
||||
match shutdown() {
|
||||
Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
||||
Ok(true) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
||||
Ok(false) => GameCreatorGuiRunnerShutdownOutcome::Retained,
|
||||
Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed(
|
||||
classify_game_creator_gui_runner_shutdown_error(&error),
|
||||
),
|
||||
@@ -2288,11 +2302,17 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
|
||||
app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
|
||||
}
|
||||
}
|
||||
match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) {
|
||||
match resolve_game_creator_gui_runner_shutdown(
|
||||
event,
|
||||
shutdown_external_agent_runner_for_gui_exit,
|
||||
) {
|
||||
GameCreatorGuiRunnerShutdownOutcome::NotRequested => {}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Requested => {
|
||||
app_log!("agent.runner.gui_exit.shutdown_requested")
|
||||
}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Retained => {
|
||||
app_log!("agent.runner.gui_exit.retained_for_other_windows")
|
||||
}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
|
||||
app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
|
||||
}
|
||||
@@ -2601,27 +2621,25 @@ fn main() {
|
||||
)
|
||||
})?;
|
||||
setup_log.append("startup.runner.configure.complete");
|
||||
let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
||||
hold_external_agent_runner_gui_participant_lock(&config_dir)
|
||||
.inspect_err(|error| {
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
"startup.runner.owner-lock.failed details={details}"
|
||||
"startup.runner.participant-lock.failed details={details}"
|
||||
));
|
||||
})
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
format!("获取 GUI owner 锁失败:{error}"),
|
||||
format!("建立 AGC 界面参与锁失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string();
|
||||
app.manage(gui_owner_lock);
|
||||
setup_log.append("startup.runner.start.begin");
|
||||
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
|
||||
set_direct_thread_manager_app_handle(app.handle().clone());
|
||||
let manifest_event_sink =
|
||||
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch)
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink)
|
||||
.inspect_err(|error| {
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
@@ -2715,12 +2733,13 @@ fn main() {
|
||||
confirm_resume_game_creator_agent_runtime_tasks,
|
||||
schedule_game_creator_agent_ready_tasks,
|
||||
check_game_creator_llm_config,
|
||||
read_platform_account_session_generation,
|
||||
read_platform_account_session_state,
|
||||
install_platform_account_session,
|
||||
clear_platform_account_session,
|
||||
read_game_creator_app_config,
|
||||
write_game_creator_app_config,
|
||||
select_game_creator_model,
|
||||
discover_game_creator_llm_models,
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
create_ui_design_resource,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,11 +110,13 @@ impl<'a> ExternalEditorBindingAccess<'a> {
|
||||
}
|
||||
|
||||
/// Call before and after every awaited remote action and immediately before installing a
|
||||
/// binding. Developer-key mode has no process-global account generation to compare.
|
||||
/// binding. 只比较身份:同一账号的 access token 轮换(长回合保活、401 续期)不得让
|
||||
/// 在途的生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin 变化仍然
|
||||
/// 失败关闭。Developer-key 模式没有进程级身份代次可比对。
|
||||
pub(crate) fn validate_frozen_session(&self) -> Result<(), String> {
|
||||
validate_external_editor_binding_access_shape(self)?;
|
||||
if let Some(session) = self.frozen_platform_session {
|
||||
validate_platform_session_snapshot(session)?;
|
||||
validate_frozen_platform_session(session)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1152,7 +1154,8 @@ mod tests {
|
||||
user_id: user_id.to_string(),
|
||||
access_token: token.to_string(),
|
||||
api_base_url: "https://dev.genarrative.world".to_string(),
|
||||
generation,
|
||||
identity_generation: generation,
|
||||
revision: generation,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4389,14 +4389,7 @@ fn with_frozen_resource_edit_platform_session<T>(
|
||||
let Some(platform_session) = platform_session else {
|
||||
return action();
|
||||
};
|
||||
let access_token_sha256 = sha256_hex(platform_session.access_token.as_bytes());
|
||||
with_validated_platform_session_fingerprint(
|
||||
&platform_session.user_id,
|
||||
&platform_session.api_base_url,
|
||||
platform_session.generation,
|
||||
&access_token_sha256,
|
||||
action,
|
||||
)
|
||||
with_validated_platform_session_identity(&platform_session.identity(), action)
|
||||
}
|
||||
|
||||
fn commit_resource_edit_asset_with_frozen_platform_session(
|
||||
@@ -4774,14 +4767,7 @@ pub(crate) fn list_pending_local_project_resource_edits_at(
|
||||
let current_platform_session = current_platform_session();
|
||||
let _platform_session_lease = current_platform_session
|
||||
.as_ref()
|
||||
.map(|session| {
|
||||
acquire_validated_platform_session_fingerprint(
|
||||
&session.user_id,
|
||||
&session.api_base_url,
|
||||
session.generation,
|
||||
&sha256_hex(session.access_token.as_bytes()),
|
||||
)
|
||||
})
|
||||
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
|
||||
.transpose()?;
|
||||
let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?;
|
||||
let entries = match fs::read_dir(&directory) {
|
||||
@@ -5084,12 +5070,8 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at(
|
||||
Ok(())
|
||||
};
|
||||
if let Some(session) = platform_session {
|
||||
let access_token_sha256 = sha256_hex(session.access_token.as_bytes());
|
||||
crate::platform_session::with_validated_platform_session_fingerprint(
|
||||
&session.user_id,
|
||||
&session.api_base_url,
|
||||
session.generation,
|
||||
&access_token_sha256,
|
||||
crate::platform_session::with_validated_platform_session_identity(
|
||||
&session.identity(),
|
||||
archive,
|
||||
)?;
|
||||
} else {
|
||||
@@ -5500,7 +5482,8 @@ mod tests {
|
||||
user_id: "gui-owner".to_string(),
|
||||
access_token: "gui-token".to_string(),
|
||||
api_base_url: "https://dev.genarrative.world".to_string(),
|
||||
generation: 7,
|
||||
identity_generation: 7,
|
||||
revision: 7,
|
||||
};
|
||||
let developer_credentials = (
|
||||
"https://dev.genarrative.world".to_string(),
|
||||
@@ -5911,6 +5894,7 @@ mod tests {
|
||||
"source-binding-token-b",
|
||||
api_base_url,
|
||||
*generation,
|
||||
*generation,
|
||||
)
|
||||
.expect("switch account after source registration");
|
||||
}
|
||||
@@ -6925,7 +6909,7 @@ mod tests {
|
||||
listener,
|
||||
upload_url,
|
||||
false,
|
||||
Some((base_url.clone(), frozen_session.generation + 1)),
|
||||
Some((base_url.clone(), frozen_session.identity_generation + 1)),
|
||||
done_receiver,
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
@@ -6952,7 +6936,8 @@ mod tests {
|
||||
"source-binding-owner-a",
|
||||
"source-binding-token-a",
|
||||
&base_url,
|
||||
frozen_session.generation + 2,
|
||||
frozen_session.identity_generation + 2,
|
||||
frozen_session.identity_generation + 2,
|
||||
)
|
||||
.expect("switch back to source binding owner A");
|
||||
let resumed_session = current_platform_session().expect("resumed source binding owner A");
|
||||
@@ -7018,7 +7003,7 @@ mod tests {
|
||||
install_test_platform_session("submission-owner-a", "submission-token-a", &base_url);
|
||||
let frozen_session = current_platform_session().expect("frozen owner A session");
|
||||
let switch_base_url = base_url.clone();
|
||||
let switch_generation = frozen_session.generation + 1;
|
||||
let switch_generation = frozen_session.identity_generation + 1;
|
||||
let server = std::thread::spawn(move || {
|
||||
let mut stream =
|
||||
accept_resource_editor_fixture_connection(&listener, "accepted switch fixture", 0);
|
||||
@@ -7032,6 +7017,7 @@ mod tests {
|
||||
"submission-token-b",
|
||||
&switch_base_url,
|
||||
switch_generation,
|
||||
switch_generation,
|
||||
)
|
||||
.expect("switch to owner B before returning accepted response");
|
||||
write_json(
|
||||
@@ -7447,8 +7433,14 @@ mod tests {
|
||||
ledger.access_scheme = None;
|
||||
initialize_resource_edit_access_identity(root, &mut ledger, base_url, Some(&frozen_a))
|
||||
.expect("write resource ledger for owner A");
|
||||
replace_platform_session_for_gui_owner("resource-owner-b", "resource-token-b", base_url, 2)
|
||||
.expect("switch global resource session to owner B");
|
||||
replace_platform_session_for_gui_owner(
|
||||
"resource-owner-b",
|
||||
"resource-token-b",
|
||||
base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch global resource session to owner B");
|
||||
|
||||
let error = prepare_resource_edit_service_identity(
|
||||
root,
|
||||
@@ -7509,7 +7501,8 @@ mod tests {
|
||||
user_id: "resource-identity-owner-b".to_string(),
|
||||
access_token: "resource-identity-token-b".to_string(),
|
||||
api_base_url: owner_a.api_base_url.clone(),
|
||||
generation: owner_a.generation + 1,
|
||||
identity_generation: owner_a.identity_generation + 1,
|
||||
revision: owner_a.revision + 1,
|
||||
};
|
||||
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared);
|
||||
ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string());
|
||||
@@ -7535,7 +7528,8 @@ mod tests {
|
||||
&owner_b.user_id,
|
||||
&owner_b.access_token,
|
||||
&owner_b.api_base_url,
|
||||
owner_b.generation,
|
||||
owner_b.identity_generation,
|
||||
owner_b.revision,
|
||||
)
|
||||
.expect("switch to resource non-owner B");
|
||||
|
||||
@@ -7639,7 +7633,8 @@ mod tests {
|
||||
"resource-lease-owner-b",
|
||||
"resource-lease-token-b",
|
||||
api_base_url,
|
||||
frozen_a.generation + 1,
|
||||
frozen_a.identity_generation + 1,
|
||||
frozen_a.revision + 1,
|
||||
)
|
||||
.expect("switch resource lease owner");
|
||||
switched_sender.send(()).expect("signal resource switch");
|
||||
@@ -8259,7 +8254,8 @@ mod tests {
|
||||
"archive-owner-b",
|
||||
"archive-token-b",
|
||||
api_base_url,
|
||||
owner_a.generation + 1,
|
||||
owner_a.identity_generation + 1,
|
||||
owner_a.revision + 1,
|
||||
)
|
||||
.expect("switch to owner B");
|
||||
let error = archive_failed_local_project_resource_edit_at(
|
||||
@@ -8388,7 +8384,8 @@ mod tests {
|
||||
"pending-owner-b",
|
||||
"pending-token-b",
|
||||
api_base_url,
|
||||
owner_a.generation + 1,
|
||||
owner_a.identity_generation + 1,
|
||||
owner_a.revision + 1,
|
||||
)
|
||||
.expect("switch to pending owner B");
|
||||
let owner_b = current_platform_session().expect("pending owner B session");
|
||||
@@ -9347,7 +9344,7 @@ mod tests {
|
||||
let (attempted_sender, attempted_receiver) = mpsc::channel();
|
||||
let (completed_sender, completed_receiver) = mpsc::channel();
|
||||
let switch_api_base_url = api_base_url.to_string();
|
||||
let switch_generation = frozen_session.generation + 1;
|
||||
let switch_generation = frozen_session.identity_generation + 1;
|
||||
let switch_thread = std::thread::spawn(move || {
|
||||
begin_switch_receiver
|
||||
.recv()
|
||||
@@ -9360,6 +9357,7 @@ mod tests {
|
||||
"commit-token-b",
|
||||
&switch_api_base_url,
|
||||
switch_generation,
|
||||
switch_generation,
|
||||
)
|
||||
.expect("switch to commit owner B");
|
||||
completed_sender
|
||||
|
||||
@@ -24,6 +24,9 @@ pub(crate) struct LocalProjectTextPreview {
|
||||
pub(crate) media_type: String,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) content: String,
|
||||
/// 仅由已登记资源的原生 UI State 校验设置;前端不根据正文猜测编辑能力。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) ui_design_asset_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
@@ -163,6 +166,7 @@ pub(crate) fn load_local_project_text_preview_with_cancellation(
|
||||
media_type: media_type.to_string(),
|
||||
byte_len: content.len() as u64,
|
||||
content,
|
||||
ui_design_asset_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,21 +12,20 @@ pub(crate) use client::{
|
||||
clear_external_agent_runner_platform_session, compact_external_agent_runner_context,
|
||||
configure_external_agent_runner, configure_external_agent_runner_read_only,
|
||||
continue_external_agent_runner_action, ensure_external_agent_runner_started,
|
||||
ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session,
|
||||
ensure_external_agent_runner_started_for_gui, hold_external_agent_runner_gui_participant_lock,
|
||||
install_external_agent_runner_platform_session,
|
||||
interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner,
|
||||
pause_external_agent_runner, read_external_agent_runner_status,
|
||||
require_external_agent_runner_configured_for_cli_runtime_write,
|
||||
require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner,
|
||||
shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit,
|
||||
shutdown_external_agent_runner_if_idle, steer_external_agent_runner,
|
||||
wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run,
|
||||
shutdown_external_agent_runner_for_gui_exit, shutdown_external_agent_runner_if_idle,
|
||||
steer_external_agent_runner, wake_external_agent_runner_pending,
|
||||
wake_external_agent_runner_pending_for_run,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
pub(crate) use endpoint::validate_windows_regular_file_handle;
|
||||
pub(crate) use endpoint::{
|
||||
acquire_external_agent_runner_gui_owner_lock, external_agent_runner_enabled,
|
||||
external_agent_runner_is_server_process,
|
||||
};
|
||||
pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION};
|
||||
pub(crate) use server::{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user