Merge branch 'master' into style/game-play
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 1m26s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 1m18s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m46s
Project CI / Frontend tests (pull_request) Successful in 4m9s
Project CI / Repository checks (pull_request) Successful in 4m26s
Project CI / Backend tests (pull_request) Failing after 5m46s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m38s
Project CI / Native shell tests (pull_request) Successful in 8m58s

This commit is contained in:
2026-09-30 14:20:29 +08:00
43 changed files with 3548 additions and 118 deletions
+1
View File
@@ -56,6 +56,7 @@ temp*build*/
/plugins/agc-unity-editor/dotnet/native-build/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
/apps/ai-game-creator-shell/src-tauri/resources/claude-agent/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
+3
View File
@@ -1075,11 +1075,14 @@ export interface AdminRechargeRefundActionResponse {
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
export type AdminAgcAgentMode = 'codex' | 'cc';
export interface AdminAgcModel {
id: string;
alias: string;
modelId: string;
enabled: boolean;
agentMode: AdminAgcAgentMode;
}
export interface AdminAgcModelCatalog {
@@ -23,14 +23,23 @@ vi.mock('../components/useAdminWriteConfirm', () => ({
confirmDialog: null,
}),
}));
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test('edits alias and upstream model without changing the stable identifier or revision', async () => {
const catalog = {
revision: 3,
defaultModelId: 'quality',
models: [
{ id: 'quality', alias: '高质量', modelId: 'gpt-6-astra', enabled: true },
{
id: 'quality',
alias: '高质量',
modelId: 'gpt-6-astra',
enabled: true,
agentMode: 'codex' as const,
},
],
};
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
@@ -54,3 +63,44 @@ test('edits alias and upstream model without changing the stable identifier or r
expect(screen.getAllByText('已保存').length).toBeGreaterThanOrEqual(2);
});
});
test('new models default to Codex and save the selected Agent mode', async () => {
const catalog = {
revision: 3,
defaultModelId: 'quality',
models: [
{
id: 'quality',
alias: '高质量',
modelId: 'gpt-6-astra',
enabled: true,
agentMode: 'codex' as const,
},
],
};
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
...input,
revision: 4,
}));
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
await screen.findByDisplayValue('gpt-6-astra');
fireEvent.click(screen.getByRole('button', { name: '添加模型' }));
const agentMode = screen.getByLabelText('模型 2 Agent 模式');
expect((agentMode as HTMLSelectElement).value).toBe('codex');
fireEvent.change(agentMode, { target: { value: 'cc' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
const payload = calls[calls.length - 1]?.[1];
expect(payload?.models).toHaveLength(2);
expect(payload?.models[1]).toMatchObject({
alias: '',
modelId: '',
enabled: true,
agentMode: 'cc',
});
});
});
@@ -116,6 +116,7 @@ export function AdminAgcModelsPage({
columns={[
{ key: 'alias', label: '别名' },
{ key: 'modelId', label: '实际模型名' },
{ key: 'agentMode', label: 'Agent 模式' },
{ key: 'enabled', label: '启用' },
{ key: 'default', label: '默认' },
{ key: 'actions', label: '操作' },
@@ -155,6 +156,7 @@ export function AdminAgcModelsPage({
alias: '',
modelId: '',
enabled: true,
agentMode: 'codex',
},
],
},
@@ -211,6 +213,21 @@ export function AdminAgcModelsPage({
}
/>
</td>
<td>
<select
aria-label={`模型 ${index + 1} Agent 模式`}
value={model.agentMode}
disabled={busy}
onChange={(e) =>
update(model.id, {
agentMode: e.target.value as AdminAgcModel['agentMode'],
})
}
>
<option value="codex">Codex</option>
<option value="cc">Claude Code</option>
</select>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 启用`}
@@ -0,0 +1,9 @@
{
"name": "@genarrative/ai-game-creator-agent-sidecar",
"private": true,
"type": "module",
"version": "0.1.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.285"
}
}
@@ -0,0 +1,157 @@
import readline from 'node:readline';
import { query } from '@anthropic-ai/claude-agent-sdk';
const sessions = new Map();
const active = new Map();
let outputChain = Promise.resolve();
function write(value) {
outputChain = outputChain
.then(
() =>
new Promise((resolve, reject) => {
process.stdout.write(`${JSON.stringify(value)}\n`, (error) =>
error ? reject(error) : resolve(),
);
}),
)
.catch(() => undefined);
return outputChain;
}
function safeEnv(input) {
const env = { ...process.env };
if (input && typeof input === 'object') {
for (const [key, value] of Object.entries(input)) {
if (typeof value === 'string') env[key] = value;
}
}
env.CLAUDE_AGENT_SDK_CLIENT_APP ??= 'genarrative-agc-agent-sidecar/0.1';
return env;
}
function buildOptions(request, controller) {
const options = {
abortController: controller,
cwd: request.cwd,
model: request.model,
permissionMode: 'dontAsk',
settingSources: [],
tools: [],
disallowedTools: ['Bash', 'Edit', 'Write', 'NotebookEdit'],
verbatimPrompts: true,
persistSession: true,
includePartialMessages: true,
env: safeEnv(request.env),
};
if (request.systemPrompt) {
options.systemPrompt = {
type: 'custom',
prompt: request.systemPrompt,
snapshot: true,
};
}
if (request.resume) options.resume = request.resume;
if (request.sessionId) options.sessionId = request.sessionId;
if (request.mcpServers && typeof request.mcpServers === 'object') {
options.mcpServers = request.mcpServers;
}
if (request.outputSchema) {
options.outputFormat = {
type: 'json_schema',
schema: request.outputSchema,
};
}
if (Number.isInteger(request.maxTurns) && request.maxTurns > 0) {
options.maxTurns = request.maxTurns;
}
return options;
}
async function runTurn(request) {
const id = String(request.id ?? '');
if (!id) return write({ type: 'error', message: 'sidecar turn 缺少 id' });
if (active.has(id))
return write({ id, type: 'error', message: 'sidecar turn 已存在' });
const controller = new AbortController();
active.set(id, { controller });
try {
const sessionKey =
typeof request.sessionKey === 'string' ? request.sessionKey : '';
const resume =
request.resume || (sessionKey ? sessions.get(sessionKey) : undefined);
const queryRequest = { ...request, resume };
const stream = query({
prompt: String(request.prompt ?? ''),
options: buildOptions(queryRequest, controller),
});
for await (const message of stream) {
if (
message?.type === 'system' &&
message?.subtype === 'init' &&
message.session_id
) {
if (sessionKey) sessions.set(sessionKey, message.session_id);
}
if (message?.session_id && sessionKey)
sessions.set(sessionKey, message.session_id);
await write({ id, type: 'event', event: message });
if (message?.type === 'result') {
await write({
id,
type: 'done',
sessionId:
message.session_id ??
(sessionKey ? sessions.get(sessionKey) : undefined),
result: message,
});
}
}
} catch (error) {
await write({
id,
type: 'error',
message: error instanceof Error ? error.message : String(error),
});
} finally {
active.delete(id);
}
}
async function cancelTurn(request) {
const id = String(request.id ?? '');
const task = active.get(id);
if (!task) return write({ id, type: 'cancelled', cancelled: false });
task.controller.abort(new Error('AGC cancelled Claude Agent SDK query'));
return write({ id, type: 'cancelled', cancelled: true });
}
const input = readline.createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
for await (const line of input) {
if (!line.trim()) continue;
let request;
try {
request = JSON.parse(line);
} catch {
await write({ type: 'error', message: 'sidecar 输入不是有效 JSON' });
continue;
}
if (request.type === 'cancel') {
await cancelTurn(request);
} else if (request.type === 'turn') {
void runTurn(request);
} else if (request.type === 'shutdown') {
for (const task of active.values()) task.controller.abort();
break;
} else {
await write({
id: request.id,
type: 'error',
message: 'sidecar 不支持该请求类型',
});
}
}
+1
View File
@@ -27,6 +27,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.285",
"@cubone/react-file-manager": "^1.35.0",
"@genarrative/image-canvas-core": "0.1.0",
"@genarrative/image-canvas-react": "0.1.0",
@@ -1358,6 +1358,7 @@ for (const channel of ['release', 'beta-2']) {
const expectedBundledDesignAgentResources = {
'design-agent': 'design-agent',
'resources/claude-agent': 'claude-agent',
...Object.fromEntries(
[
'codex-patch-parser',
@@ -32,6 +32,83 @@ fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
Ok(format!("{:x}", hasher.finalize()))
}
fn claude_agent_platform(target: &str) -> Option<&'static str> {
match target {
"x86_64-pc-windows-msvc" => Some("win32-x64"),
"aarch64-pc-windows-msvc" => Some("win32-arm64"),
"x86_64-apple-darwin" => Some("darwin-x64"),
"aarch64-apple-darwin" => Some("darwin-arm64"),
"x86_64-unknown-linux-gnu" => Some("linux-x64"),
"aarch64-unknown-linux-gnu" => Some("linux-arm64"),
"x86_64-unknown-linux-musl" => Some("linux-x64-musl"),
"aarch64-unknown-linux-musl" => Some("linux-arm64-musl"),
_ => None,
}
}
fn stage_bundled_claude_agent(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
let Some(platform) = claude_agent_platform(&target) else {
println!("cargo:warning=Claude Agent SDK sidecar 暂不支持目标:{target}");
return;
};
let app_root = manifest_dir.parent().expect("AGC Tauri manifest parent");
let repo_root = app_root
.parent()
.and_then(|apps_dir| apps_dir.parent())
.expect("AGC 应用必须位于仓库 apps 目录下");
let roots = [app_root, repo_root];
let sdk_source = roots
.iter()
.map(|root| root.join("node_modules/@anthropic-ai/claude-agent-sdk"))
.find(|path| path.join("sdk.mjs").is_file())
.unwrap_or_else(|| panic!("Claude Agent SDK 缺失;请先执行 npm ci"));
let binary_package = format!("claude-agent-sdk-{platform}");
let binary_source = roots
.iter()
.map(|root| {
root.join("node_modules/@anthropic-ai")
.join(&binary_package)
})
.find(|path| path.is_dir())
.unwrap_or_else(|| panic!("Claude Agent SDK 原生运行时缺失:{binary_package}"));
let entry_source = app_root.join("agent-sidecar/src/index.mjs");
assert!(entry_source.is_file(), "Claude Agent SDK sidecar 入口缺失");
let target_root = manifest_dir.join("resources/claude-agent");
if target_root.exists() {
fs::remove_dir_all(&target_root).expect("清理 Claude Agent SDK staging 失败");
}
fs::create_dir_all(target_root.join("node_modules/@anthropic-ai"))
.expect("创建 Claude Agent SDK staging 目录失败");
stage_plugin_file(&entry_source, &target_root.join("index.mjs"));
copy_plugin_tree(
&sdk_source,
&target_root.join("node_modules/@anthropic-ai/claude-agent-sdk"),
);
copy_plugin_tree(
&binary_source,
&target_root
.join("node_modules/@anthropic-ai")
.join(&binary_package),
);
let binary_name = if target.contains("windows") {
"claude.exe"
} else {
"claude"
};
let staged_binary = target_root
.join("node_modules/@anthropic-ai")
.join(&binary_package)
.join(binary_name);
if let Ok(metadata) = fs::metadata(binary_source.join(binary_name)) {
fs::set_permissions(&staged_binary, metadata.permissions())
.expect("保留 Claude Agent SDK 原生运行时权限失败");
}
println!("cargo:rerun-if-changed={}", entry_source.display());
println!("cargo:rerun-if-changed={}", sdk_source.display());
println!("cargo:rerun-if-changed={}", binary_source.display());
}
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
@@ -262,6 +339,7 @@ fn main() {
env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"),
);
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
stage_bundled_claude_agent(&manifest_dir);
stage_bundled_codex_cli(&manifest_dir);
prepare_unity_editor_helper(&manifest_dir);
prepare_godot_editor_extension(&manifest_dir);
@@ -9,6 +9,7 @@ use std::collections::BTreeSet;
use std::io::{Seek, SeekFrom};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
mod claude_code_cli;
mod codex_app_server;
mod codex_cli;
mod codex_provider_proxy;
@@ -48,6 +49,12 @@ mod runtime_protocol;
mod runtime_state;
mod runtime_tools;
mod skill_pack;
use claude_code_cli::*;
pub(crate) use claude_code_cli::{
cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at,
direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error,
game_creator_claude_code_cli_version_identity,
};
use codex_app_server::*;
pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at,
File diff suppressed because it is too large Load Diff
@@ -4530,6 +4530,9 @@ pub(crate) fn cancel_direct_codex_turn_at(
root: &Path,
client_turn_id: Option<&str>,
) -> Result<DirectTurnCancelView, String> {
if let Some(view) = cancel_direct_claude_code_turn_at(root, client_turn_id)? {
return Ok(view);
}
let key = direct_codex_active_turn_key(root);
let expected = client_turn_id
.map(str::trim)
@@ -5363,6 +5366,23 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
};
let config = load_game_creator_app_config()
.map_err(|detail| DirectTurnError::EnvironmentNotReady { detail })?;
if config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI {
if let Some(error) = game_creator_claude_code_cli_route_error(&config.llm) {
return Err(DirectTurnError::EnvironmentNotReady { detail: error });
}
return direct_game_creator_claude_code_chat_at(
&codex_root,
&config.llm,
system_prompt,
user_prompt,
client_turn_id,
observer,
)
.await
.map_err(|detail| {
DirectTurnError::from_model_call(&platform_llm::LlmError::Transport(detail))
});
}
game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| {
DirectTurnError::EnvironmentNotReady {
detail: error.to_string(),
@@ -5459,6 +5479,13 @@ pub(crate) async fn direct_game_creator_home_codex_chat(
user_prompt: String,
) -> Result<String, String> {
let config = load_game_creator_app_config()?;
if config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI {
if let Some(error) = game_creator_claude_code_cli_route_error(&config.llm) {
return Err(error);
}
return direct_game_creator_claude_code_home_chat(&config.llm, system_prompt, user_prompt)
.await;
}
game_creator_codex_app_server_validate_llm_config(&config.llm)
.map_err(|error| error.to_string())?;
let snapshot = AgentRuntimeProviderRequestSnapshot {
@@ -373,10 +373,25 @@ fn game_creator_agent_runtime_provider_config_fingerprint_for_mode(
} else {
None
};
let claude_code_cli = if agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI {
Some(serde_json::json!({
"executable": "claude",
"version": game_creator_claude_code_cli_version_identity()?,
"protocol": "genarrative-claude-code-cli-agent.v1",
"structuredOutput": true,
"nativeTools": false,
"mcp": "direct-loopback-only",
"isolatedHome": true,
"noSessionPersistence": true,
}))
} else {
None
};
let stable = serde_json::json!({
"agentMode": agent_mode,
"codexAppServer": codex_app_server,
"codexCli": codex_cli,
"claudeCodeCli": claude_code_cli,
"apiKeySha256": format!("{:x}", Sha256::digest(llm.api_key.as_bytes())),
"baseUrl": llm.base_url,
"model": llm.model,
@@ -419,6 +434,8 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_m
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER | GAME_CREATOR_AGENT_MODE_CODEX_CLI
) {
Some(game_creator_codex_cli_version_identity()?)
} else if agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI {
Some(game_creator_claude_code_cli_version_identity()?)
} else {
None
};
@@ -1448,6 +1465,9 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
request_game_creator_agent_codex_cli(request).await
}
GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI => {
request_game_creator_agent_claude_code_cli(request).await
}
GAME_CREATOR_AGENT_MODE_PROVIDER => {
let client = build_game_creator_agent_runtime_llm_client(
&llm_for_request,
@@ -1524,6 +1544,9 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
request_game_creator_agent_codex_cli(request.clone()).await
}
GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI => {
request_game_creator_agent_claude_code_cli(request.clone()).await
}
GAME_CREATOR_AGENT_MODE_PROVIDER => {
let client = client.expect("provider mode constructs an HTTP client");
request_game_creator_agent_runtime_provider_llm(&client, llm, request.clone())
@@ -1927,10 +1927,10 @@ pub(crate) fn select_game_creator_reasoning_effort(
persist_game_creator_app_config(config, overlays, false)
}
#[tauri::command]
pub(crate) fn select_game_creator_model(
fn select_game_creator_model_inner(
model_id: String,
is_default: bool,
catalog_agent_mode: Option<String>,
) -> Result<GameCreatorAppConfigView, String> {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
@@ -1948,11 +1948,27 @@ pub(crate) fn select_game_creator_model(
{
return Err("模型标识无效".into());
}
if let Some(agent_mode) = catalog_agent_mode {
config.agent_mode = match agent_mode.trim() {
"codex" => GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(),
"cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
value => return Err(format!("模型 Agent 模式无效:{value}")),
};
}
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) fn select_game_creator_model(
model_id: String,
is_default: bool,
agent_mode: Option<String>,
) -> Result<GameCreatorAppConfigView, String> {
select_game_creator_model_inner(model_id, is_default, agent_mode)
}
#[tauri::command]
pub(crate) async fn discover_game_creator_llm_models(
llm: GameCreatorLlmConfig,
@@ -443,20 +443,30 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
fn check_game_creator_codex_config(
app_config: &GameCreatorAppConfig,
) -> GameCreatorLlmConfigStatus {
let cli_error = check_game_creator_codex_cli_available()
.and_then(|()| {
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
check_game_creator_codex_app_server_available()
} else {
Ok(())
}
})
.err();
let cli_error = match app_config.agent_mode.as_str() {
GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI => {
crate::agent::game_creator_claude_code_cli_version_identity().map(|_| ())
}
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER | GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
check_game_creator_codex_cli_available().and_then(|()| {
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
check_game_creator_codex_app_server_available()
} else {
Ok(())
}
})
}
_ => Ok(()),
}
.err();
let global_route_error = game_creator_codex_app_server_llm_route_error(
&app_config.agent_mode,
&app_config.llm,
"llm",
);
)
.or_else(|| {
game_creator_claude_code_cli_llm_route_error(&app_config.agent_mode, &app_config.llm)
});
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
status.agent_mode = app_config.agent_mode.clone();
if !app_config.llm.custom_enabled && game_creator_official_llm_route_locked() {
@@ -550,6 +560,16 @@ pub(crate) fn game_creator_codex_app_server_llm_route_error(
None
}
pub(crate) fn game_creator_claude_code_cli_llm_route_error(
agent_mode: &str,
llm: &GameCreatorLlmConfig,
) -> Option<String> {
if agent_mode != GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI {
return None;
}
crate::agent::game_creator_claude_code_cli_route_error(llm)
}
pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> {
crate::agent::game_creator_codex_cli_version_identity().map(|_| ())
}
@@ -3650,10 +3670,15 @@ pub(crate) fn scrub_locked_game_creator_config_file(config: &mut GameCreatorAppC
if config.llm.as_ref().and_then(|llm| llm.custom_enabled) == Some(true) {
return false;
}
let mut changed = config.agent_mode.as_deref()
!= Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
|| config.agent_llm.is_some();
config.agent_mode = Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string());
let locked_agent_mode =
if config.agent_mode.as_deref() == Some(GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI) {
GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI
} else {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
};
let mut changed =
config.agent_mode.as_deref() != Some(locked_agent_mode) || config.agent_llm.is_some();
config.agent_mode = Some(locked_agent_mode.to_string());
config.agent_llm = None;
if let Some(llm) = config.llm.as_mut() {
// 官方路由仍然清空凭据,但把连接字段留在文件里:手写自定义连接时
@@ -3806,7 +3831,9 @@ pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCr
if !game_creator_official_llm_route_locked() {
return;
}
config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string();
if config.agent_mode != GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI {
config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string();
}
config.agent_llm.clear();
config.editor_api.api_key.clear();
if config.llm.custom_enabled {
@@ -4528,9 +4555,12 @@ pub(crate) fn normalize_game_creator_agent_mode(value: &str) -> Result<String, S
Ok(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string())
}
GAME_CREATOR_AGENT_MODE_CODEX_CLI => Ok(GAME_CREATOR_AGENT_MODE_CODEX_CLI.to_string()),
GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI => {
Ok(GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string())
}
GAME_CREATOR_AGENT_MODE_PROVIDER => Ok(GAME_CREATOR_AGENT_MODE_PROVIDER.to_string()),
value => Err(format!(
"配置项 agentMode 无效:{value},请使用 codex_app_server、codex_cli 或 provider"
"配置项 agentMode 无效:{value},请使用 codex_app_server、codex_cli、claude_code_cli 或 provider"
)),
}
}
@@ -12,11 +12,17 @@ use std::time::Duration;
const API_RESPONSE_ENVELOPE_VERSION: &str = "v1";
const MODEL_CATALOG_MAX_BYTES: usize = 1024 * 1024;
fn default_codex_agent_mode() -> String {
"codex".to_string()
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireModelSummary {
id: String,
display_name: String,
#[serde(default = "default_codex_agent_mode")]
agent_mode: String,
}
#[derive(Debug, Deserialize)]
@@ -49,6 +55,7 @@ fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse {
.map(|model| LlmModelSummary {
id: model.id,
display_name: model.display_name,
agent_mode: model.agent_mode,
})
.collect(),
revision: catalog.revision,
@@ -1438,6 +1438,7 @@ const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json";
const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json";
const GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER: &str = "codex_app_server";
const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli";
const GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI: &str = "claude_code_cli";
const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider";
const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2";
const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1";
@@ -38,9 +38,9 @@ fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials(
write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap();
let mut config = read_game_creator_app_config().unwrap().config;
assert_eq!(config.llm.model, "a/v1");
let selected = select_game_creator_model("b:v2".into(), false).unwrap();
let selected = select_game_creator_model("b:v2".into(), false, None).unwrap();
assert_eq!(selected.config.llm.model, "b:v2");
assert!(select_game_creator_model("not-listed".into(), false).is_err());
assert!(select_game_creator_model("not-listed".into(), false, None).is_err());
config.llm.visible_models = vec!["b:v2".into()];
config.llm.api_key = "changed-fixture-key".into();
write_game_creator_app_config(config).unwrap();
@@ -228,6 +228,11 @@ fn agent_mode_defaults_to_codex_app_server_and_preserves_explicit_modes() {
normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_CLI).expect("cli mode"),
GAME_CREATOR_AGENT_MODE_CODEX_CLI
);
assert_eq!(
normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI)
.expect("cc mode"),
GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI
);
assert!(normalize_game_creator_agent_mode("unknown")
.expect_err("unknown mode")
.contains("agentMode"));
@@ -958,7 +963,8 @@ fn app_config_model_selection_only_updates_model_overlay() {
r#"{"selectedModelId":"old","selectedModelIsDefault":false,"llm":{"reasoningEffort":"low"}}"#,
)
.expect("write overlay");
let saved = select_game_creator_model("new-model".to_string(), true).expect("select model");
let saved =
select_game_creator_model("new-model".to_string(), true, None).expect("select model");
let effective = load_game_creator_app_config().expect("effective config");
assert_eq!(saved.config.selected_model_id, "new-model");
assert_eq!(effective.selected_model_id, "new-model");
@@ -981,12 +987,12 @@ fn model_selection_default_flag_round_trips() {
let _guard = use_test_runtime_config_dir(root.clone());
let saved =
select_game_creator_model("quality".to_string(), true).expect("select default model");
select_game_creator_model("quality".to_string(), true, None).expect("select default model");
assert_eq!(saved.config.selected_model_id, "quality");
assert!(saved.config.selected_model_is_default);
let saved =
select_game_creator_model("fast".to_string(), false).expect("select explicit model");
select_game_creator_model("fast".to_string(), false, None).expect("select explicit model");
assert_eq!(saved.config.selected_model_id, "fast");
assert!(!saved.config.selected_model_is_default);
@@ -39,14 +39,19 @@
"vendor/codex-utils-path-uri/LICENSE": "licenses/codex-utils-path-uri/LICENSE",
"vendor/codex-utils-path-uri/NOTICE": "licenses/codex-utils-path-uri/NOTICE",
"vendor/codex-utils-absolute-path/LICENSE": "licenses/codex-utils-absolute-path/LICENSE",
"vendor/codex-utils-absolute-path/NOTICE": "licenses/codex-utils-absolute-path/NOTICE"
"vendor/codex-utils-absolute-path/NOTICE": "licenses/codex-utils-absolute-path/NOTICE",
"resources/claude-agent": "claude-agent"
},
"linux": {
"deb": {
"depends": ["bubblewrap"]
"depends": [
"bubblewrap"
]
},
"rpm": {
"depends": ["bubblewrap"]
"depends": [
"bubblewrap"
]
}
},
"icon": [
@@ -575,6 +575,7 @@ export type GameCreatorLlmApiKind =
export type GameCreatorAgentMode =
| 'codex_app_server'
| 'codex_cli'
| 'claude_code_cli'
| 'provider';
export type RuntimeLlmProviderPresetId =
| 'custom'
@@ -171,6 +171,9 @@ export function ConversationModelSelect({
invoke<GameCreatorAppConfigView>('select_game_creator_model', {
modelId: next,
isDefault: nextIsDefault,
agentMode:
catalog.models.find((model) => model.id === next)?.agentMode ??
'codex',
}),
);
} catch {
@@ -336,10 +339,13 @@ export function ConversationModelSelect({
try {
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('Native host unavailable');
const agentMode =
models.find((model) => model.id === id)?.agentMode ?? 'codex';
const result = await queueConfigWrite(() =>
invoke<GameCreatorAppConfigView>('select_game_creator_model', {
modelId: id,
isDefault: false,
agentMode,
}),
);
if (result.config.selectedModelId !== id)
@@ -166,7 +166,11 @@ function normalizeRuntimeConfigDraft(
: defaultRuntimeConfigDraft.llm.reasoningEffort;
return {
...config,
agentMode: 'codex_app_server',
// Agent 模式由后台模型选择绑定;设置页保存不能把 cc 选择覆盖回 Codex。
agentMode:
config.agentMode === 'claude_code_cli'
? 'claude_code_cli'
: 'codex_app_server',
llm: {
...config.llm,
apiKey: config.llm.customEnabled ? config.llm.apiKey : '',
@@ -663,7 +667,11 @@ export function RuntimeConfigDialog({
const config = normalizeRuntimeConfigDraft(
{
...runtimeConfigDraft,
agentMode: 'codex_app_server',
// 模型选择器已把后台绑定的执行模式写入配置,这里只保留它。
agentMode:
runtimeConfigDraft.agentMode === 'claude_code_cli'
? 'claude_code_cli'
: 'codex_app_server',
editorApi: advancedExternalEditorConfigEnabled
? runtimeConfigDraft.editorApi
: defaultRuntimeConfigDraft.editorApi,
@@ -0,0 +1,36 @@
import { resolveTauriInvoke } from '../app/tauri';
export type ClientLlmModel = {
displayName: string;
id: string;
agentMode: 'codex' | 'cc';
};
export type ClientLlmModelCatalog = {
defaultModelId: string;
models: ClientLlmModel[];
revision: number;
};
type ClientLlmModelWire = Omit<ClientLlmModel, 'agentMode'> & {
agentMode?: ClientLlmModel['agentMode'] | null;
};
type ClientLlmModelCatalogWire = Omit<ClientLlmModelCatalog, 'models'> & {
models: ClientLlmModelWire[];
};
export async function loadClientLlmModels(): Promise<ClientLlmModelCatalog> {
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('本地模型服务不可用');
const catalog = await invoke<ClientLlmModelCatalogWire>(
'load_game_creator_llm_models',
);
return {
...catalog,
models: catalog.models.map((model) => ({
...model,
agentMode: model.agentMode ?? 'codex',
})),
};
}
@@ -1,16 +1,8 @@
import { resolveTauriInvoke } from '../app/tauri';
import type { GameCreatorAppConfigView } from '../app/types';
import { type ClientLlmModelCatalog, loadClientLlmModels } from './clientApi';
export type ClientLlmModel = {
displayName: string;
id: string;
};
export type ClientLlmModelCatalog = {
defaultModelId: string;
models: ClientLlmModel[];
revision: number;
};
export type { ClientLlmModel, ClientLlmModelCatalog } from './clientApi';
let cached: ClientLlmModelCatalog | null = null;
let inFlight: Promise<ClientLlmModelCatalog> | null = null;
@@ -56,12 +48,16 @@ async function loadEffectiveCatalog(epoch: number) {
const ids = llm.visibleModels ?? [];
return {
defaultModelId: ids[0] ?? '',
models: ids.map((id) => ({ id, displayName: id })),
models: ids.map((id) => ({
id,
displayName: id,
agentMode: 'codex' as const,
})),
revision: --localRevision,
};
}
// 官方模型目录的凭据、请求和响应解析归 Rust;渲染层只接收目录快照。
return invoke<ClientLlmModelCatalog>('load_game_creator_llm_models');
return loadClientLlmModels();
}
/** 最近一次成功读取的模型目录,用于首屏渲染与刷新失败时兜底。 */
@@ -281,6 +281,7 @@ export function registerDesignAgentSurfaceTests() {
expect(harness.invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'fast',
isDefault: false,
agentMode: 'codex',
});
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,

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