From 77892a89300aa843d201c1b6156c25c4b556c6ae Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 11 Sep 2026 16:31:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E4=B8=8E=E5=AF=B9=E8=AF=9D=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=80=81=E8=87=AA=E5=8A=A8=E7=BB=AD=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一模型等API的401续期并同步Rust和Runner会话 补齐DirectProject鉴权失败续期与同回合重试 保留鉴权失败提示并阻止账号切换后的旧请求重发 补充续期成功失败并发及权限边界测试 同步客户端实施文档与认证排障记忆 --- apps/ai-game-creator-shell/src/App.tsx | 51 +++- .../src/services/clientApi.ts | 53 ++-- .../tests/appSurface/home.suite.ts | 247 ++++++++++-------- .../tests/clientApi.test.ts | 146 +++++++++++ docs/project-memory/shared-memory/pitfalls.md | 5 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 + 6 files changed, 384 insertions(+), 124 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/clientApi.test.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9f53b0868..a1d2230a3 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -245,6 +245,10 @@ import { captureAgentRuntimeError, invokeDiagnostic, } from './services/errorReporting'; +import { + currentPlatformSessionGeneration, + requestPlatformSessionRefresh, +} from './services/platformSession'; import type { HomeCreationType } from './view/home'; import { type ProjectAgentResultSummary, @@ -258,6 +262,35 @@ const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = 'direct-codex-turn-already-running:'; +function isDirectCodexAuthenticationRequired(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('authentication-required') || + message.includes('codex-app-server-error:unauthorized') || + /kind=codex-app-server-unauthorized(?=\s|$)/.test(message) || + message.includes('登录已失效') + ); +} + +async function withDirectCodexSessionRefresh(operation: () => Promise) { + const generation = currentPlatformSessionGeneration(); + try { + return await operation(); + } catch (error) { + if (!isDirectCodexAuthenticationRequired(error)) throw error; + if (currentPlatformSessionGeneration() !== generation) throw error; + const refresh = await requestPlatformSessionRefresh(); + if (refresh.status === 'failed') throw error; + if ( + refresh.status !== 'refreshed' || + currentPlatformSessionGeneration() !== refresh.generation + ) { + throw new Error('登录账号已变化,原对话请求已停止'); + } + return operation(); + } +} + const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([ 'accepted', 'running', @@ -5605,10 +5638,20 @@ export function App({ if (attachments?.length) { directTurnInput.attachments = attachments; } - const reply = await directInvoke( - 'chat_with_game_creator_direct_codex', - directTurnInput, - ); + const reply = await withDirectCodexSessionRefresh(() => { + // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。 + activeDirectCodexTurnRef.current = { + projectPath: directProjectPath, + turnId: clientTurnId, + lastSequence: -1, + receivedDirectUpdate: false, + }; + setDirectCodexStatus('accepted'); + return directInvoke( + 'chat_with_game_creator_direct_codex', + directTurnInput, + ); + }); try { await persistDirectAssistantMessage(reply); } catch (error) { diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 3ff339a6b..72ce5b077 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -10,6 +10,10 @@ import { } from '../../../../packages/shared/src'; import { fetchClientHttp } from './clientHttp'; import { captureClientError } from './errorReporting'; +import { + currentPlatformSessionGeneration, + requestPlatformSessionRefresh, +} from './platformSession'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; @@ -84,23 +88,40 @@ export async function requestClientApi( fallbackMessage: string, options: { skipAuth?: boolean } = {}, ) { - const headers = new Headers(init.headers); - headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); - if (!options.skipAuth) { - const token = getStoredAuthAccessToken(); - if (token) { - headers.set('Authorization', `Bearer ${token}`); + const generation = currentPlatformSessionGeneration(); + const request = async () => { + const headers = new Headers(init.headers); + headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); + if (!options.skipAuth) { + const token = getStoredAuthAccessToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + } + try { + return await fetchClientHttp(url, { + ...init, + credentials: 'same-origin', + headers, + }); + } catch (error) { + throw apiNetworkError(url, error); + } + }; + + let response = await request(); + // Access tokens are short lived. Refresh the cookie-backed session once and + // retry the original request so callers do not need to handle token expiry. + if (!options.skipAuth && response.status === 401) { + if (currentPlatformSessionGeneration() === generation) { + const refresh = await requestPlatformSessionRefresh(); + if ( + refresh.status === 'refreshed' && + currentPlatformSessionGeneration() === refresh.generation + ) { + response = await request(); + } } - } - let response: Response; - try { - response = await fetchClientHttp(url, { - ...init, - credentials: 'same-origin', - headers, - }); - } catch (error) { - throw apiNetworkError(url, error); } if (!response.ok) { captureApiErrorStatus(url, response); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e83a74396..ccdddba87 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2200,116 +2200,155 @@ export function registerHomeProjectCreationTests() { ); }); - it('shows a readable reason when the direct Codex turn is rejected', async () => { - const projectPath = - 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\failed-direct-project'; - const manifest = createGameCreationAppManifest( - 'failed-direct-project', - '直连失败项目', - ); - const persistedMessages: Array> = []; - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'get_local_game_manifest') { - expect(args).toEqual({ projectPath }); - return manifest; + it.each([false, true])( + 'recovers a direct Codex auth failure or preserves its readable reason (refresh=%s)', + async (refreshSucceeds) => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + if (String(input) === '/api/auth/refresh') { + return new Response( + JSON.stringify({ token: 'refreshed-direct-token' }), + { + status: refreshSucceeds ? 200 : 401, + }, + ); } - if (command === 'read_local_conversation') { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; + if (String(input) === '/api/auth/me') { + return new Response(JSON.stringify({ user: testAuthUser })); } - if (command === 'append_local_permission_log') { - return {}; - } - if (command === 'read_project_permission_policy') { - return { - path: '.agent/policy.json', - policy: { deniedCommands: [], confirmCommands: [] }, - }; - } - if (command === 'append_local_conversation_message') { - const message = args?.message as Record; - const messageId = String(args?.messageId ?? ''); - if ( - !messageId || - !persistedMessages.some( - (candidate) => candidate.messageId === messageId, - ) - ) { - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - ...message, - messageId, - updatedAt: Number( - message.updatedAt ?? persistedMessages.length + 1, - ), - }); + throw new Error(`unexpected fetch ${String(input)}`); + }); + let directAttempts = 0; + const expectedReply = refreshSucceeds + ? '续期后对话完成' + : '陶泥儿智能创作 鉴权失败,请重新登录后重试'; + const projectPath = `C:\\Users\\tester\\Documents\\Genarrative GameAgent\\failed-direct-project-${refreshSucceeds}`; + const manifest = createGameCreationAppManifest( + 'failed-direct-project', + '直连失败项目', + ); + const persistedMessages: Array> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_manifest') { + expect(args).toEqual({ projectPath }); + return manifest; } - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'chat_with_game_creator_direct_codex') { - throw new Error('codex-app-server-error:unauthorized'); - } - throw new Error(`unexpected invoke ${command}`); - }, - ); - window.__TAURI__ = { core: { invoke } }; + if (command === 'read_local_conversation') { + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [...persistedMessages], + }; + } + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { deniedCommands: [], confirmCommands: [] }, + }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as Record; + const messageId = String(args?.messageId ?? ''); + if ( + !messageId || + !persistedMessages.some( + (candidate) => candidate.messageId === messageId, + ) + ) { + persistedMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + ...message, + messageId, + updatedAt: Number( + message.updatedAt ?? persistedMessages.length + 1, + ), + }); + } + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [...persistedMessages], + }; + } + if (command === 'chat_with_game_creator_direct_codex') { + directAttempts += 1; + if (refreshSucceeds && directAttempts === 2) { + expect(invoke).toHaveBeenCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: testAuthUser.id, + accessToken: 'refreshed-direct-token', + }), + ); + return expectedReply; + } + throw new Error('codex-app-server-error:unauthorized'); + } + if ( + command === 'read_platform_account_session_generation' || + command === 'install_platform_account_session' || + command === 'clear_platform_account_session' + ) + return null; + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - projectSupervisorOnly: true, - initialSupervisorMessage: '生成一个游戏', - }), - ); - - expect( - await screen.findByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'), - ).not.toBeNull(); - await waitFor(() => { - expect(persistedMessages).toHaveLength(2); - }); - expect(persistedMessages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ role: 'user', content: '生成一个游戏' }), - expect.objectContaining({ - role: 'assistant', - content: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + initialSupervisorMessage: '生成一个游戏', }), - ]), - ); - expect(JSON.stringify(persistedMessages)).not.toContain( - 'codex-app-server-error:unauthorized', - ); - cleanup(); + ); - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - projectSupervisorOnly: true, - }), - ); - expect(await screen.findByText('生成一个游戏')).not.toBeNull(); - expect( - await screen.findByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'), - ).not.toBeNull(); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', - ), - ).toHaveLength(1); - }); + expect(await screen.findByText(expectedReply)).not.toBeNull(); + await waitFor(() => { + expect(persistedMessages).toHaveLength(2); + }); + expect(persistedMessages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: '生成一个游戏' }), + expect.objectContaining({ + role: 'assistant', + content: expectedReply, + }), + ]), + ); + expect(JSON.stringify(persistedMessages)).not.toContain( + 'codex-app-server-error:unauthorized', + ); + cleanup(); + + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + }), + ); + expect(await screen.findByText('生成一个游戏')).not.toBeNull(); + expect(await screen.findByText(expectedReply)).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(refreshSucceeds ? 2 : 1); + if (refreshSucceeds) { + const attempts = invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ); + expect(attempts[1]?.[1]).toEqual(attempts[0]?.[1]); + } + }, + ); } export function registerRecentProjectsTests() { diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts new file mode 100644 index 000000000..529680372 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -0,0 +1,146 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; +import { + requestClientApi, + setStoredAuthAccessToken, +} from '../src/services/clientApi'; +import { + beginPlatformSessionTransition, + commitAuthenticatedPlatformSession, + currentPlatformSessionGeneration, + resetPlatformSessionStateForTests, +} from '../src/services/platformSession'; + +vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); +vi.mock('../src/services/errorReporting', () => ({ + captureClientError: vi.fn(), +})); + +const user = { id: 'session-user' } as AuthUser; +const nativeInvoke = vi.fn(async () => null); +const catalog = { models: [{ id: 'quality', displayName: '高质量' }] }; +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status }); + +beforeEach(async () => { + resetPlatformSessionStateForTests(); + window.localStorage.clear(); + nativeInvoke.mockClear(); + window.__TAURI__ = { core: { invoke: nativeInvoke } }; + setStoredAuthAccessToken('expired-token'); + await commitAuthenticatedPlatformSession( + user, + currentPlatformSessionGeneration(), + ); + nativeInvoke.mockClear(); +}); + +afterEach(() => { + resetPlatformSessionStateForTests(); + window.localStorage.clear(); + delete window.__TAURI__; + vi.restoreAllMocks(); +}); + +it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => { + let refreshCalls = 0; + let modelCalls = 0; + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input, init) => { + if (input === '/api/auth/refresh') { + refreshCalls += 1; + return json({ token: 'fresh-token' }); + } + if (input === '/api/auth/me') return json({ user }); + modelCalls += 1; + const token = new Headers(init?.headers).get('Authorization'); + if (token === 'Bearer expired-token') return json({}, 401); + expect(token).toBe('Bearer fresh-token'); + expect(nativeInvoke).toHaveBeenCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + accessToken: 'fresh-token', + userId: user.id, + }), + ); + return json(catalog); + }); + + const results = await Promise.all([ + requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), + requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), + ]); + expect(results).toEqual([catalog, catalog]); + expect(refreshCalls).toBe(1); + expect(modelCalls).toBe(4); + expect(fetch).toHaveBeenCalledTimes(6); +}); + +it.each([401])('续期失败保留原 HTTP %s,且不重发业务请求', async (status) => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(json({}, status)) + .mockResolvedValueOnce(json({}, 401)); + await expect( + requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), + ).rejects.toMatchObject({ status }); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it('跳过鉴权的请求不触发续期', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 401)); + await expect( + requestClientApi('/api/example', {}, '读取失败', { skipAuth: true }), + ).rejects.toMatchObject({ status: 401 }); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it('403 权限拒绝不触发续期或重发写请求', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 403)); + await expect( + requestClientApi( + '/api/example', + { method: 'POST', body: '{}' }, + '权限不足', + ), + ).rejects.toMatchObject({ status: 403 }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(nativeInvoke).not.toHaveBeenCalled(); +}); + +it('续期成功后的再次未授权不循环重试', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(json({}, 401)) + .mockResolvedValueOnce(json({ token: 'fresh-token' })) + .mockResolvedValueOnce(json({ user })) + .mockResolvedValueOnce(json({}, 401)); + await expect( + requestClientApi('/api/llm/models', {}, '读取失败'), + ).rejects.toMatchObject({ status: 401 }); + expect(fetch).toHaveBeenCalledTimes(4); +}); + +it('请求期间账号切换后,不替新账号续期或重发旧请求', async () => { + let finish!: (response: Response) => void; + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const pending = requestClientApi('/api/llm/models', {}, '读取失败'); + const rejection = expect(pending).rejects.toMatchObject({ status: 401 }); + const generation = beginPlatformSessionTransition(); + setStoredAuthAccessToken('other-token'); + await commitAuthenticatedPlatformSession( + { ...user, id: 'other-user' }, + generation, + ); + finish(json({}, 401)); + await rejection; + expect(fetch).toHaveBeenCalledTimes(1); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 763d0eca0..ae3b84c2f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4984,6 +4984,11 @@ - 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。 - 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。 +## AGC 登录态续期必须同步本地运行时 + +- 模型目录 HTTP 请求与 DirectProject 的 Rust/app-server 使用同一账号,但凭据分别保存在 WebView 与 Rust / Runner;续期应复用 `requestPlatformSessionRefresh` 完成用户核验及本地会话安装,不能只写 localStorage。 +- 普通 API 仅在 `401` 时续期并至多重试一次;`403` 权限拒绝不重发。对话续期失败保留原机器可读鉴权错误,避免用户提示退化为普通执行失败;账号代次变化时停止旧请求。 + ## AGC 前端等待超时与 worker 端口冲突 - `backend` 模式需要同时探测 API、worker 和必要的 SpacetimeDB 端口。只让 API 漂移会遗漏仍被旧进程占用的 worker 端口。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3473bffd4..66d3594ac 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1265,6 +1265,7 @@ game-project/ 本文早期关于“DirectProject 关闭通用 shell、原生网络和主动工具”的描述属于迁移前基线,现由以下覆盖规则取代:DirectProject 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内恢复 Codex 原生文件/搜索/命令、图片查看和 Skill;其余 ToolHost/DirectHome 合同不变。客户端审核的 `agc_tools` MCP 继续承担平台美术、资源登记、去背景、浏览器试玩和受控搜索,并保留项目锁、幂等账本、下载校验、恢复与投影权威。 DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`。多 Agent、Apps、完整插件 Runtime、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`:内置 `agc_tools` 由客户端启动参数注入,用户在客户端扩展列表启用的独立第三方 MCP 以原生配置写入该次隔离 home;全局 Codex MCP、禁用项、Plugin hooks/apps 和其它插件能力不进入 DirectProject。第三方项固定非 required,配置或启动失败只记录该项,不替换 `agc_tools`;provider session token、工具桥地址和受控搜索标记不得通过第三方 MCP 的环境转发字段泄露。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。 + ## 2026-08-24 AGC UI 原型桥接与自主 UI workflow - 2026-08-24 起,`ui-prototype` 与 UI 编辑器的 `UI` JSON 资源明确分离。设计图生成后必须由白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`:为每个功能页面创建并关联 `UI` JSON,载入页面设计图和已登记图片/图标/字体,调用 UI Editor 的 provider-backed 结构识别、多树合并与分批组件绑定,持久化 State/revision,写入 `game/` 应用标记,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 各阶段的 `generationKind` 和 manifest revision 投影给客户端。Provider 未配置、请求失败、工具调用缺失、结果不匹配、未知字体引用、未产出可渲染组件或仍有待审节点时保留最近真实阶段并返回 blocker,不得使用 deterministic seed 冒充完成。工作台点击 `ui-prototype` 时通过 `ensure_ui_design_resource_for_prototype` 幂等补齐关联资源;工作流完成后自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段,交给用户检查和手动调整。UI 编辑器独立的语义建议请求也必须复用统一 LLM 传输选择,`llm.stream=true` 时发送 `stream=true` 并聚合完整工具调用后再校验结果。只生成图片、登记空 JSON 或进入普通图片画布均不构成 UI 工作流完成,详见 [`【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。 @@ -1287,3 +1288,8 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - `/api/llm/responses` 与 `/api/llm/chat/completions` 的正式请求体上限为 `32 MiB`。两个路由必须显式配置 Axum `DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)`;不能依赖 handler 内的 `Bytes / Json` 后置检查,否则 Axum 默认 `2 MiB` 会先拒绝 Direct Codex 携带图片工具结果的大上下文请求。超过 `32 MiB` 仍返回 `413 PAYLOAD_TOO_LARGE`。 - Codex app-server 的 failed turn 需要把上游 / 连接层 HTTP 413、`PAYLOAD_TOO_LARGE` 和 provider proxy 的 `provider request too large` 映射为稳定分类 `codex-app-server-error:request-too-large`;用户可见文案固定为“模型请求体过大,请减少参考图或上下文后重试”,不得落入 `other` 或泛化成权限 / 安全策略错误。 + +## 2026-09-11 AGC 登录态自动续期 + +- AGC 前端请求客户端配套后端的鉴权 API(包括 `/api/llm/models`)收到 `401` 时,共享进行中的 refresh 请求;确认当前用户并安装 Rust / Runner 会话后,用新 access token 最多重试原请求一次。`403` 权限拒绝不触发续期;续期失败保留原鉴权错误,账号切换或登出后不重发旧请求。 +- DirectProject 的 Rust/app-server 对话调用返回鉴权失效时,前端先刷新客户端平台会话并重新提交同一 `clientTurnId`;平台会话代次变化后由 app-server pool 使用新 access token 建立连接,避免长时间运行后必须重新登录。 -- 2.52.0