Compare commits

...

7 Commits

Author SHA1 Message Date
kdletters dc8e03988f 修复AGC模型列表刷新无反馈与响应体无限等待
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m26s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m20s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m8s
Project CI / AI game creator shell Rust crates (push) Successful in 3m6s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m4s
Project CI / Repository checks (push) Successful in 3m27s
Project CI / Frontend tests (push) Successful in 4m39s
Project CI / Native shell tests (push) Successful in 7m27s
Project CI / AI game creator shell web tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 8m47s
ConversationModelSelect:手动刷新立即显示「刷新中…」,真实刷新成功后显示「模型列表已刷新」,revision 未变化也有反馈
ConversationModelSelect:刷新失败按 HTTP 状态与超时给出可辨认提示,沿用有效缓存时不报告刷新成功,已选模型与挂载/聚焦语义不变
clientApi:成功与错误响应体统一走 readClientHttpResponseText 的 15 秒上限,响应体卡住时结束等待并释放模型目录在途请求
测试:补齐模型目录响应体超时后保留缓存并可重试、迟到响应不覆盖新目录、手动刷新进行中/同版本成功/失败反馈用例
文档:同步 AGC 模型目录刷新反馈与响应体超时合同,并在共享记忆记录该排障口径
2026-09-15 14:41:57 +08:00
lhk229 8ae7ed0128 Merge branch 'master' into opt/design-debug
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m32s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m39s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m56s
Project CI / Repository checks (pull_request) Successful in 4m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m33s
Project CI / Backend tests (pull_request) Successful in 8m44s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m32s
Project CI / Frontend tests (pull_request) Successful in 4m31s
Project CI / Native shell tests (pull_request) Successful in 6m53s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m42s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m45s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m48s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m1s
Project CI / AI game creator shell Rust crates (push) Successful in 3m22s
Project CI / Frontend tests (push) Successful in 5m23s
Project CI / Backend tests (push) Successful in 7m54s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m41s
Project CI / Repository checks (push) Successful in 4m3s
Project CI / Native shell tests (push) Successful in 7m50s
Project CI / AI game creator shell web tests (push) Successful in 2m32s
2026-09-15 12:29:58 +08:00
lhk229 dfdcc43a0b Merge pull request '新增 AGC 首页策划补全入口' (#369) from feat/entrance into master
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m30s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m0s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m12s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m25s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m40s
Project CI / AI game creator shell Rust crates (push) Successful in 2m13s
Project CI / Repository checks (push) Successful in 3m59s
Project CI / Frontend tests (push) Successful in 5m36s
Project CI / Native shell tests (push) Successful in 6m35s
Project CI / Backend tests (push) Successful in 7m13s
Project CI / AI game creator shell web tests (push) Successful in 3m31s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/369
2026-09-15 12:29:47 +08:00
lhk229 9389c7fff8 修正 AGC 策划补全说明
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
更新首页启动模式注释以覆盖游戏策划补全分支

同步实施计划与当前测试覆盖边界
2026-09-15 12:26:57 +08:00
lhk229 77d3917d9c 统一策划 Agent Debug 运行时开关
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
策划 Debug 日志与快速推进入口共用 GENARRATIVE_AGC_DESIGN_DEBUG。

通过 Tauri 查询构建与运行时状态,移除前端 VITE Debug 开关依赖。

更新开发启动器、策划工作区测试和技术方案文档。
2026-09-15 04:22:45 +00:00
lhk229 fbf8d34f45 新增 AGC 首页策划补全入口
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m27s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m44s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m46s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m11s
Project CI / Repository checks (pull_request) Successful in 3m43s
Project CI / Frontend tests (pull_request) Successful in 4m35s
Project CI / Native shell tests (pull_request) Successful in 6m8s
Project CI / Backend tests (pull_request) Successful in 7m8s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m11s
首页做游戏入口增加策划补全勾选项

勾选后复用 planning Agent Runtime 启动模式

补充首页启动模式回归测试与实施计划
2026-09-15 12:14:03 +08:00
kdletters 33369f1180 修复生图资产实时刷新
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m5s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m50s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m13s
Project CI / AI game creator shell Rust crates (push) Successful in 2m2s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m12s
Project CI / Repository checks (push) Successful in 4m16s
Project CI / Frontend tests (push) Successful in 5m8s
Project CI / Native shell tests (push) Successful in 6m31s
Project CI / Backend tests (push) Successful in 7m32s
Project CI / AI game creator shell web tests (push) Successful in 4m12s
兼容 Windows 普通、verbatim 与 UNC 项目路径的失效事件匹配

让普通生图成功后立即发送 manifest 刷新通知

补充资源卡片与桥接通知回归测试
2026-09-15 10:37:47 +08:00
26 changed files with 545 additions and 44 deletions
@@ -22,7 +22,6 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..'); const repoRoot = resolve(appRoot, '../..');
const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
const AGC_DESIGN_DEBUG_ENV = 'GENARRATIVE_AGC_DESIGN_DEBUG'; const AGC_DESIGN_DEBUG_ENV = 'GENARRATIVE_AGC_DESIGN_DEBUG';
const AGC_DESIGN_DEBUG_VITE_ENV = 'VITE_GENARRATIVE_AGC_DESIGN_DEBUG';
const designDebugEnabled = const designDebugEnabled =
process.env[AGC_DESIGN_DEBUG_ENV]?.trim() === '0' ? '0' : '1'; process.env[AGC_DESIGN_DEBUG_ENV]?.trim() === '0' ? '0' : '1';
@@ -137,7 +136,6 @@ async function runTauriDev(
env: { env: {
...withAgcDevEndpointEnv(endpoint), ...withAgcDevEndpointEnv(endpoint),
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled, [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
[AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled,
}, },
}); });
const childResult = waitForCli(child); const childResult = waitForCli(child);
@@ -191,7 +189,6 @@ async function prepareFrontendDev(endpoint, { onChild, signal }) {
cwd: repoRoot, cwd: repoRoot,
env: { env: {
...withAgcDevEndpointEnv(endpoint), ...withAgcDevEndpointEnv(endpoint),
[AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled,
}, },
}, },
); );
@@ -606,11 +606,7 @@ fn build_design_request(
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。 // 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
fn design_debug(root: &Path, kind: &str, data: Value) { fn design_debug(root: &Path, kind: &str, data: Value) {
if std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG") if !design_debug_enabled() {
.ok()
.as_deref()
!= Some("1")
{
return; return;
} }
type Entry = (PathBuf, Value); type Entry = (PathBuf, Value);
@@ -1133,6 +1129,18 @@ fn resolve_design_runtime_mode(root: &Path) -> Result<Option<DesignRuntimeMode>,
})) }))
} }
fn design_debug_enabled() -> bool {
std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG")
.ok()
.as_deref()
== Some("1")
}
#[tauri::command]
pub(crate) fn is_design_agent_debug_enabled() -> bool {
cfg!(debug_assertions) && design_debug_enabled()
}
#[tauri::command] #[tauri::command]
pub(crate) fn set_design_agent_runtime_mode( pub(crate) fn set_design_agent_runtime_mode(
project_path: String, project_path: String,
@@ -1159,12 +1167,7 @@ pub(crate) fn debug_fast_forward_design_session(
project_path: String, project_path: String,
target_phase: String, target_phase: String,
) -> Result<DesignRuntimeMode, String> { ) -> Result<DesignRuntimeMode, String> {
if !cfg!(debug_assertions) if !is_design_agent_debug_enabled() {
|| std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG")
.ok()
.as_deref()
!= Some("1")
{
return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string()); return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string());
} }
let root = Path::new(project_path.trim()); let root = Path::new(project_path.trim());
@@ -2159,6 +2159,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
generate_platform_art_asset_with_options_at(&state.root, &prompt, &[], &options), generate_platform_art_asset_with_options_at(&state.root, &prompt, &[], &options),
) )
.await?; .await?;
emit_game_creator_manifest_invalidated(&state.root, "direct-codex-art");
let resources = bridge_art_resources( let resources = bridge_art_resources(
&state.root, &state.root,
std::slice::from_ref(&generated.asset.local_path), std::slice::from_ref(&generated.asset.local_path),
@@ -2672,6 +2672,7 @@ fn main() {
hydrate_design_agent_session, hydrate_design_agent_session,
reset_design_agent_session, reset_design_agent_session,
get_design_agent_runtime_mode, get_design_agent_runtime_mode,
is_design_agent_debug_enabled,
set_design_agent_runtime_mode, set_design_agent_runtime_mode,
debug_fast_forward_design_session, debug_fast_forward_design_session,
continue_design_agent_session, continue_design_agent_session,
@@ -1088,6 +1088,90 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
fs::remove_dir_all(ui_config_dir).ok(); fs::remove_dir_all(ui_config_dir).ok();
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn direct_image_generation_notifies_after_manifest_commit() {
let root = unique_project_path();
let config_dir = unique_project_path();
let canvas_base_url = spawn_mock_external_canvas_generation_api_server(None);
let _session = crate::platform_session::install_test_platform_session(
"direct-image-refresh-user",
"editor-runtime-key",
&canvas_base_url,
);
fs::create_dir_all(&config_dir).expect("create config directory");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" }
})
.to_string(),
)
.expect("write config");
let _config = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(&root, "direct-image-refresh", "生成图片刷新测试")
.expect("init project");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow generation");
let listener =
TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).expect("bind event receiver");
let sink = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
sink.configure(listener.local_addr().unwrap().port(), &"d".repeat(64))
.expect("configure event receiver");
let bridge = start_direct_tool_bridge(&root, false)
.await
.expect("start tool bridge");
let client = reqwest::Client::new();
let result: Value = client.post(bridge.url()).json(&serde_json::json!({
"tool": "agc_generate_image",
"arguments": { "prompt": "像素月光主角", "kind": "icon-spec", "outputPath": "assets/art-spec.png" }
})).send().await.expect("generate through bridge").json().await.expect("read tool result");
assert_eq!(result["isError"], false, "{result}");
let manifest = read_existing_manifest_for_project(&root).expect("read committed manifest");
assert!(manifest
.assets
.iter()
.any(|asset| asset.local_path == "assets/art-spec.png"));
assert!(root.join("assets/art-spec.png").is_file());
let payload = read_manifest_invalidation_relay_payload_with_deadline(&listener)
.expect("generation must notify the client");
let envelope: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&payload).expect("event envelope");
assert_eq!(
envelope.event.project_path,
fs::canonicalize(&root).unwrap().to_string_lossy()
);
assert_eq!(envelope.event.agent_id, "direct-codex-art");
let rejected: Value = client
.post(bridge.url())
.json(&serde_json::json!({
"tool": "agc_generate_image", "arguments": { "prompt": "", "kind": "icon-spec" }
}))
.send()
.await
.expect("send rejected request")
.json()
.await
.expect("read rejected result");
assert_eq!(rejected["isError"], true);
assert_eq!(
read_manifest_invalidation_relay_payload_with_deadline(&listener)
.expect_err("rejected generation must not emit a commit")
.kind(),
io::ErrorKind::TimedOut
);
drop(bridge);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_manifest() { async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_manifest() {
let root = unique_project_path(); let root = unique_project_path();
+10 -2
View File
@@ -190,6 +190,7 @@ import {
missingChatCommandArgumentMessage, missingChatCommandArgumentMessage,
projectFileActionDrafts, projectFileActionDrafts,
projectPathHasControlCharacter, projectPathHasControlCharacter,
projectPathsMatchForInvalidation,
readableArtifactsFromAgentRunTrace, readableArtifactsFromAgentRunTrace,
sortCheckpointManifestFiles, sortCheckpointManifestFiles,
summarizeAgentRunSupportFileReadDrafts, summarizeAgentRunSupportFileReadDrafts,
@@ -2192,10 +2193,17 @@ export function App({
void subscribeTauriEvent<GameCreatorManifestInvalidatedEvent>( void subscribeTauriEvent<GameCreatorManifestInvalidatedEvent>(
'game-creator-manifest-invalidated', 'game-creator-manifest-invalidated',
(event) => { (event) => {
if (event.payload.projectPath !== localProjectPathRef.current) { const activeProjectPath = localProjectPathRef.current;
if (
!activeProjectPath ||
!projectPathsMatchForInvalidation(
event.payload.projectPath,
activeProjectPath,
)
) {
return; return;
} }
void refreshManifest(event.payload.projectPath); void refreshManifest(activeProjectPath);
}, },
) )
.then((unlisten) => { .then((unlisten) => {
@@ -17,6 +17,27 @@ export function projectPathHasControlCharacter(value: string) {
}); });
} }
// 失效事件只是重读提示:匹配 Windows 的普通 / verbatim 路径后,调用方仍用当前项目路径
// 读取权威清单。此比较不解析链接,也不作为文件访问授权依据。
export function projectPathsMatchForInvalidation(
eventPath: string,
activePath: string | null,
) {
if (!eventPath || !activePath) return false;
function normalize(path: string) {
if (/^\\\\\?\\UNC\\/i.test(path)) {
path = `\\\\${path.slice(8)}`;
} else if (/^\\\\\?\\[a-z]:\\/i.test(path)) {
path = path.slice(4);
}
if (/^[a-z]:[\\/]/i.test(path) || /^\\\\[^?.\\][^\\]*\\[^\\]+/.test(path)) {
return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
}
return path;
}
return normalize(eventPath) === normalize(activePath);
}
export function isSafeProjectRelativePath(value: string) { export function isSafeProjectRelativePath(value: string) {
const path = value.trim(); const path = value.trim();
return ( return (
@@ -80,6 +80,7 @@ export {
isAbsoluteProjectPath, isAbsoluteProjectPath,
isSafeProjectRelativePath, isSafeProjectRelativePath,
projectPathHasControlCharacter, projectPathHasControlCharacter,
projectPathsMatchForInvalidation,
} from './projectPath'; } from './projectPath';
export { export {
summarizeProjectDependencyMap, summarizeProjectDependencyMap,
@@ -14,6 +14,8 @@ import type {
ClientLlmModel, ClientLlmModel,
ClientLlmModelCatalog, ClientLlmModelCatalog,
} from '../../services/clientApi'; } from '../../services/clientApi';
import { ClientAuthRequestError } from '../../services/clientApi';
import { ClientHttpTimeoutError } from '../../services/clientHttp';
import { import {
cachedLlmModelCatalog, cachedLlmModelCatalog,
refreshLlmModelCatalog, refreshLlmModelCatalog,
@@ -27,6 +29,14 @@ export type ConversationModelSelectHandle = {
/** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */ /** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */
class ModelSelectionConfigError extends Error {} class ModelSelectionConfigError extends Error {}
function modelCatalogErrorMessage(error: unknown) {
if (error instanceof ClientHttpTimeoutError)
return '模型列表请求超时,请重试';
if (error instanceof ClientAuthRequestError && error.status)
return `模型列表加载失败(HTTP ${error.status}`;
return '模型列表加载失败';
}
export function ConversationModelSelect({ export function ConversationModelSelect({
className, className,
disabled, disabled,
@@ -51,6 +61,7 @@ export function ConversationModelSelect({
const [busy, setBusy] = useState(!initialCatalog); const [busy, setBusy] = useState(!initialCatalog);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
const [manualRefreshBusy, setManualRefreshBusy] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const appliedRevisionRef = useRef<number | null>( const appliedRevisionRef = useRef<number | null>(
@@ -180,7 +191,11 @@ export function ConversationModelSelect({
); );
const syncCatalog = useCallback( const syncCatalog = useCallback(
async (showBusy: boolean) => { async (showBusy: boolean, manualRefresh = false) => {
if (manualRefresh && mountedRef.current) {
setManualRefreshBusy(true);
setNotice('正在刷新模型列表');
}
const busyToken = showBusy const busyToken = showBusy
? ++busyTokenRef.current ? ++busyTokenRef.current
: busyTokenRef.current; : busyTokenRef.current;
@@ -195,12 +210,17 @@ export function ConversationModelSelect({
try { try {
let catalog: ClientLlmModelCatalog; let catalog: ClientLlmModelCatalog;
let usingCachedCatalog = false; let usingCachedCatalog = false;
let catalogError: unknown = null;
try { try {
catalog = await refreshLlmModelCatalog(); catalog = await refreshLlmModelCatalog();
} catch { } catch (error) {
catalogError = error;
const cached = cachedLlmModelCatalog(); const cached = cachedLlmModelCatalog();
if (!cached) { if (!cached) {
if (mountedRef.current) setError('模型列表加载失败'); if (mountedRef.current) {
setError(modelCatalogErrorMessage(error));
setNotice('');
}
markReady(false); markReady(false);
return false; return false;
} }
@@ -209,10 +229,14 @@ export function ConversationModelSelect({
} }
const ready = await applyCatalog(catalog, showBusy, epochAtRequest); const ready = await applyCatalog(catalog, showBusy, epochAtRequest);
if (usingCachedCatalog && mountedRef.current) if (usingCachedCatalog && mountedRef.current)
setError('模型列表加载失败'); setError(modelCatalogErrorMessage(catalogError));
if (manualRefresh && mountedRef.current) {
setNotice(usingCachedCatalog ? '' : '模型列表已刷新');
}
return ready; return ready;
} catch (error) { } catch (error) {
if (mountedRef.current) { if (mountedRef.current) {
setNotice('');
setError( setError(
error instanceof ModelSelectionConfigError error instanceof ModelSelectionConfigError
? error.message ? error.message
@@ -230,6 +254,7 @@ export function ConversationModelSelect({
) { ) {
setBusy(false); setBusy(false);
} }
if (manualRefresh && mountedRef.current) setManualRefreshBusy(false);
} }
}, },
[applyCatalog, markReady], [applyCatalog, markReady],
@@ -375,11 +400,12 @@ export function ConversationModelSelect({
type="button" type="button"
className="conversation-model-menu-refresh" className="conversation-model-menu-refresh"
aria-label="刷新模型列表" aria-label="刷新模型列表"
disabled={disabled || busy} disabled={disabled || busy || manualRefreshBusy}
onClick={() => void syncCatalog(true)} aria-busy={manualRefreshBusy}
onClick={() => void syncCatalog(true, true)}
> >
<RefreshCcw size={13} aria-hidden="true" /> <RefreshCcw size={13} aria-hidden="true" />
<span></span> <span>{manualRefreshBusy ? '刷新中…' : '刷新模型列表'}</span>
</button> </button>
</div> </div>
) : null} ) : null}
@@ -185,6 +185,7 @@ export function DesignWorkspacePanel({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [debugPreparing, setDebugPreparing] = useState(false); const [debugPreparing, setDebugPreparing] = useState(false);
const [designDebugEnabled, setDesignDebugEnabled] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>( const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
() => new Set(), () => new Set(),
@@ -264,6 +265,16 @@ export function DesignWorkspacePanel({
void loadWorkspace({ showLoading: true, hydrateSession: true }); void loadWorkspace({ showLoading: true, hydrateSession: true });
}, [loadWorkspace]); }, [loadWorkspace]);
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
void invoke<boolean>('is_design_agent_debug_enabled')
.then(setDesignDebugEnabled)
.catch(() => setDesignDebugEnabled(false));
}, []);
useEffect(() => { useEffect(() => {
if (!canSubscribeTauriEvents() || !projectPath.trim()) { if (!canSubscribeTauriEvents() || !projectPath.trim()) {
return; return;
@@ -360,7 +371,7 @@ export function DesignWorkspacePanel({
<p>Agent </p> <p>Agent </p>
</div> </div>
<div className="design-workspace-panel__actions"> <div className="design-workspace-panel__actions">
{import.meta.env.VITE_GENARRATIVE_AGC_DESIGN_DEBUG === '1' ? ( {designDebugEnabled ? (
<button <button
type="button" type="button"
className="design-workspace-panel__refresh" className="design-workspace-panel__refresh"
@@ -8,7 +8,7 @@ import {
ProfileWalletLedgerResponse, ProfileWalletLedgerResponse,
unwrapApiResponse, unwrapApiResponse,
} from '../../../../packages/shared/src'; } from '../../../../packages/shared/src';
import { fetchClientHttp } from './clientHttp'; import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
import { captureClientError } from './errorReporting'; import { captureClientError } from './errorReporting';
import { import {
currentPlatformSessionGeneration, currentPlatformSessionGeneration,
@@ -48,8 +48,12 @@ export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
} }
async function readApiErrorMessage(response: Response, fallback: string) { async function readApiErrorMessage(
const text = await response.text(); response: Response,
fallback: string,
url: string,
) {
const text = await readClientHttpResponseText(response, { url });
if (!text.trim()) { if (!text.trim()) {
return fallback; return fallback;
} }
@@ -126,11 +130,11 @@ export async function requestClientApi<T>(
if (!response.ok) { if (!response.ok) {
captureApiErrorStatus(url, response); captureApiErrorStatus(url, response);
throw new ClientAuthRequestError( throw new ClientAuthRequestError(
await readApiErrorMessage(response, fallbackMessage), await readApiErrorMessage(response, fallbackMessage, url),
{ status: response.status }, { status: response.status },
); );
} }
const text = await response.text(); const text = await readClientHttpResponseText(response, { url });
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T); return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
} }
@@ -163,7 +167,7 @@ export async function requestClientApiBytes(
if (!response.ok) { if (!response.ok) {
captureApiErrorStatus(url, response); captureApiErrorStatus(url, response);
throw new ClientAuthRequestError( throw new ClientAuthRequestError(
await readApiErrorMessage(response, fallbackMessage), await readApiErrorMessage(response, fallbackMessage, url),
{ status: response.status }, { status: response.status },
); );
} }
@@ -0,0 +1,12 @@
import type { ProjectStartMode } from '../../app/types';
import type { HomeCreationType } from './useHomeDraftStore';
export function resolveHomeStartMode(
creationType: HomeCreationType,
planningCompletionEnabled: boolean,
): ProjectStartMode {
return creationType === 'doc' ||
(creationType === 'game' && planningCompletionEnabled)
? 'planning'
: 'direct-build';
}
@@ -20,6 +20,7 @@ import {
richTextToAttachments, richTextToAttachments,
richTextToPrompt, richTextToPrompt,
} from './components/RichInputArea/richTextToPrompt'; } from './components/RichInputArea/richTextToPrompt';
import { resolveHomeStartMode } from './homeStartMode';
import InspirationGallery from './InspirationGallery'; import InspirationGallery from './InspirationGallery';
import { import {
type HomeCreationType, type HomeCreationType,
@@ -145,14 +146,18 @@ export default function HomeView({
(state) => state.setRichText, (state) => state.setRichText,
); );
const [homeCreationBusy, setHomeCreationBusy] = useState(false); const [homeCreationBusy, setHomeCreationBusy] = useState(false);
const [planningCompletionEnabled, setPlanningCompletionEnabled] =
useState(false);
const homeCreationBusyRef = useRef(false); const homeCreationBusyRef = useRef(false);
const activeCreationType = const activeCreationType =
HOME_CREATION_TYPE_ITEMS.find( HOME_CREATION_TYPE_ITEMS.find(
(item) => item.creationType === homeCreationType, (item) => item.creationType === homeCreationType,
) ?? HOME_CREATION_TYPE_ITEMS[0]!; ) ?? HOME_CREATION_TYPE_ITEMS[0]!;
// 做方案走立项策划链路做游戏 / 做素材维持既有的直接开建路由 // 做方案始终走立项策划链路做游戏勾选“策划补全”时复用该链路,做素材保持直接开建。
const startMode: ProjectStartMode = const startMode = resolveHomeStartMode(
homeCreationType === 'doc' ? 'planning' : 'direct-build'; homeCreationType,
planningCompletionEnabled,
);
async function createFromHome() { async function createFromHome() {
if (homeCreationBusyRef.current || creationBusy) { if (homeCreationBusyRef.current || creationBusy) {
@@ -241,7 +246,12 @@ export default function HomeView({
type="button" type="button"
key={item.creationType} key={item.creationType}
aria-pressed={isActive} aria-pressed={isActive}
onClick={() => setHomeCreationType(item.creationType)} onClick={() => {
setHomeCreationType(item.creationType);
if (item.creationType !== 'game') {
setPlanningCompletionEnabled(false);
}
}}
> >
<CreationTypeIcon size={14} aria-hidden="true" /> <CreationTypeIcon size={14} aria-hidden="true" />
{item.label} {item.label}
@@ -262,8 +272,21 @@ export default function HomeView({
}} }}
> >
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)"> <div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-3">
<UploadButton /> <UploadButton />
{homeCreationType === 'game' ? (
<label className="inline-flex cursor-pointer items-center gap-1.5 whitespace-nowrap">
<input
type="checkbox"
checked={planningCompletionEnabled}
onChange={(event) =>
setPlanningCompletionEnabled(event.target.checked)
}
disabled={homeCreationBusy || creationBusy}
/>
<span></span>
</label>
) : null}
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
<ConversationModelSelect <ConversationModelSelect
@@ -1212,10 +1212,10 @@ function createProjectSupervisorRuntimeHarness({
}, },
}); });
}, },
emitManifestInvalidated(agentId: string) { emitManifestInvalidated(agentId: string, eventProjectPath = projectPath) {
manifestInvalidatedHandler?.({ manifestInvalidatedHandler?.({
payload: { payload: {
projectPath, projectPath: eventProjectPath,
agentId, agentId,
}, },
}); });
@@ -2257,6 +2257,7 @@ export function registerHomeProjectCreationTests() {
it('refreshes Direct Codex art commits while the turn is still running and after a later failure', async () => { it('refreshes Direct Codex art commits while the turn is still running and after a later failure', async () => {
const projectPath = const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\live-direct-art'; 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\live-direct-art';
const eventProjectPath = `\\\\?\\${projectPath}`;
const manifest = createGameCreationAppManifest( const manifest = createGameCreationAppManifest(
'live-direct-art', 'live-direct-art',
'直连美术实时刷新', '直连美术实时刷新',
@@ -2347,7 +2348,7 @@ export function registerHomeProjectCreationTests() {
taskId: 'direct-codex-art-art-spritesheet', taskId: 'direct-codex-art-art-spritesheet',
}, },
}, },
]; ].map((asset) => ({ ...asset, category: 'ui-interaction' as const }));
for (let index = 0; index < committedAssets.length; index += 1) { for (let index = 0; index < committedAssets.length; index += 1) {
const refreshCountBeforeEvent = invoke.mock.calls.filter( const refreshCountBeforeEvent = invoke.mock.calls.filter(
@@ -2357,8 +2358,12 @@ export function registerHomeProjectCreationTests() {
...currentManifest, ...currentManifest,
assets: committedAssets.slice(0, index + 1), assets: committedAssets.slice(0, index + 1),
}; };
runtimeHarness.setProjectRevision(index + 1);
act(() => { act(() => {
runtimeHarness.emitManifestInvalidated('direct-codex-art'); runtimeHarness.emitManifestInvalidated(
'direct-codex-art',
eventProjectPath,
);
}); });
await waitFor(() => { await waitFor(() => {
expect( expect(
@@ -2373,6 +2378,12 @@ export function registerHomeProjectCreationTests() {
), ),
).toHaveLength(1); ).toHaveLength(1);
} }
await openResourceBookCategory('UI 交互');
expect(getResourceSelectButton('art-spec.png')).not.toBeNull();
expect(
getResourceSelectButton('direct-game-background.png'),
).not.toBeNull();
expect(getResourceSelectButton('art-spritesheet.png')).not.toBeNull();
const refreshCountBeforeFailure = invoke.mock.calls.filter( const refreshCountBeforeFailure = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest', ([command]) => command === 'get_local_game_manifest',
@@ -10,6 +10,12 @@ import {
getClientAuthRefreshOperation, getClientAuthRefreshOperation,
refreshClientAuthAccessToken, refreshClientAuthAccessToken,
} from '../src/services/clientAuth'; } from '../src/services/clientAuth';
import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp';
import {
cachedLlmModelCatalog,
refreshLlmModelCatalog,
resetLlmModelCatalogCacheForTest,
} from '../src/services/llmModelCatalog';
import { import {
beginPlatformSessionTransition, beginPlatformSessionTransition,
commitAuthenticatedPlatformSession, commitAuthenticatedPlatformSession,
@@ -18,6 +24,10 @@ import {
} from '../src/services/platformSession'; } from '../src/services/platformSession';
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
vi.mock(
'../../../packages/shared/src',
() => import('../../../packages/shared/src/http'),
);
vi.mock('../src/services/errorReporting', () => ({ vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(), captureClientError: vi.fn(),
})); }));
@@ -29,6 +39,7 @@ const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status }); new Response(JSON.stringify(value), { status });
beforeEach(async () => { beforeEach(async () => {
resetLlmModelCatalogCacheForTest();
resetPlatformSessionStateForTests(); resetPlatformSessionStateForTests();
window.localStorage.clear(); window.localStorage.clear();
nativeInvoke.mockClear(); nativeInvoke.mockClear();
@@ -42,12 +53,60 @@ beforeEach(async () => {
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers();
resetLlmModelCatalogCacheForTest();
resetPlatformSessionStateForTests(); resetPlatformSessionStateForTests();
window.localStorage.clear(); window.localStorage.clear();
delete window.__TAURI__; delete window.__TAURI__;
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it.each([200, 503])(
'模型目录 HTTP %s 响应体卡住后超时,保留缓存且能再次刷新',
async (status) => {
vi.useFakeTimers();
const previous = { ...catalog, defaultModelId: 'quality', revision: 1 };
const updated = {
defaultModelId: 'fast',
models: [{ id: 'fast', displayName: '快速' }],
revision: 2,
};
let body!: ReadableStreamDefaultController<Uint8Array>;
const stalledResponse = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
body = controller;
},
}),
{ status },
);
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(json(previous))
.mockResolvedValueOnce(stalledResponse)
.mockResolvedValueOnce(json(updated));
await expect(refreshLlmModelCatalog()).resolves.toEqual(previous);
let failure: unknown;
const pending = refreshLlmModelCatalog().catch((error: unknown) => {
failure = error;
});
try {
await vi.advanceTimersByTimeAsync(CLIENT_HTTP_DEFAULT_TIMEOUT_MS);
expect(failure).toMatchObject({ code: 'CLIENT_HTTP_TIMEOUT' });
await pending;
expect(cachedLlmModelCatalog()).toEqual(previous);
await expect(refreshLlmModelCatalog()).resolves.toEqual(updated);
expect(fetch).toHaveBeenCalledTimes(3);
} finally {
// 迟到的响应不能在新刷新完成后覆盖缓存,同时释放测试流。
body.enqueue(new TextEncoder().encode(JSON.stringify(previous)));
body.close();
await pending;
}
expect(cachedLlmModelCatalog()).toEqual(updated);
},
);
it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => { it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => {
let refreshCalls = 0; let refreshCalls = 0;
let modelCalls = 0; let modelCalls = 0;
@@ -16,11 +16,36 @@ import {
ConversationModelSelect, ConversationModelSelect,
type ConversationModelSelectHandle, type ConversationModelSelectHandle,
} from '../src/features/project-workspace/ConversationModelSelect'; } from '../src/features/project-workspace/ConversationModelSelect';
import { loadClientLlmModels } from '../src/services/clientApi'; import {
ClientAuthRequestError,
type ClientLlmModelCatalog,
loadClientLlmModels,
} from '../src/services/clientApi';
import { ClientHttpTimeoutError } from '../src/services/clientHttp';
import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog'; import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog';
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() })); vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() })); const MockClientAuthRequestError = vi.hoisted(
() =>
class MockClientAuthRequestError extends Error {
readonly status: number | null;
readonly networkError: boolean;
constructor(
message: string,
options: { status?: number | null; networkError?: boolean } = {},
) {
super(message);
this.status = options.status ?? null;
this.networkError = options.networkError ?? false;
}
},
);
vi.mock('../src/services/clientApi', () => ({
ClientAuthRequestError: MockClientAuthRequestError,
loadClientLlmModels: vi.fn(),
}));
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
const invoke = vi.fn(); const invoke = vi.fn();
let savedModelId = 'quality'; let savedModelId = 'quality';
let savedModelIsDefault = true; let savedModelIsDefault = true;
@@ -54,6 +79,122 @@ beforeEach(() => {
}); });
afterEach(cleanup); afterEach(cleanup);
async function renderReadyModelMenu() {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
});
return onReady;
}
test('shows manual refresh progress immediately without clearing the selected model', async () => {
const onReady = await renderReadyModelMenu();
let resolveRefresh!: (catalog: ClientLlmModelCatalog) => void;
vi.mocked(loadClientLlmModels).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
const refreshButton = screen.getByRole('button', { name: '刷新模型列表' });
expect(refreshButton.textContent).toBe('刷新中…');
expect(refreshButton).toHaveProperty('disabled', true);
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('高质量');
expect(onReady).toHaveBeenLastCalledWith(false);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
await act(async () => {
resolveRefresh({
defaultModelId: 'quality',
models: [{ id: 'quality', displayName: '高质量' }],
revision: 1,
});
});
expect(screen.getByRole('status').textContent).toBe('模型列表已刷新');
expect(savedModelId).toBe('quality');
expect(onReady).toHaveBeenLastCalledWith(true);
});
test('confirms a manual refresh even when the catalog revision is unchanged', async () => {
await renderReadyModelMenu();
expect(screen.queryByRole('status')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByText('模型列表已刷新');
expect(loadClientLlmModels).toHaveBeenCalledTimes(3);
expect(
screen
.getByRole('option', { name: /高质量/ })
.getAttribute('aria-selected'),
).toBe('true');
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
expect(screen.getByRole('button', { name: '刷新模型列表' })).toHaveProperty(
'disabled',
false,
);
});
test.each([
[
'HTTP 404',
new ClientAuthRequestError('private server detail', { status: 404 }),
'模型列表加载失败(HTTP 404',
],
[
'HTTP 401',
new ClientAuthRequestError('private server detail', { status: 401 }),
'模型列表加载失败(HTTP 401',
],
[
'timeout',
new ClientHttpTimeoutError('https://private.example/models', 15000),
'模型列表请求超时,请重试',
],
['unknown', new Error('private server detail'), '模型列表加载失败'],
])(
'reports a safe %s failure with cached models and permits retry without claiming success',
async (_label, failure, message) => {
const onReady = await renderReadyModelMenu();
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByText('模型列表已刷新');
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(failure);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toBe(message),
);
expect(screen.queryByText('模型列表已刷新')).toBeNull();
expect(screen.queryByText('正在刷新模型列表')).toBeNull();
expect(document.body.textContent).not.toContain('private');
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('高质量');
expect(
screen
.getByRole('option', { name: /高质量/ })
.getAttribute('aria-selected'),
).toBe('true');
expect(savedModelId).toBe('quality');
expect(onReady).toHaveBeenLastCalledWith(true);
expect(screen.getByRole('button', { name: '刷新模型列表' })).toHaveProperty(
'disabled',
false,
);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByText('模型列表已刷新');
expect(screen.queryByRole('alert')).toBeNull();
},
);
test('only displays aliases and persists selection through the native command', async () => { test('only displays aliases and persists selection through the native command', async () => {
const onReady = vi.fn(); const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />); render(<ConversationModelSelect disabled={false} onReady={onReady} />);
@@ -23,9 +23,11 @@ afterEach(() => {
}); });
it('prepares debug fixtures from the header and refreshes the phase and files without a manual refresh', async () => { it('prepares debug fixtures from the header and refreshes the phase and files without a manual refresh', async () => {
vi.stubEnv('VITE_GENARRATIVE_AGC_DESIGN_DEBUG', '1');
let prepared = false; let prepared = false;
const invoke = vi.fn(async (command: string) => { const invoke = vi.fn(async (command: string) => {
if (command === 'is_design_agent_debug_enabled') {
return true;
}
if (command === 'debug_fast_forward_design_session') { if (command === 'debug_fast_forward_design_session') {
prepared = true; prepared = true;
return { activeRuntime: 'design' }; return { activeRuntime: 'design' };
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { resolveHomeStartMode } from '../src/view/home/homeStartMode';
describe('AGC 首页启动模式', () => {
it('做游戏勾选策划补全时进入策划 runtime', () => {
expect(resolveHomeStartMode('game', true)).toBe('planning');
});
it('做游戏未勾选策划补全时保持直接创作', () => {
expect(resolveHomeStartMode('game', false)).toBe('direct-build');
});
it('做方案始终进入策划 runtime,其他入口不受影响', () => {
expect(resolveHomeStartMode('doc', false)).toBe('planning');
expect(resolveHomeStartMode('art', true)).toBe('direct-build');
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { projectPathsMatchForInvalidation } from '../src/features/project-summary/projectPath';
describe('项目刷新事件路径', () => {
it.each([
['C:\\Projects\\game', '\\\\?\\C:\\Projects\\game'],
['\\\\?\\C:\\Projects\\game', 'c:/Projects/game/'],
['\\\\server\\share\\game', '\\\\?\\UNC\\server\\share\\game'],
['/tmp/game', '/tmp/game'],
])('识别同一项目 %s 与 %s', (eventPath, activePath) => {
expect(projectPathsMatchForInvalidation(eventPath, activePath)).toBe(true);
});
it.each([
['\\\\?\\C:\\Projects\\game-other', 'C:\\Projects\\game'],
['\\\\?\\C:\\Projects\\game\\child', 'C:\\Projects\\game'],
['\\\\?\\UNC\\other\\share\\game', '\\\\server\\share\\game'],
['\\\\.\\C:\\Projects\\game', 'C:\\Projects\\game'],
['/tmp/Game', '/tmp/game'],
['', ''],
['C:\\Projects\\game', null],
])('拒绝其它项目或空作用域 %s 与 %s', (eventPath, activePath) => {
expect(projectPathsMatchForInvalidation(eventPath, activePath)).toBe(false);
});
});
@@ -0,0 +1,20 @@
Version: 1
Status: active
Date: 2026-09-15
Parent Spec: 【里程碑】AGC首页策划补全入口-2026-09-15.md
## 修改顺序
1.`view/home/index.tsx` 增加本地复选框状态与入口切换清理。
2. 将游戏勾选状态映射到既有 `ProjectStartMode`
3. 增加启动模式纯函数测试,覆盖模式分流;首页显示边界和切换清理作为后续组件测试补充项。
## 验证
- AGC 首页相关定向测试。
- AGC 前端 typecheck。
- `npm run check:encoding``git diff --check`
## 当前验证边界
本次已交付测试覆盖 `planning` / `direct-build` 模式分流;“策划补全”复选框的显示边界及切换创作类型后的状态清理尚未有组件级自动化测试,需后续补充 `HomeView` 测试时完成。
@@ -0,0 +1,20 @@
Version: 1
Status: active
Date: 2026-09-15
Parent Spec: AGC 首页与 Agent Runtime 入口
## 范围
在首页“做游戏”输入框下增加“策划补全”复选框;勾选后复用现有 `planning` 启动模式进入策划 Agent Runtime。
## 验收标准
- 仅“做游戏”显示复选框。
- 勾选时提交 `planning`,未勾选时提交 `direct-build`
- “做方案”原有 `planning` 行为保持不变。
- 切换到其它创作类型时清除游戏专属勾选状态。
## 不做项
- 不新增 runtime 类型、后端接口或持久化字段。
- 不改变现有策划 runtime 内部流程。
@@ -1,5 +1,9 @@
# 踩坑与排障记录 # 踩坑与排障记录
## Windows 已登记生图资产未刷新
Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\` / `\\?\UNC\`,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 `agc_generate_image` 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。
## 2026-09-14 严格 IPC 桩缺登记新命令时,症状可能是「unhandled rejection + 不相干的提示断言」,而不是同一处报错 ## 2026-09-14 严格 IPC 桩缺登记新命令时,症状可能是「unhandled rejection + 不相干的提示断言」,而不是同一处报错
- **现象**`ProjectDevelopmentView` 新增「项目打开时读生成任务账本」(`list_local_project_asset_generations`)后,两个**别的关注点**的用例同时红:`resourceCanvasManualLayout.test.tsx``AssertionError: expected [ Array(1) ] to deeply equal []`(严格桩把新命令记进 `unexpectedCommands`),并伴随 7 条 `Unhandled Rejection: TypeError: Cannot read properties of undefined (reading 'map')``appSurface/project-development.suite.ts` 的「布局读时提示」用例则因为新命令被当成 unexpected invoke 抛错、触发了新的提示条,导致 `queryBySelector('.game-resource-live-notice')` 断言失败。 - **现象**`ProjectDevelopmentView` 新增「项目打开时读生成任务账本」(`list_local_project_asset_generations`)后,两个**别的关注点**的用例同时红:`resourceCanvasManualLayout.test.tsx``AssertionError: expected [ Array(1) ] to deeply equal []`(严格桩把新命令记进 `unexpectedCommands`),并伴随 7 条 `Unhandled Rejection: TypeError: Cannot read properties of undefined (reading 'map')``appSurface/project-development.suite.ts` 的「布局读时提示」用例则因为新命令被当成 unexpected invoke 抛错、触发了新的提示条,导致 `queryBySelector('.game-resource-live-notice')` 断言失败。
@@ -22,6 +26,11 @@
- **验证**`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 新增两条——「探测脚本使用 netstat 且不再出现 Get-NetTCPConnection」「命令行按 PID 缓存后随请求下发、TTL 过期即失效」;定向 vitest 55 passed。本机实测:不含 SpacetimeDB 端口的探测 368 ms(原约 22 秒)、含 SpacetimeDB 端口 3.8 秒、命中缓存 368 ms;`npm run agc:serve``starting backend stack``backend ready` 由约 80 秒降到 16.7 秒(其中归属校验只占 4.4 秒,其余是 SpacetimeDB + api-server 的真实启动时间)。 - **验证**`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 新增两条——「探测脚本使用 netstat 且不再出现 Get-NetTCPConnection」「命令行按 PID 缓存后随请求下发、TTL 过期即失效」;定向 vitest 55 passed。本机实测:不含 SpacetimeDB 端口的探测 368 ms(原约 22 秒)、含 SpacetimeDB 端口 3.8 秒、命中缓存 368 ms;`npm run agc:serve``starting backend stack``backend ready` 由约 80 秒降到 16.7 秒(其中归属校验只占 4.4 秒,其余是 SpacetimeDB + api-server 的真实启动时间)。
- **残留**:这台机器上首次 WMI 调用本身仍是秒级(曾见 18 秒),所以「新 SpacetimeDB PID 的第一次探测」仍可能多花几秒;命令行在进程存活期内不变,TTL 只用来限制 PID 复用造成的误判窗口。 - **残留**:这台机器上首次 WMI 调用本身仍是秒级(曾见 18 秒),所以「新 SpacetimeDB PID 的第一次探测」仍可能多花几秒;命令行在进程存活期内不变,TTL 只用来限制 PID 复用造成的误判窗口。
- **关联**`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``readWindowsPortOwnerIdentities`)、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts``apps/ai-game-creator-shell/scripts/dev-windows-process.mjs`(退出清理仍走整份 `Win32_Process` 快照,自带 1 秒缓存,不在本次范围)。 - **关联**`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``readWindowsPortOwnerIdentities`)、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts``apps/ai-game-creator-shell/scripts/dev-windows-process.mjs`(退出清理仍走整份 `Win32_Process` 快照,自带 1 秒缓存,不在本次范围)。
## 2026-09-15 AGC JSON API 的响应体也必须有等待上限
- `fetchClientHttp` 的超时只覆盖请求到响应头返回;随后直接等待 `response.text()` 仍可能无限挂起。模型目录共用一个在途 Promise,响应体卡住会使后续刷新复用同一挂起请求、选择器持续忙碌。
- 成功 JSON 与错误响应体均复用 `readClientHttpResponseText` 的 15 秒上限;超时后保留最后一次有效目录并释放在途请求,手动重试重新发起请求。迟到的响应不得覆盖重试获得的新目录。
- 排查时区分接口未挂载(404)、未授权(401)、网络或响应体超时以及刷新无变化但缺少反馈;不能仅凭客户端启动 IPC 回退警告判断刷新失败原因。
## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖 ## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖
@@ -7,6 +7,8 @@
- `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。 - `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。
- `GET /api/llm/models` 返回启用项的 `id/displayName``defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。 - `GET /api/llm/models` 返回启用项的 `id/displayName``defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。 - 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。
- 手动刷新立即显示进行中状态;真实刷新成功后显示完成反馈,即使 `revision` 未变化也有反馈。失败沿用有效缓存时仍显示失败,不能报告刷新成功;HTTP 状态和超时使用可辨认的提示。
- 模型目录与其它客户端 JSON API 的成功、失败响应体读取均复用 `readClientHttpResponseText` 的 15 秒上限;响应头已返回但响应体卡住时必须结束本次等待、释放目录在途请求并允许重试,迟到的响应不得覆盖新目录。
- AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。 - AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId``selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。 - 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId``selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
- `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。 - `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。
@@ -19,4 +21,5 @@
- 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。 - 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。
- 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。 - 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。
- 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。 - 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。
- 响应体超时保留有效缓存、再次刷新重新请求、迟到响应不覆盖新目录;手动刷新进行中、同版本成功与缓存兜底失败反馈。
- AGC/admin-web 类型检查与定向测试、编码检查、Rust 定向检查、schema 一致性与 diff 检查。 - AGC/admin-web 类型检查与定向测试、编码检查、Rust 定向检查、schema 一致性与 diff 检查。
@@ -1167,7 +1167,7 @@ game-project/
- `.agent/manifest.json` 的存储写边界使用同目录持久文件锁跨线程、跨进程串行化;锁必须覆盖旧 manifest 读取、不可变版本前缀校验、临时文件安装和安装后回读一致性校验。锁文件拒绝符号链接、非普通文件和异常所有权 / 硬链接;Windows 使用不共享写句柄,Unix 使用 `O_NOFOLLOW + flock`。旧快照在新版本安装后只能被拒绝,不能覆盖已追加版本。 - `.agent/manifest.json` 的存储写边界使用同目录持久文件锁跨线程、跨进程串行化;锁必须覆盖旧 manifest 读取、不可变版本前缀校验、临时文件安装和安装后回读一致性校验。锁文件拒绝符号链接、非普通文件和异常所有权 / 硬链接;Windows 使用不共享写句柄,Unix 使用 `O_NOFOLLOW + flock`。旧快照在新版本安装后只能被拒绝,不能覆盖已追加版本。
- 后台 Agent 的 manifest 变化以共用 Runtime 状态投影 / 终态 emitter 作为失效因果点:`game-creator-agent-runtime-update` 的 Rust / TypeScript DTO 固定携带 `manifestInvalidated`,且 App 必须在 Supervisor、selected agent、session 和 run 身份的任何 early return 之前处理失效。GUI 进程内 Runtime 直接发该事件;External Runner 是独立进程、没有 GUI `AppHandle`,因此 Runner 协议 v5 的 `runner.attach_gui_owner` 必须登记 GUI 创建的随机 loopback 端口和 64 位随机令牌,Runner 的同一 emitter 通过受令牌保护的短连接转发 `game-creator-manifest-invalidated`。两条路径都只传项目路径与 Agent 身份,不复制 manifest,也不靠轮询补偿。 - 后台 Agent 的 manifest 变化以共用 Runtime 状态投影 / 终态 emitter 作为失效因果点:`game-creator-agent-runtime-update` 的 Rust / TypeScript DTO 固定携带 `manifestInvalidated`,且 App 必须在 Supervisor、selected agent、session 和 run 身份的任何 early return 之前处理失效。GUI 进程内 Runtime 直接发该事件;External Runner 是独立进程、没有 GUI `AppHandle`,因此 Runner 协议 v5 的 `runner.attach_gui_owner` 必须登记 GUI 创建的随机 loopback 端口和 64 位随机令牌,Runner 的同一 emitter 通过受令牌保护的短连接转发 `game-creator-manifest-invalidated`。两条路径都只传项目路径与 Agent 身份,不复制 manifest,也不靠轮询补偿。
- Direct Codex 不伪造普通 Agent Runtime state。每张平台美术在本地文件与 manifest 提交成功后,统一通过 standalone `game-creator-manifest-invalidated` 发送 `projectPath + direct-codex-art`;只读恢复的已付费源图同样在 `register_local_asset_at` 成功后发送,下载、解码、文件写入或登记失败时不得发送成功失效。前端仍把 `game-creator-agent-progress` 仅用于进度文案;Direct Codex 整体命令成功、失败或超时 reject 后都追加一次 manifest 最终对账,只有完整成功才启动本地预览。 - Direct Codex 不伪造普通 Agent Runtime state。每张平台美术在本地文件与 manifest 提交成功后,统一通过 standalone `game-creator-manifest-invalidated` 发送 `projectPath + direct-codex-art`普通 `agc_generate_image` 同样在生成通道成功返回后、工具结果组装前发出通知,不能只覆盖标准美术包。只读恢复的已付费源图同样在 `register_local_asset_at` 成功后发送,下载、解码、文件写入或登记失败时不得发送成功失效。失效事件匹配当前项目时统一 Windows 盘符、UNC 与对应 verbatim 前缀的写法,实际重读始终使用当前项目保存的路径;该比较仅用于刷新提示,不替代后端路径与权限校验。前端仍把 `game-creator-agent-progress` 仅用于进度文案;Direct Codex 整体命令成功、失败或超时 reject 后都追加一次 manifest 最终对账,只有完整成功才启动本地预览。
- App 收到当前项目的 Runtime / relay 失效后重新调用 `get_local_game_manifest`。重读按项目 single-flight 合并事件风暴;读取中再到达失效只追加一轮串行重读,不并发提交同项目响应。应用结果同时校验组件仍挂载、当前项目路径和项目 scope version;项目切换、组件卸载或旧 scope 的迟到响应不得覆盖新项目。Project Supervisor 对外发布前以“revision 前读 -> manifest -> revision 后读”取得一致快照,再通过 `onManifestChange(projectPath, manifest, metadata)` 携带 `projectId + revision + source`;启动器按 `projectPath + projectId` 只接受更高 revision,同 revision 只接受内容一致的重复,旧轮询和同 revision 分叉都不得覆盖。资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。集成测试记录“事件未重新打开项目”的调用基线前,必须先等待项目写入最近列表后触发的只读目录状态刷新完成,不能把这项合法后台检查误算成失效事件副作用。 - App 收到当前项目的 Runtime / relay 失效后重新调用 `get_local_game_manifest`。重读按项目 single-flight 合并事件风暴;读取中再到达失效只追加一轮串行重读,不并发提交同项目响应。应用结果同时校验组件仍挂载、当前项目路径和项目 scope version;项目切换、组件卸载或旧 scope 的迟到响应不得覆盖新项目。Project Supervisor 对外发布前以“revision 前读 -> manifest -> revision 后读”取得一致快照,再通过 `onManifestChange(projectPath, manifest, metadata)` 携带 `projectId + revision + source`;启动器按 `projectPath + projectId` 只接受更高 revision,同 revision 只接受内容一致的重复,旧轮询和同 revision 分叉都不得覆盖。资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。集成测试记录“事件未重新打开项目”的调用基线前,必须先等待项目写入最近列表后触发的只读目录状态刷新完成,不能把这项合法后台检查误算成失效事件副作用。
- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer、task flow 或对应任务环。前端收到截断 DTO 时只剔除 `producerAssignments``taskFlows` 与对应 `cyclicTaskIds`Rust 根据当前 manifest、精确资源引用和仍可信任务深度下限返回的 `dependencyDepths` 继续保留,前端只校验资源仍存在且深度为非负安全整数,不得自行重算或压平权威深度。精确引用边、reference connection index、`cyclicResourceIds` 与 unresolved references 同样继续保留。 - `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer、task flow 或对应任务环。前端收到截断 DTO 时只剔除 `producerAssignments``taskFlows` 与对应 `cyclicTaskIds`Rust 根据当前 manifest、精确资源引用和仍可信任务深度下限返回的 `dependencyDepths` 继续保留,前端只校验资源仍存在且深度为非负安全整数,不得自行重算或压平权威深度。精确引用边、reference connection index、`cyclicResourceIds` 与 unresolved references 同样继续保留。
-- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦按稳定 `resourceId` 驱动焦点状态:仅 `null -> id``idA -> idB` 聚焦详情 region,同一 ID 的 manifest 重投影不得抢走音频、视频、链接或关闭按钮焦点;显式收起和 Escape 恢复画布滚动并优先聚焦原触发卡片。聚焦资源被删除时清理 stale focused / selected ID,关闭详情并把焦点落到资源搜索框;项目切换或运行视图切换清除旧恢复意图,不得恢复旧项目卡片。橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。 -- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦按稳定 `resourceId` 驱动焦点状态:仅 `null -> id``idA -> idB` 聚焦详情 region,同一 ID 的 manifest 重投影不得抢走音频、视频、链接或关闭按钮焦点;显式收起和 Escape 恢复画布滚动并优先聚焦原触发卡片。聚焦资源被删除时清理 stale focused / selected ID,关闭详情并把焦点落到资源搜索框;项目切换或运行视图切换清除旧恢复意图,不得恢复旧项目卡片。橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。

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