diff --git a/CONTEXT.md b/CONTEXT.md index da2aca812..724d95de3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -174,6 +174,14 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI ## 项目开发对话(DirectProject) +**DirectProject 专属聊天模块**: +AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。 +_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源 + +**项目工作台布局**: +承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。 +_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态 + **项目对话历史**: AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。 _Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本 diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index ecd7501ac..16b39ca89 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -6,6 +6,7 @@ import { getAdminAgcTemplates, getAdminFeatureGateConfig, getAdminUserDetail, + importAdminAgcTemplates, listAdminRechargeOrders, reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, @@ -65,6 +66,48 @@ test('模板管理读取和更新复用认证封装,提交 revision 和封面 ]); }); +test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => { + const imported = { + revision: 'rev-2', + writable: true, + templates: [], + imported: [ + { + id: 'alpha', + templateVersion: '0.1.0', + zipSizeBytes: 4, + zipSha256: 'a'.repeat(64), + reusedObjects: false, + }, + ], + }; + const fetchMock = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify({ ok: true, data: imported }), { + status: 200, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const form = new FormData(); + form.append( + 'manifest', + JSON.stringify({ expectedRevision: 'rev-1', templates: [] }), + ); + form.append( + 'zip_0', + new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }), + ); + + expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('/admin/api/agc-templates/import'); + expect(init.method).toBe('POST'); + expect(init.body).toBe(form); + expect(init.headers).not.toHaveProperty('Content-Type'); + expect(init.headers.Authorization).toBe('Bearer admin-token'); +}); + test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 47e7ebd67..d85e5a8c8 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -30,9 +30,11 @@ import type { AdminExternalApiKeyListQuery, AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, + AdminImportAgcTemplatesResponse, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, + AdminProjectSnapshotChannelsResponse, AdminProjectSnapshotListQuery, AdminProjectSnapshotListResponse, AdminRechargeOrderListQuery, @@ -87,6 +89,8 @@ interface AdminRequestOptions { method?: string; token?: string; body?: unknown; + /** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */ + formData?: FormData; headers?: Record; signal?: AbortSignal; } @@ -174,6 +178,8 @@ export async function request( if (typeof options.body !== 'undefined') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(options.body); + } else if (options.formData) { + init.body = options.formData; } const response = await fetch(buildRequestUrl(path), init); @@ -209,6 +215,7 @@ export function listAdminProjectSnapshots( ) { const params = new URLSearchParams(); if (query.cursor) params.set('cursor', query.cursor); + if (query.channel) params.set('channel', query.channel); params.set('limit', String(query.limit ?? 20)); return request( `/admin/api/project-snapshots?${params.toString()}`, @@ -216,13 +223,27 @@ export function listAdminProjectSnapshots( ); } +export function getAdminProjectSnapshotChannels( + token: string, + signal?: AbortSignal, +) { + return request( + '/admin/api/project-snapshots/channels', + { token, signal }, + ); +} + export async function downloadAdminProjectSnapshot( token: string, + channel: string, userId: string, projectId: string, signal?: AbortSignal, ) { - const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`; + const params = new URLSearchParams(); + if (channel) params.set('channel', channel); + const query = params.toString(); + const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`; const response = await fetch(buildRequestUrl(path), { headers: { Authorization: `Bearer ${token.trim()}`, @@ -1196,3 +1217,15 @@ export function updateAdminAgcTemplate( { token, method: 'PUT', body }, ); } + +/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */ +export function importAdminAgcTemplates(token: string, formData: FormData) { + return request( + '/admin/api/agc-templates/import', + { + token, + method: 'POST', + formData, + }, + ); +} diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index dfcd4b993..44b833586 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -105,11 +105,15 @@ export interface AdminProjectSnapshotEntry { fileCount: number; totalBytes: number; status: 'ready' | 'partial' | 'unverified'; + channel: string; + authorDisplayName?: string | null; + authorPublicUserCode?: string | null; } export interface AdminProjectSnapshotListQuery { cursor?: string | null; limit?: number; + channel?: string | null; } export interface AdminProjectSnapshotListResponse { @@ -117,6 +121,11 @@ export interface AdminProjectSnapshotListResponse { nextCursor: string | null; } +export interface AdminProjectSnapshotChannelsResponse { + defaultChannel: string; + channels: string[]; +} + export interface AdminErrorReportEntry { batchId: string; eventCount: number; @@ -1065,3 +1074,35 @@ export interface AdminUpdateAgcTemplateRequest { dataBase64: string; }; } + +export interface AdminImportAgcTemplateItemPayload { + id: string; + title: string; + summary: string; + tags: string[]; + runtime: string; + engine: string; + engineVersion: string; + templateVersion: string; + entry: string; + zipField: string; + coverField: string; +} + +export interface AdminImportAgcTemplatesManifest { + expectedRevision: string; + templates: AdminImportAgcTemplateItemPayload[]; +} + +export interface AdminImportAgcTemplateResult { + id: string; + templateVersion: string; + zipSizeBytes: number; + zipSha256: string; + reusedObjects: boolean; +} + +export interface AdminImportAgcTemplatesResponse + extends AdminAgcTemplateLibraryResponse { + imported: AdminImportAgcTemplateResult[]; +} diff --git a/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts index bee81d5f9..5ae88ce97 100644 --- a/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts +++ b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import { downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from './adminApiClient'; @@ -21,12 +22,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () => expect( await listAdminProjectSnapshots( 'admin-token', - { cursor: 'user/a+项目', limit: 20 }, + { cursor: 'user/a+项目', limit: 20, channel: 'release' }, controller.signal, ), ).toEqual(payload); expect(fetchMock).toHaveBeenCalledWith( - '/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20', + '/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), signal: controller.signal, @@ -34,6 +35,23 @@ test('项目列表携带分页与后台授权,解析标准响应', async () => ); }); +test('渠道列表按后台授权读取,解析本部署渠道', async () => { + const payload = { defaultChannel: 'release', channels: ['dev', 'release'] }; + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: true, data: payload })), + ); + vi.stubGlobal('fetch', fetchMock); + expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload); + expect(fetchMock).toHaveBeenCalledWith( + '/admin/api/project-snapshots/channels', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), + }), + ); +}); + test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response('PK\u0003\u0004', { @@ -48,6 +66,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () = const controller = new AbortController(); const archive = await downloadAdminProjectSnapshot( 'admin-token', + 'release', 'user/a', 'project/b', controller.signal, @@ -55,7 +74,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () = expect(archive.filename).toBe('三消-r2.zip'); expect(archive.blob.type).toBe('application/zip'); expect(fetchMock).toHaveBeenCalledWith( - '/admin/api/project-snapshots/user%2Fa/project%2Fb/download', + '/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer admin-token', @@ -81,7 +100,8 @@ test.each([ vi.fn().mockResolvedValue(new Response('PK', { headers })), ); expect( - (await downloadAdminProjectSnapshot('token', 'user', 'project')).filename, + (await downloadAdminProjectSnapshot('token', 'release', 'user', 'project')) + .filename, ).toBe(expected.replace('工程', 'project')); }); @@ -104,7 +124,7 @@ test.each([401, 403, 409, 500])( ), ); await expect( - downloadAdminProjectSnapshot('token', 'user', 'project'), + downloadAdminProjectSnapshot('token', 'release', 'user', 'project'), ).rejects.toMatchObject({ status, code: 'SNAPSHOT_FAILURE', @@ -123,6 +143,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => { ), ); await expect( - downloadAdminProjectSnapshot('token', 'user', 'project'), + downloadAdminProjectSnapshot('token', 'release', 'user', 'project'), ).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' }); }); diff --git a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx index b96acd4fd..667e44af5 100644 --- a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx @@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { AdminApiError, getAdminAgcTemplates, + importAdminAgcTemplates, updateAdminAgcTemplate, } from '../api/adminApiClient'; import type { @@ -24,6 +25,7 @@ import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage'; vi.mock('../api/adminApiClient', async (importOriginal) => ({ ...(await importOriginal()), getAdminAgcTemplates: vi.fn(), + importAdminAgcTemplates: vi.fn(), updateAdminAgcTemplate: vi.fn(), })); @@ -79,6 +81,21 @@ beforeEach(() => { : entry, ), })); + vi.mocked(importAdminAgcTemplates) + .mockReset() + .mockImplementation(async () => ({ + ...structuredClone(library), + revision: 'rev-imported', + imported: [ + { + id: 'alpha', + templateVersion: '0.1.0', + zipSizeBytes: 4, + zipSha256: 'a'.repeat(64), + reusedObjects: false, + }, + ], + })); vi.stubGlobal( 'URL', Object.assign(class extends URL {}, { @@ -496,3 +513,133 @@ test('读取失败显示真实错误,401 交给会话处理且不显示空库' expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'), ); }); + +function uploadZipFile(name: string) { + return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, { + type: 'application/zip', + }); +} + +function uploadCoverFile(name: string) { + return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, { + type: 'image/png', + }); +} + +async function openUploadDialog() { + fireEvent.click(await screen.findByRole('button', { name: '上传模板' })); + return screen.getByRole('dialog', { name: '上传模板' }); +} + +function pickUploadFiles(dialog: HTMLElement, accept: string, files: File[]) { + const input = dialog.querySelector( + `input[accept="${accept}"]`, + ); + if (!input) throw new Error(`missing file input for ${accept}`); + fireEvent.change(input, { target: { files } }); +} + +test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => { + render(); + const dialog = await openUploadDialog(); + + pickUploadFiles(dialog, '.zip,application/zip', [ + uploadZipFile('alpha.zip'), + uploadZipFile('beta.zip'), + ]); + expect(within(dialog).getByText('alpha.zip')).not.toBeNull(); + expect(within(dialog).getByText('beta.zip')).not.toBeNull(); + expect( + within(dialog) + .getByRole('button', { name: /上传 2 个模板/ }) + .hasAttribute('disabled'), + ).toBe(true); + expect(within(dialog).getAllByText('未匹配封面')).toHaveLength(2); + + pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [ + uploadCoverFile('alpha.png'), + uploadCoverFile('beta.png'), + ]); + expect(within(dialog).getByText('alpha.png')).not.toBeNull(); + expect( + within(dialog) + .getByRole('button', { name: /上传 2 个模板/ }) + .hasAttribute('disabled'), + ).toBe(false); +}); + +test('批量上传:确认后提交 manifest 与文件,成功后刷新列表', async () => { + render(); + const dialog = await openUploadDialog(); + pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]); + pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [ + uploadCoverFile('alpha.png'), + ]); + + fireEvent.click( + within(dialog).getByRole('button', { name: /上传 1 个模板/ }), + ); + await confirmWrite(); + + await waitFor(() => expect(importAdminAgcTemplates).toHaveBeenCalledTimes(1)); + const formData = vi.mocked(importAdminAgcTemplates).mock.calls[0]![1]; + const manifest = JSON.parse(String(formData.get('manifest'))); + expect(manifest.expectedRevision).toBe('rev-1'); + expect(manifest.templates[0]).toMatchObject({ + id: 'alpha', + zipField: 'zip_0', + coverField: 'cover_0', + }); + expect((formData.get('zip_0') as File).name).toBe('alpha.zip'); + expect((formData.get('cover_0') as File).name).toBe('alpha.png'); + expect( + (await screen.findAllByText(/已导入 1 个模板/)).length, + ).toBeGreaterThan(0); +}); + +test('批量上传:冲突给出刷新引导', async () => { + render(); + const dialog = await openUploadDialog(); + pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]); + pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [ + uploadCoverFile('alpha.png'), + ]); + + vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce( + new AdminApiError({ + status: 409, + message: '模板库已更新,请刷新后重新编辑', + }), + ); + fireEvent.click( + within(dialog).getByRole('button', { name: /上传 1 个模板/ }), + ); + await confirmWrite(); + expect(await screen.findByText(/请刷新列表后重试/)).not.toBeNull(); + expect( + within(dialog) + .getByRole('button', { name: /上传 1 个模板/ }) + .hasAttribute('disabled'), + ).toBe(true); +}); + +test('批量上传:服务端拒绝时展示真实原因', async () => { + render(); + const dialog = await openUploadDialog(); + pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]); + pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [ + uploadCoverFile('alpha.png'), + ]); + + vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce( + new AdminApiError({ + status: 400, + message: 'alpha:模板包缺少清单声明的 entry:index.html', + }), + ); + fireEvent.click( + within(dialog).getByRole('button', { name: /上传 1 个模板/ }), + ); + await confirmWrite(); + expect(await screen.findByText(/模板包缺少清单声明的 entry/)).not.toBeNull(); +}); diff --git a/apps/admin-web/src/pages/AdminAgcTemplatesPage.tsx b/apps/admin-web/src/pages/AdminAgcTemplatesPage.tsx index 989ca2ce0..f5cd5fb16 100644 --- a/apps/admin-web/src/pages/AdminAgcTemplatesPage.tsx +++ b/apps/admin-web/src/pages/AdminAgcTemplatesPage.tsx @@ -11,7 +11,7 @@ import { TableRow, TextField, } from '@genarrative/shared/components'; -import { RefreshCcw } from 'lucide-react'; +import { RefreshCcw, Upload } from 'lucide-react'; import { type FormEvent, useCallback, @@ -21,16 +21,28 @@ import { } from 'react'; import { + formatAdminApiError, getAdminAgcTemplates, + importAdminAgcTemplates, isAdminApiError, updateAdminAgcTemplate, } from '../api/adminApiClient'; import type { AdminAgcTemplateLibraryResponse, AdminAgcTemplatePayload, + AdminImportAgcTemplateResult, AdminUpdateAgcTemplateRequest, } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { + attachCoverFiles, + buildTemplateImportFormData, + deriveTemplateUploadRow, + TEMPLATE_IMPORT_MAX_BATCH, + TEMPLATE_UPLOAD_RUNTIMES, + type TemplateUploadRow, + validateTemplateUploadRows, +} from './adminAgcTemplateUploadModel'; import { handlePageError, splitLines } from './pageUtils'; type PageProps = { token: string; onUnauthorized: (message?: string) => void }; @@ -39,6 +51,14 @@ type EditingTemplate = { revision: string; key: number; }; + +type TemplateUploadState = { + rows: TemplateUploadRow[]; + errors: Record; + submitting: boolean; + error: string; + results: AdminImportAgcTemplateResult[] | null; +}; const runtimeLabels: Record = { html: 'HTML', cocos: 'Cocos', @@ -63,6 +83,7 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) { const [notice, setNotice] = useState(''); const [conflict, setConflict] = useState(false); const [editing, setEditing] = useState(null); + const [upload, setUpload] = useState(null); const mounted = useRef(false); const readGeneration = useRef(0); const readController = useRef(null); @@ -165,6 +186,96 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) { setEditing(null); } + function openUpload() { + if (writesDisabled || !snapshot) return; + setUpload({ + rows: [], + errors: {}, + submitting: false, + error: '', + results: null, + }); + } + + function closeUpload() { + if (upload?.submitting) return; + setUpload(null); + } + + async function submitUpload(rows: TemplateUploadRow[]) { + const revision = snapshot?.revision; + if ( + !revision || + writing.current || + loading || + conflict || + !snapshot?.writable + ) { + return; + } + const errors = validateTemplateUploadRows(rows); + if (Object.keys(errors).length > 0) { + setUpload((current) => (current ? { ...current, errors } : current)); + return; + } + writing.current = true; + setUpload((current) => + current + ? { ...current, submitting: true, errors: {}, error: '', results: null } + : current, + ); + setError(''); + setNotice(''); + try { + const confirmed = await confirmWrite({ + action: '上传模板', + target: `${rows.length} 个模板(发布后不可删除,只能下架)`, + }); + if (!confirmed || !mounted.current) return; + const response = await importAdminAgcTemplates( + token, + buildTemplateImportFormData(revision, rows), + ); + if (!mounted.current) return; + readGeneration.current += 1; + readController.current?.abort(); + setSnapshot({ + revision: response.revision, + writable: response.writable, + templates: response.templates, + }); + setConflict(false); + setNotice(`已导入 ${response.imported.length} 个模板`); + setUpload((current) => + current + ? { + ...current, + submitting: false, + rows: [], + results: response.imported, + } + : current, + ); + } catch (error) { + if (!mounted.current) return; + const conflictNow = isAdminApiError(error) && error.status === 409; + setConflict(conflictNow); + const message = formatUploadError(error); + setUpload((current) => + current ? { ...current, submitting: false, error: message } : current, + ); + // 上传失败只在弹窗里交代:401 仍要交给会话处理,其余错误不要同时打到列表页。 + if (isAdminApiError(error) && error.status === 401) { + handlePageError(error, unauthorized.current, setError); + } + } finally { + writing.current = false; + setUpload((current) => + current ? { ...current, submitting: false } : current, + ); + } + } + const terms = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean); const entries = (snapshot?.templates ?? []).filter((entry) => { const searchable = [entry.id, entry.title, ...entry.tags] @@ -186,14 +297,24 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {

模板管理

- +
+ + +
{readOnly ? ( 当前为只读模式,无法保存或上下架 @@ -378,6 +499,41 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) { ) : ( confirmDialog )} + {upload ? ( + + + setUpload((current) => + current + ? { + ...current, + rows, + errors: validateTemplateUploadRows(rows), + } + : current, + ) + } + onSubmit={() => void submitUpload(upload.rows)} + onClose={closeUpload} + onRefresh={() => void refresh()} + /> + {confirmDialog} + + ) : null}
); } @@ -627,3 +783,266 @@ function formatTemplateSize(bytes: number) { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; } + +function formatUploadError(error: unknown) { + const message = formatAdminApiError(error); + if (isAdminApiError(error) && error.status === 409) { + return `${message}(模板库已更新,请刷新列表后重试)`; + } + if (isAdminApiError(error) && error.status === 503) { + return `${message}(发布锁可能仍被占用,请联系运维核对)`; + } + return message; +} + +function TemplateUploadDialog({ + state, + disabled, + readOnly, + busy, + conflict, + onRowsChange, + onSubmit, + onClose, + onRefresh, +}: { + state: TemplateUploadState; + disabled: boolean; + readOnly: boolean; + busy: boolean; + conflict: boolean; + onRowsChange: (rows: TemplateUploadRow[]) => void; + onSubmit: () => void; + onClose: () => void; + onRefresh: () => void; +}) { + const uploadBusy = state.submitting; + const rowErrorCount = Object.keys(state.errors).length; + + function appendZipFiles(files: File[]) { + const known = new Set(state.rows.map((row) => row.key)); + const next = [...state.rows]; + for (const file of files) { + if (!/\.zip$/iu.test(file.name) || known.has(file.name)) continue; + known.add(file.name); + next.push(deriveTemplateUploadRow(file)); + } + onRowsChange(next); + } + + function updateRow(key: string, patch: Partial) { + onRowsChange( + state.rows.map((row) => (row.key === key ? { ...row, ...patch } : row)), + ); + } + + return ( + <> + {readOnly ? ( + 当前为只读模式,无法上传 + ) : null} +
+ + +
+ {state.rows.length === 0 ? ( + + 还没有选择模板包。简介、标签与引擎可在上传后用「编辑」补齐。 + + ) : ( +
+ + + + 模板包 + ID + 名称 + 版本 + 运行时 + entry + 封面 + 操作 + + + + {state.rows.map((row) => { + const rowError = state.errors[row.key]; + const rowDisabled = disabled || uploadBusy; + return ( + + +
+ {row.zipFile.name} +
+ {rowError ? ( +
+ {rowError} +
+ ) : null} +
+ + + updateRow(row.key, { id: event.currentTarget.value }) + } + /> + + + + updateRow(row.key, { + title: event.currentTarget.value, + }) + } + /> + + + + updateRow(row.key, { + templateVersion: event.currentTarget.value, + }) + } + /> + + + + updateRow(row.key, { + runtime: event.currentTarget.value, + }) + } + > + {TEMPLATE_UPLOAD_RUNTIMES.map((value) => ( + + ))} + + + + + updateRow(row.key, { + entry: event.currentTarget.value, + }) + } + /> + + + {row.coverFile ? ( + + {row.coverFile.name} + + ) : ( + 未匹配封面 + )} + + + + +
+ ); + })} +
+
+
+ )} + {state.error ? ( + + {state.error} + + ) : null} + {conflict ? ( + + ) : null} + {state.results ? ( + + {`已导入 ${state.results.length} 个模板:${state.results + .map((result) => `${result.id}@${result.templateVersion}`) + .join('、')}`} + + ) : null} +
+ + +
+ + ); +} diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx index 30e4ba2ae..7916e60e3 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { AdminApiError, downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; @@ -23,6 +24,7 @@ vi.mock('../api/adminApiClient', async () => ({ '../api/adminApiClient', )), downloadAdminProjectSnapshot: vi.fn(), + getAdminProjectSnapshotChannels: vi.fn(), listAdminProjectSnapshots: vi.fn(), })); @@ -35,12 +37,18 @@ const entry: AdminProjectSnapshotEntry = { fileCount: 12, totalBytes: 2048, status: 'ready', + channel: 'dev', + authorDisplayName: '陶泥作者', + authorPublicUserCode: 'SY-00000007', }; beforeEach(() => { vi.mocked(listAdminProjectSnapshots) .mockReset() .mockResolvedValue({ items: [entry], nextCursor: null }); + vi.mocked(getAdminProjectSnapshotChannels) + .mockReset() + .mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] }); vi.mocked(downloadAdminProjectSnapshot).mockReset(); }); afterEach(() => { @@ -88,36 +96,166 @@ test('按项目展示完整性并限制未完成工程下载', async () => { ).toBe(false); }); -test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => { +test('按游标翻页回到上一页时复用已取得的游标', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], + nextCursor: 'page-3', + }) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }); + render(); + await screen.findByText('三消工程'); + const pagination = await screen.findByRole('navigation', { + name: '项目工程分页', + }); + expect(pagination.textContent).toContain('第 1 页'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 1, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect( + screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'), + ).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第二工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 2, + 'token', + { cursor: 'page-2', limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(screen.queryByText('三消工程')).toBeNull(); + expect(pagination.textContent).toContain('第 2 页'); + expect( + screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'), + ).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(pagination.textContent).toContain('第 1 页'); +}); + +test('切换每页条数从第一页按新条数重新加载', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], + nextCursor: 'page-3', + }) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第二工程'); + const pagination = screen.getByRole('navigation', { name: '项目工程分页' }); + expect(pagination.textContent).toContain('第 2 页'); + + fireEvent.change(screen.getByLabelText('每页条数'), { + target: { value: '50' }, + }); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 50, channel: 'dev' }, + expect.any(AbortSignal), + ); + // 重新加载时页脚会重建,必须重新取节点再断言。 + expect( + screen.getByRole('navigation', { name: '项目工程分页' }).textContent, + ).toContain('第 1 页'); +}); + +test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => { vi.mocked(listAdminProjectSnapshots) .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) .mockResolvedValueOnce({ items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], nextCursor: null, }) - .mockRejectedValueOnce(new Error('远端清单读取失败')) - .mockResolvedValueOnce({ items: [], nextCursor: null }); + .mockRejectedValueOnce(new Error('翻页读取失败')); render(); - fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); await screen.findByText('第二工程'); - expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( - 2, - 'token', - { cursor: 'page-2', limit: 20 }, - expect.any(AbortSignal), - ); - expect(screen.getByText('三消工程')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); await screen.findByRole('alert'); - expect(screen.getByText('第二工程')).toBeTruthy(); - expect(screen.queryByText('暂无已上传项目')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '刷新' })); - await screen.findByText('暂无已上传项目'); - expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith( + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, 'token', - { cursor: null, limit: 20 }, + { cursor: 'page-2', limit: 20, channel: 'dev' }, expect.any(AbortSignal), ); + const pagination = screen.getByRole('navigation', { name: '项目工程分页' }); + expect(pagination.textContent).toContain('第 2 页'); + expect(screen.getByText('第二工程')).toBeTruthy(); + expect(screen.queryByText('暂无已上传项目')).toBeNull(); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('暂无已上传项目'); + expect(screen.queryByRole('alert')).toBeNull(); +}); + +test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => { + render(); + const row = (await screen.findByText('三消工程')).closest('tr')!; + expect(within(row).getByText('陶泥作者')).toBeTruthy(); + expect(within(row).getByText('SY-00000007')).toBeTruthy(); + expect( + within(row).getByRole('button', { name: '查看用户信息' }), + ).toBeTruthy(); + expect(within(row).queryByText('user-1')).toBeNull(); +}); + +test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => { + vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({ + defaultChannel: 'release', + channels: ['dev', 'release'], + }); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockResolvedValueOnce({ + items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 1, + 'token', + { cursor: null, limit: 20, channel: 'release' }, + expect.any(AbortSignal), + ); + const channelSelect = screen.getByLabelText('项目工程渠道'); + expect((channelSelect as HTMLSelectElement).value).toBe('release'); + + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第 2 页,本页 1 个项目'); + fireEvent.change(channelSelect, { target: { value: 'dev' } }); + await screen.findByText('dev 工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy(); }); test('下载使用返回的中文文件名,随后释放对象 URL', async () => { @@ -154,6 +292,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () = expect(createObjectURL).toHaveBeenCalledWith(blob); expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith( 'token', + 'dev', 'user-1', 'project-1', expect.any(AbortSignal), @@ -164,7 +303,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () = test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => { vi.mocked(downloadAdminProjectSnapshot).mockImplementation( - (_token, _user, _project, signal) => + (_token, _channel, _user, _project, signal) => new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), @@ -177,7 +316,7 @@ test('取消下载中止请求且不显示错误,卸载中止列表请求', as fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); fireEvent.click(await screen.findByRole('button', { name: '取消下载' })); expect( - vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted, + vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted, ).toBe(true); await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {})); @@ -248,6 +387,10 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => { onUnauthorized={onUnauthorized} />, ); + // 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。 + await waitFor(() => + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1), + ); const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2]; view.rerender( { , ); fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); - const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]; + const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]; view.unmount(); expect(signal?.aborted).toBe(true); await act(async () => { diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx index 489956117..30621d554 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -1,11 +1,19 @@ -import { Download, RefreshCcw, X } from 'lucide-react'; +import { + ChevronLeft, + ChevronRight, + Download, + RefreshCcw, + X, +} from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { handlePageError } from './pageUtils'; interface AdminProjectSnapshotsPageProps { @@ -31,21 +39,32 @@ const snapshotStatuses = { }, }; +const DEFAULT_PAGE_SIZE = 20; +const PAGE_SIZE_OPTIONS = [20, 50, 100]; + export function AdminProjectSnapshotsPage({ token, onUnauthorized, }: AdminProjectSnapshotsPageProps) { const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); + const [pageIndex, setPageIndex] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + // null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。 + const [channel, setChannel] = useState(null); + const [channelOptions, setChannelOptions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [downloadingKey, setDownloadingKey] = useState(null); const listController = useRef(null); const downloadController = useRef(null); + // 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回, + // 因此按页记录已取得的游标,翻页只在这些游标之间移动。 + const pageCursors = useRef<(string | null)[]>([null]); const loadPage = useCallback( - async (cursor: string | null = null) => { + async (cursor: string | null, limit: number, page: number) => { listController.current?.abort(); const controller = new AbortController(); listController.current = controller; @@ -54,23 +73,16 @@ export function AdminProjectSnapshotsPage({ try { const response = await listAdminProjectSnapshots( token, - { cursor, limit: 20 }, + { cursor, limit, channel }, controller.signal, ); if (controller.signal.aborted) return; - setItems((current) => { - if (!cursor) return response.items; - const entries = new Map( - current.map((entry) => [snapshotKey(entry), entry]), - ); - response.items.forEach((entry) => - entries.set(snapshotKey(entry), entry), - ); - return [...entries.values()]; - }); + setItems(response.items); setNextCursor(response.nextCursor); + setPageIndex(page); setHasLoaded(true); } catch (error: unknown) { + // 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。 if (!controller.signal.aborted) handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -80,22 +92,70 @@ export function AdminProjectSnapshotsPage({ } } }, - [token, onUnauthorized], + [token, onUnauthorized, channel], ); useEffect(() => { + // 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。 + const controller = new AbortController(); + void (async () => { + try { + const response = await getAdminProjectSnapshotChannels( + token, + controller.signal, + ); + if (controller.signal.aborted) return; + setChannelOptions(response.channels); + setChannel((current) => current ?? response.defaultChannel); + } catch (error: unknown) { + if (controller.signal.aborted) return; + // 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。 + handlePageError(error, onUnauthorized, setErrorMessage); + setChannel((current) => current ?? ''); + } + })(); + return () => controller.abort(); + }, [token, onUnauthorized]); + + useEffect(() => { + if (channel === null) return undefined; + pageCursors.current = [null]; setItems([]); setNextCursor(null); + setPageIndex(1); setHasLoaded(false); setDownloadingKey(null); - void loadPage(); + void loadPage(null, pageSize, 1); return () => { listController.current?.abort(); listController.current = null; downloadController.current?.abort(); downloadController.current = null; }; - }, [loadPage]); + }, [loadPage, pageSize, channel]); + + function goToNextPage() { + if (!nextCursor) return; + pageCursors.current[pageIndex] = nextCursor; + void loadPage(nextCursor, pageSize, pageIndex + 1); + } + + function goToPreviousPage() { + if (pageIndex <= 1) return; + void loadPage( + pageCursors.current[pageIndex - 2] ?? null, + pageSize, + pageIndex - 1, + ); + } + + function refreshCurrentPage() { + void loadPage( + pageCursors.current[pageIndex - 1] ?? null, + pageSize, + pageIndex, + ); + } async function downloadProject(entry: AdminProjectSnapshotEntry) { if (downloadController.current || entry.status === 'partial') return; @@ -106,6 +166,7 @@ export function AdminProjectSnapshotsPage({ try { const archive = await downloadAdminProjectSnapshot( token, + channel ?? '', entry.userId, entry.projectId, controller.signal, @@ -140,19 +201,42 @@ export function AdminProjectSnapshotsPage({ setDownloadingKey(null); } + // 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。 + const visibleChannelOptions = channelOptions.length + ? channelOptions + : channel + ? [channel] + : []; + return (

项目工程

- +
+ + +
{errorMessage ? (
@@ -169,7 +253,7 @@ export function AdminProjectSnapshotsPage({ 项目 - 用户 ID + 用户 同步时间 文件数 体积 @@ -187,7 +271,22 @@ export function AdminProjectSnapshotsPage({ {entry.projectName || entry.projectId} {entry.projectId} - {entry.userId} + +
+
+ {projectOwnerDisplayName(entry)} + + {entry.authorPublicUserCode?.trim() || '-'} + +
+ +
+ {new Date(entry.syncedAtMs).toLocaleString('zh-CN', { @@ -239,23 +338,66 @@ export function AdminProjectSnapshotsPage({ {hasLoaded && items.length === 0 && !errorMessage ? (

暂无已上传项目

) : null} - {nextCursor ? ( -
- -
+ {hasLoaded ? ( + ) : null}
); } +function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) { + return ( + entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-' + ); +} + function snapshotKey(entry: AdminProjectSnapshotEntry) { return `${entry.userId}/${entry.projectId}`; } diff --git a/apps/admin-web/src/pages/adminAgcTemplateUploadModel.test.ts b/apps/admin-web/src/pages/adminAgcTemplateUploadModel.test.ts new file mode 100644 index 000000000..c6a958909 --- /dev/null +++ b/apps/admin-web/src/pages/adminAgcTemplateUploadModel.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; + +import { + attachCoverFiles, + buildTemplateImportFormData, + deriveTemplateUploadRow, + parseTemplateTags, + sanitizeTemplateId, + TEMPLATE_IMPORT_MAX_BATCH, + type TemplateUploadRow, + validateTemplateUploadRows, +} from './adminAgcTemplateUploadModel'; + +function zipFile(name: string) { + return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, { + type: 'application/zip', + }); +} + +function coverFile(name: string) { + return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, { + type: 'image/png', + }); +} + +function row(zipName: string, patch: Partial = {}) { + return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch }; +} + +describe('sanitizeTemplateId', () => { + it('lowercases, keeps whitelisted characters and drops traversal', () => { + expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d'); + expect(sanitizeTemplateId('../../evil.zip')).toBe('evil'); + expect(sanitizeTemplateId('a..b.zip')).toBe('a.b'); + expect(sanitizeTemplateId('__hidden__')).toBe('hidden__'); + expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64); + }); +}); + +describe('deriveTemplateUploadRow', () => { + it('starts from safe defaults so a batch only needs covers attached', () => { + const derived = row('my-template.zip'); + expect(derived.id).toBe('my-template'); + expect(derived.title).toBe('my-template'); + expect(derived.runtime).toBe('html'); + expect(derived.templateVersion).toBe('0.1.0'); + expect(derived.entry).toBe('index.html'); + expect(derived.coverFile).toBeNull(); + }); +}); + +describe('attachCoverFiles', () => { + it('matches covers by template id and ignores non-image files', () => { + const rows = [row('alpha.zip'), row('beta.zip')]; + const covers = [ + coverFile('alpha.png'), + coverFile('beta.webp'), + coverFile('notes.txt'), + ]; + + const next = attachCoverFiles(rows, covers); + + expect(next[0]?.coverFile?.name).toBe('alpha.png'); + expect(next[1]?.coverFile?.name).toBe('beta.webp'); + }); + + it('keeps an already matched cover when the new selection has no partner', () => { + const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })]; + const next = attachCoverFiles(rows, [coverFile('other.png')]); + expect(next[0]?.coverFile?.name).toBe('alpha.png'); + }); +}); + +describe('validateTemplateUploadRows', () => { + it('accepts a complete batch', () => { + const rows = [ + row('alpha.zip', { coverFile: coverFile('alpha.png') }), + row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }), + ]; + expect(validateTemplateUploadRows(rows)).toEqual({}); + }); + + it('reports missing cover, duplicate id and invalid fields per row', () => { + const rows = [ + row('alpha.zip'), + row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }), + row('gamma.zip', { + coverFile: coverFile('gamma.png'), + runtime: 'docker', + templateVersion: 'Bad Version', + entry: '../escape.js', + title: ' ', + }), + ]; + const errors = validateTemplateUploadRows(rows); + expect(errors['alpha.zip']).toContain('缺少封面'); + expect(errors.second).toContain('ID 在本批次内重复'); + expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符'); + }); + + it('rejects a batch larger than the server limit', () => { + const rows = Array.from( + { length: TEMPLATE_IMPORT_MAX_BATCH + 1 }, + (_, index) => + row(`template-${index}.zip`, { + coverFile: coverFile(`template-${index}.png`), + }), + ); + const errors = validateTemplateUploadRows(rows); + expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1); + expect(errors['template-0.zip']).toContain('单批最多上传'); + }); +}); + +describe('buildTemplateImportFormData', () => { + it('writes manifest field names and attaches zip / cover per row', () => { + const rows = [ + row('alpha.zip', { + coverFile: coverFile('alpha.png'), + tags: '起步, 起步 2d', + title: ' Alpha ', + }), + row('beta.zip', { coverFile: coverFile('beta.png') }), + ]; + + const form = buildTemplateImportFormData('a'.repeat(64), rows); + const manifest = JSON.parse(String(form.get('manifest'))); + + expect(manifest.expectedRevision).toBe('a'.repeat(64)); + expect(manifest.templates).toHaveLength(2); + expect(manifest.templates[0]).toMatchObject({ + id: 'alpha', + title: 'Alpha', + tags: ['起步', '2d'], + zipField: 'zip_0', + coverField: 'cover_0', + }); + expect(manifest.templates[1]).toMatchObject({ + id: 'beta', + zipField: 'zip_1', + coverField: 'cover_1', + }); + expect((form.get('zip_0') as File).name).toBe('alpha.zip'); + expect((form.get('cover_1') as File).name).toBe('beta.png'); + }); + + it('parses tags with dedupe and caps them at the server limit', () => { + expect(parseTemplateTags(' a, a,b c ')).toEqual(['a', 'b', 'c']); + const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join( + ',', + ); + expect(parseTemplateTags(many)).toHaveLength(16); + }); +}); diff --git a/apps/admin-web/src/pages/adminAgcTemplateUploadModel.ts b/apps/admin-web/src/pages/adminAgcTemplateUploadModel.ts new file mode 100644 index 000000000..11192981a --- /dev/null +++ b/apps/admin-web/src/pages/adminAgcTemplateUploadModel.ts @@ -0,0 +1,191 @@ +import type { + AdminImportAgcTemplateItemPayload, + AdminImportAgcTemplatesManifest, +} from '../api/adminApiTypes'; + +/** 与服务端 module-assets 的导入上限保持一致;超出请分批或改走 CLI。 */ +export const TEMPLATE_IMPORT_MAX_BATCH = 20; +export const TEMPLATE_UPLOAD_RUNTIMES = [ + 'html', + 'unity', + 'godot', + 'cocos', +] as const; +const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u; +const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u; +const ENTRY_PATTERN = /^(?![\\/:])(?!.*\.\.)[^\s\\:]+$/u; +const COVER_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/iu; + +export interface TemplateUploadRow { + /** 稳定行标识:用 ZIP 文件名,重选文件后不会串行。 */ + key: string; + zipFile: File; + coverFile: File | null; + id: string; + title: string; + summary: string; + tags: string; + runtime: string; + engine: string; + engineVersion: string; + templateVersion: string; + entry: string; +} + +/** 文件名 → 模板 ID:小写、只留白名单字符,并去掉 `..` 与开头的非字母数字。 */ +export function sanitizeTemplateId(value: string) { + return value + .replace(/\.zip$/iu, '') + .trim() + .toLowerCase() + .replace(/\.{2,}/gu, '.') + .replace(/[^a-z0-9._-]+/gu, '-') + .replace(/^[^a-z0-9]+/u, '') + .slice(0, 64); +} + +export function deriveTemplateUploadRow(zipFile: File): TemplateUploadRow { + const id = sanitizeTemplateId(zipFile.name); + return { + key: zipFile.name, + zipFile, + coverFile: null, + id, + title: id, + summary: '', + tags: '', + runtime: 'html', + engine: '', + engineVersion: '', + templateVersion: '0.1.0', + entry: 'index.html', + }; +} + +/** 封面按「与模板 ID 同名的图片」匹配,一次多选即可覆盖整批。 */ +export function attachCoverFiles( + rows: TemplateUploadRow[], + coverFiles: File[], +): TemplateUploadRow[] { + const covers = new Map(); + for (const file of coverFiles) { + if (!COVER_EXTENSION_PATTERN.test(file.name)) continue; + const stem = sanitizeTemplateId( + file.name.replace(COVER_EXTENSION_PATTERN, ''), + ); + if (!covers.has(stem)) covers.set(stem, file); + } + return rows.map((row) => { + const matched = covers.get(row.id.trim().toLowerCase()) ?? null; + return matched ? { ...row, coverFile: matched } : row; + }); +} + +export function parseTemplateTags(value: string) { + const seen = new Set(); + const tags: string[] = []; + for (const raw of value.split(/[,,\s]+/u)) { + const tag = raw.trim(); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + tags.push(tag); + } + return tags.slice(0, 16); +} + +/** 逐行校验:返回 row.key → 错误文案;空对象表示整批可以提交。 */ +export function validateTemplateUploadRows( + rows: TemplateUploadRow[], +): Record { + const errors: Record = {}; + const seen = new Set(); + const fail = (row: TemplateUploadRow, message: string) => { + if (!errors[row.key]) errors[row.key] = message; + }; + if (rows.length === 0) return errors; + if (rows.length > TEMPLATE_IMPORT_MAX_BATCH) { + for (const row of rows) { + fail(row, `单批最多上传 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板`); + } + return errors; + } + for (const row of rows) { + const id = row.id.trim(); + if (!TEMPLATE_ID_PATTERN.test(id)) { + fail( + row, + 'ID 必须是 1-64 位小写字母、数字、点、下划线或连字符,且以字母数字开头', + ); + } else if (seen.has(id)) { + fail(row, 'ID 在本批次内重复'); + } else { + seen.add(id); + } + if (!row.title.trim() || row.title.trim().length > 80) { + fail(row, '名称必须是 1-80 个字符'); + } + if (row.summary.trim().length > 1000) { + fail(row, '简介最多 1000 个字符'); + } + if (!TEMPLATE_VERSION_PATTERN.test(row.templateVersion.trim())) { + fail(row, '版本号必须是 1-32 位小写字母、数字、点、下划线或连字符'); + } + if ( + !TEMPLATE_UPLOAD_RUNTIMES.includes( + row.runtime as (typeof TEMPLATE_UPLOAD_RUNTIMES)[number], + ) + ) { + fail(row, `运行时只能是 ${TEMPLATE_UPLOAD_RUNTIMES.join(' / ')}`); + } + if (!ENTRY_PATTERN.test(row.entry.trim())) { + fail(row, 'entry 必须是模板包内的相对路径'); + } + if (!row.coverFile) { + fail(row, '缺少封面:请上传与模板 ID 同名的 PNG / JPEG / WebP'); + } + } + return errors; +} + +export function buildTemplateImportManifest( + expectedRevision: string, + rows: TemplateUploadRow[], +): AdminImportAgcTemplatesManifest { + return { + expectedRevision, + templates: rows.map((row, index) => { + const item: AdminImportAgcTemplateItemPayload = { + id: row.id.trim(), + title: row.title.trim(), + summary: row.summary.trim(), + tags: parseTemplateTags(row.tags), + runtime: row.runtime, + engine: row.engine.trim(), + engineVersion: row.engineVersion.trim(), + templateVersion: row.templateVersion.trim(), + entry: row.entry.trim(), + zipField: `zip_${index}`, + coverField: `cover_${index}`, + }; + return item; + }), + }; +} + +export function buildTemplateImportFormData( + expectedRevision: string, + rows: TemplateUploadRow[], +): FormData { + const form = new FormData(); + form.append( + 'manifest', + JSON.stringify(buildTemplateImportManifest(expectedRevision, rows)), + ); + rows.forEach((row, index) => { + form.append(`zip_${index}`, row.zipFile, row.zipFile.name); + if (row.coverFile) { + form.append(`cover_${index}`, row.coverFile, row.coverFile.name); + } + }); + return form; +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index fdc22f363..2198adbdf 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -88,10 +88,60 @@ z-index: 1100; } +.admin-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.admin-agc-template-upload-dialog { + border-radius: 10px; + max-width: min(1180px, calc(100vw - 24px)); +} + +.admin-agc-template-upload-pickers { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.admin-agc-template-upload-picker { + display: grid; + gap: 6px; + font-size: 13px; +} + +.admin-agc-template-upload-picker input[type='file'] { + width: 100%; + font-size: 12px; +} + +/* 上传行数多时表格自己滚动,窄屏不撑坏整页布局。 */ +.admin-agc-template-upload-scroll { + max-height: min(52vh, 520px); + overflow: auto; +} + +.admin-agc-template-upload-file { + font-size: 12px; + word-break: break-all; +} + +.admin-agc-template-upload-row-error { + margin-top: 4px; + color: #b3261e; + font-size: 12px; +} + @media (max-width: 680px) { .admin-agc-template-filters { grid-template-columns: minmax(0, 1fr); } + + .admin-agc-template-upload-dialog { + max-width: calc(100vw - 12px); + } } * { @@ -1544,15 +1594,15 @@ button:disabled { } .admin-project-snapshot-table th:first-child { - width: 20%; + width: 18%; } .admin-project-snapshot-table th:nth-child(2) { - width: 14%; + width: 18%; } .admin-project-snapshot-table th:nth-child(3) { - width: 18%; + width: 16%; } .admin-project-snapshot-table th:nth-child(4) { @@ -1639,6 +1689,21 @@ button:disabled { } } +.admin-project-snapshot-pagination { + justify-content: space-between; + gap: 12px; +} + +.admin-project-snapshot-pagination-info { + color: #755a49; + font-size: 13px; + font-weight: 700; +} + +.admin-project-snapshot-pagination .admin-field { + min-width: 92px; +} + .admin-recharge-table { min-width: 1080px; table-layout: fixed; diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 931fec88c..385f52277 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -9,6 +9,7 @@ "dev-stack": "node scripts/start-dev-stack.mjs", "build": "node scripts/build-release.mjs", "release:upload": "node scripts/release-upload.mjs", + "nsis:prepare": "node scripts/ensure-nsis-toolset.mjs", "skill-pack:check": "node scripts/check-skill-pack.mjs", "skill-pack:sync": "node scripts/check-skill-pack.mjs --write", "skill-pack:test": "node --test scripts/check-skill-pack.test.mjs", @@ -75,6 +76,7 @@ "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", "@types/three": "^0.184.1", + "jszip": "^3.10.1", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", "vitest": "^0.34.6" diff --git a/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs b/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs index 2d14f5963..abb218558 100644 --- a/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs +++ b/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs @@ -185,7 +185,12 @@ test('nextVersion 只在 patch 位递增', () => { test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => { const base = { - args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'], + args: [ + 'cp', + '--force', + '/tmp/a.json', + 'oss://agc-dev/agc/global-version.json', + ], endpoint: 'oss-rg-china-mainland.aliyuncs.com', accessKeyId: 'id', accessKeySecret: 'secret', diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index b1f317674..45656fc67 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { inflateSync } from 'node:zlib'; -export const appIdentifier = 'world.genarrative.ai-game-creator'; +import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'; + +// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。 +export const appIdentifier = AGC_APP_IDENTIFIER; export const configFileName = 'game-creator.config.json'; export const localConfigFileName = 'game-creator.config.local.json'; export const runnerEndpointFileName = 'agent-runner.endpoint.json'; diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs index 49bf93abf..67c4ad0b8 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -14,6 +14,7 @@ import { resolveReleasePartition, runTauriBuild, } from './build-release.mjs'; +import { resolveChannelInstallIdentity } from './channel-identity.mjs'; import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; import { readUpdaterPubkey, @@ -39,27 +40,19 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = path.resolve(appRoot, '../..'); /** - * 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。 - * 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。 + * 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置): + * 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后 + * 让入口静默找错对象(清理、打包、归档三处一起失效)。 */ -function readProductName() { - const read = (file) => - JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8')); - const base = read('tauri.conf.json'); - const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json'); - const productName = fs.existsSync(macosPath) - ? (read('tauri.macos.conf.json').productName ?? base.productName) - : base.productName; +function resolveProductName(channel) { + const { productName } = resolveChannelInstallIdentity(channel); assert.ok( typeof productName === 'string' && productName.trim().length > 0, - 'Tauri 配置缺少 productName', + '渠道安装身份缺少 productName', ); return productName; } -const productName = readProductName(); -const appBundleName = `${productName}.app`; -const updaterArtifactName = `${productName}.app.tar.gz`; assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行'); assert.equal( process.env.JENKINS_URL?.length > 0, @@ -102,6 +95,9 @@ process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target'); const macTarget = 'aarch64-apple-darwin'; const context = resolveReleaseContext([`--target=${macTarget}`]); const partition = resolveReleasePartition(context.channel, context.target); +const productName = resolveProductName(context.channel); +const appBundleName = `${productName}.app`; +const updaterArtifactName = `${productName}.app.tar.gz`; const version = await prepareReleaseVersion(context); // 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`, // 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。 diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 92b719262..bd1d5907b 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -13,6 +13,11 @@ import { defaultEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; +import { + resolveChannelInstallIdentity, + resolveReleaseChannel, +} from './channel-identity.mjs'; +import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -88,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock'); const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; -const reservedChannelNames = new Set([ - 'win', - 'mac', - 'windows', - 'macos', - 'darwin', - 'linux', -]); +export { resolveReleaseChannel } from './channel-identity.mjs'; /** * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 @@ -164,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) { throw new Error(`不支持的发布目标:${target}`); } -export function resolveReleaseChannel(env = process.env) { - const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'; - if ( - !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || - channel.endsWith('-') || - reservedChannelNames.has(channel) || - /-(win|mac)$/u.test(channel) - ) { - throw new Error( - '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', - ); - } - return channel; -} - /** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */ export function resolveReleasePartition( channel = resolveReleaseChannel(), @@ -427,12 +410,19 @@ export function buildTauriBuildArguments( ]; } -/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */ +/** + * 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道, + * 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录, + * 不同渠道必须在同一台设备上并存而不是互相顶掉。 + */ export function createChannelConfig( channel = resolveReleaseChannel(), target = defaultTarget(), ) { + const { productName, identifier } = resolveChannelInstallIdentity(channel); return { + productName, + identifier, plugins: { updater: { endpoints: [updateManifestUrl(channel, target)], @@ -877,14 +867,19 @@ export async function buildRelease( args = [], { prepareVersion = prepareReleaseVersion, + prepareToolset = prepareNsisToolsetForRelease, build = runTauriBuild, generateManifest = generateUpdateManifest, } = {}, ) { const context = resolveReleaseContext(args); - if (!args.includes('--no-bundle')) await prepareVersion(context); + const bundling = !args.includes('--no-bundle'); + if (bundling) await prepareVersion(context); + // Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包; + // 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。 + if (bundling) await prepareToolset(context, { bundling }); build(args, context); - if (!args.includes('--no-bundle')) return generateManifest(context); + if (bundling) return generateManifest(context); } if ( diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 59e1a878b..848b74692 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -37,6 +37,11 @@ import { selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; +import { + AGC_APP_IDENTIFIER, + AGC_PRODUCT_NAME, + resolveChannelInstallIdentity, +} from './channel-identity.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; @@ -184,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, plugins: { updater: { endpoints: [ @@ -204,6 +211,68 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }); }); +test('channel install identity isolates co-installed builds and keeps the default channel stable', () => { + // 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。 + assert.deepEqual(resolveChannelInstallIdentity('dev'), { + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, + }); + assert.deepEqual(resolveChannelInstallIdentity('release'), { + productName: '陶泥儿 Release', + identifier: `${AGC_APP_IDENTIFIER}.release`, + }); + assert.deepEqual(resolveChannelInstallIdentity('beta-2'), { + productName: '陶泥儿 Beta-2', + identifier: `${AGC_APP_IDENTIFIER}.beta-2`, + }); + + // 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。 + for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) { + const identity = resolveChannelInstallIdentity(channel); + assert.notEqual(identity.productName, AGC_PRODUCT_NAME); + assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER); + assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)); + } + + for (const channel of ['dev-win', 'Release', 'win', 'beta-']) { + assert.throws( + () => resolveChannelInstallIdentity(channel), + /发布渠道无效/u, + ); + } +}); + +test('channel install identity is baked into the same build-time config as the endpoint', () => { + withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { + const config = createChannelConfig('release', windowsTarget); + assert.equal(config.productName, '陶泥儿 Release'); + assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`); + assert.match( + config.plugins.updater.endpoints[0], + /\/release-win\/latest\.json$/u, + ); + }); +}); + +test('channel products keep first-install selection working under the channel product name', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-')); + try { + const { productName } = resolveChannelInstallIdentity('release'); + const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`); + writeFileSync(dmg, 'channel first installation disk image'); + writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); + assert.equal( + selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], { + target: 'aarch64-apple-darwin', + version: packageVersion, + }), + dmg, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('packaged renderer receives the same channel as the updater manifest', () => { const context = resolveReleaseContext([], { AGC_BUILD_TARGET: windowsTarget, @@ -590,6 +659,71 @@ test('no-bundle smoke skips version writes and manifest generation', async () => assert.deepEqual(steps, ['dev']); }); +test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => { + const events = []; + await buildRelease(['--target', windowsTarget], { + prepareVersion: () => { + events.push('version'); + }, + prepareToolset: (context, options) => { + events.push(`toolset:${context.target}:${options.bundling}`); + }, + build: () => { + events.push('build'); + }, + generateManifest: () => { + events.push('manifest'); + }, + }); + assert.deepEqual(events, [ + 'version', + `toolset:${windowsTarget}:true`, + 'build', + 'manifest', + ]); +}); + +test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => { + const events = []; + await assert.rejects( + buildRelease(['--target', windowsTarget], { + prepareVersion: () => { + events.push('version'); + }, + prepareToolset: () => { + throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败'); + }, + build: () => { + events.push('build'); + }, + generateManifest: () => { + events.push('manifest'); + }, + }), + /NSIS 工具链预置失败/u, + ); + assert.deepEqual(events, ['version']); +}); + +test('--no-bundle 不预置 NSIS 工具链', async () => { + const steps = []; + await buildRelease(['--no-bundle', '--target', windowsTarget], { + prepareVersion: () => { + steps.push('version'); + }, + prepareToolset: () => { + steps.push('toolset'); + }, + build: () => { + steps.push('build'); + }, + generateManifest: () => { + steps.push('manifest'); + }, + }); + assert.deepEqual(steps, ['build']); +}); + test('release stages Node before Tauri and injects its resource mapping only for bundles', () => { const context = resolveReleaseContext(['--target', windowsTarget]); const events = []; diff --git a/apps/ai-game-creator-shell/scripts/channel-identity.mjs b/apps/ai-game-creator-shell/scripts/channel-identity.mjs new file mode 100644 index 000000000..5de68db22 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/channel-identity.mjs @@ -0,0 +1,73 @@ +/** + * AGC 渠道 → 安装身份。 + * + * 渠道同时决定两件事: + * - 更新端点:OSS 分区 `-win` / `-mac` 的清单地址; + * - 安装身份:`productName` 与 `identifier`。 + * + * 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、 + * Windows WebView2 数据目录以及 `%APPDATA%\` 客户端数据目录。 + * 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、 + * 本地项目与运行锁。 + * + * 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。 + */ + +export const AGC_DEFAULT_CHANNEL = 'dev'; +export const AGC_PRODUCT_NAME = '陶泥儿'; +export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator'; + +const reservedChannelNames = new Set([ + 'win', + 'mac', + 'windows', + 'macos', + 'darwin', + 'linux', +]); + +/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */ +export function validateReleaseChannel(channel) { + if ( + typeof channel !== 'string' || + !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || + channel.endsWith('-') || + reservedChannelNames.has(channel) || + /-(win|mac)$/u.test(channel) + ) { + throw new Error( + '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', + ); + } + return channel; +} + +export function resolveReleaseChannel(env = process.env) { + return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'); +} + +/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */ +export function channelDisplaySuffix(channel) { + return validateReleaseChannel(channel) + .split('-') + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join('-'); +} + +/** + * 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀, + * 保证同一台设备上不同渠道互不覆盖。 + */ +export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) { + validateReleaseChannel(channel); + if (channel === AGC_DEFAULT_CHANNEL) { + return Object.freeze({ + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, + }); + } + return Object.freeze({ + productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`, + identifier: `${AGC_APP_IDENTIFIER}.${channel}`, + }); +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index df9518a8d..eaba4e582 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -27,6 +27,11 @@ import { appIdentifier, defaultRealSwarmTestTask, } from './agent-swarm-test-chat.mjs'; +import { + AGC_APP_IDENTIFIER, + AGC_PRODUCT_NAME, + resolveChannelInstallIdentity, +} from './channel-identity.mjs'; import { askHidden, assertSafeGameCreatorConfigDestination, @@ -102,10 +107,6 @@ const appInvokeSources = readSourceFiles( new URL('../src/', import.meta.url), new Set(['.ts', '.tsx']), ); -const appEntrypointSource = fs.readFileSync( - new URL('../src/main.tsx', import.meta.url), - 'utf8', -); const tauriHandlerSource = fs.readFileSync( new URL('../src-tauri/src/main.rs', import.meta.url), 'utf8', @@ -130,6 +131,45 @@ const rustSharedContractSource = fs.readFileSync( ); const allowedUncalledTauriCommands = [ 'append_direct_project_conversation_message', + // Supervisor 调试窗口、开发者面板、专业 Agent 对话与旧命令聊天的前端调用方已随 + // Supervisor 前端链路整体删除;命令本身仍注册在 Rust 侧并由 native Runtime、CLI + // swarm 与 Rust 测试使用,保留 present,仅不再出现在 App 前端源码里。 + 'answer_game_creator_agent_runtime_user_input', + 'cancel_game_creator_agent_runtime_task', + 'chat_with_game_creator_role_agent', + 'chat_with_game_creator_role_agent_stream', + 'check_game_creator_llm_config', + 'confirm_game_creator_agent_runtime_task', + 'diff_local_project_checkpoint', + 'get_game_creation_agent_capabilities', + 'get_limited_local_commands', + 'list_local_project_export_packages', + 'read_game_creator_agent_runtime', + 'read_local_agent_memory', + 'read_local_game_memory', + 'reject_game_creator_agent_runtime_task', + 'retry_game_creator_agent_runtime_task', + 'schedule_game_creator_agent_ready_tasks', + 'start_game_creator_agent_runtime_task', + 'steer_game_creator_agent_runtime_task', + 'write_local_agent_memory', + 'write_local_game_memory', + 'write_local_project_file', + // Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。 + 'archive_game_creator_agent_session', + 'clear_game_creator_agent_goal', + 'compact_game_creator_agent_runtime_context', + 'confirm_retry_game_creator_agent_runtime_task', + 'create_game_creator_agent_session', + 'edit_game_creator_agent_goal', + 'fork_game_creator_agent_session', + 'list_game_creator_agent_sessions', + 'pause_game_creator_agent_goal', + 'read_game_creator_agent_goal', + 'resume_game_creator_agent_goal', + 'set_active_game_creator_agent_session', + 'start_game_creator_agent_goal', + 'start_game_creator_supervisor_runtime_task', // TODO: Remove the retired binding command after the legacy runtime path is removed. 'bind_components', 'chat_with_game_creator_agent', @@ -159,6 +199,26 @@ const allowedUncalledTauriCommands = [ 'call_agc_plugin', 'read_agc_plugin_panel', 'set_agc_plugin_enabled', + // 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口; + // 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 / + // 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现, + // `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按 + // native-only 登记,避免孤儿检查一直报错。 + // 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。 + 'build_local_project_index', + 'control_agent_run', + 'create_local_project_checkpoint', + 'export_local_project_package', + 'generate_local_game_draft', + 'generate_platform_art_asset', + 'import_canvas_asset', + 'import_canvas_export', + 'open_canvas_project', + 'register_local_asset', + 'restore_local_project_checkpoint', + 'run_limited_local_command', + 'sync_canvas_project_assets', + 'write_project_permission_policy', ]; const sourceExtensions = new Set([ '.json', @@ -1308,7 +1368,8 @@ if ( } for (const requiredSource of [ - "export const appIdentifier = 'world.genarrative.ai-game-creator'", + "import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'", + 'export const appIdentifier = AGC_APP_IDENTIFIER', "'--swarm-chat'", "'--autonomous-game-build'", "'--preview-serve'", @@ -1319,14 +1380,38 @@ for (const requiredSource of [ } } -if (tauriConfig.productName !== '陶泥儿') { +// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端 +// 的升级链路与既有安装目录都会断开。 +const defaultChannelIdentity = resolveChannelInstallIdentity('dev'); +if (tauriConfig.productName !== AGC_PRODUCT_NAME) { throw new Error('AI game creator shell productName drifted'); } -if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') { +if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) { throw new Error('AI game creator shell identifier drifted'); } +if ( + tauriConfig.productName !== defaultChannelIdentity.productName || + tauriConfig.identifier !== defaultChannelIdentity.identifier +) { + throw new Error( + 'AI game creator shell baseline config must match the default channel identity', + ); +} + +// 非默认渠道必须派生出独立安装身份,否则同机安装会互相顶掉。 +for (const channel of ['release', 'beta-2']) { + const identity = resolveChannelInstallIdentity(channel); + if ( + identity.productName === defaultChannelIdentity.productName || + identity.identifier === defaultChannelIdentity.identifier || + !identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`) + ) { + throw new Error(`channel install identity not isolated: ${channel}`); + } +} + const expectedBundledDesignAgentResources = { 'design-agent': 'design-agent', ...Object.fromEntries( @@ -1440,13 +1525,7 @@ const eventCapability = JSON.parse( ); const eventCapabilityWindows = new Set(eventCapability.windows ?? []); const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []); -for (const windowLabel of [ - 'client', - 'developer', - 'main', - 'launcher', - 'supervisor-chat', -]) { +for (const windowLabel of ['client', 'main', 'launcher']) { if (!eventCapabilityWindows.has(windowLabel)) { throw new Error( `AI game creator shell event capability missing window: ${windowLabel}`, @@ -1829,20 +1908,6 @@ if ( ); } -for (const snippet of [ - 'import.meta.env.DEV', - 'supervisorChatMode', - 'supervisorChatOnly', - 'open_project_supervisor_chat_window', - 'index.html?supervisor-chat&projectPath=', -]) { - if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) { - throw new Error( - `AI game creator shell developer window guardrail drifted: ${snippet}`, - ); - } -} - if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) { throw new Error( 'AI game creator normal startup must not automatically open the developer window', @@ -1875,31 +1940,17 @@ for (const snippet of [ '官方账号服务(固定)', 'runtime_config.save', "'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'", - "'activate_local_game_preview'", - '已切换到客户端运行视图', 'async function executeRunLocal', 'function needsInitializedChatProject', - 'function resolvePendingCommandProjectPath', - 'resolveChatProjectPath(localProject) ?? draftProjectPath', - '`permission.cancel ${command.id} missing-project`', "'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'", "'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'", 'function parseRememberInput', "'/trace 或 /loop:查看最近一次 Agent loop trace'", - 'async function executeAgentTraceChat', - "relativePath: '.agent/logs/command.log'", "'permission.pending'", "'permission.confirm'", "'permission.cancel'", "'command.auto'", - "'agent.run_status'", 'function summarizeAgentRunTrace', - '工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}', - 'agentRunTrace.error ?', - 'className="trace-error"', - 'agentRunTrace.taskGraph.repairRoutes.map', - "in: ${step.inputPaths.join(', ') || 'none'}", - "out: ${step.outputPaths.join(', ') || 'none'}", ]) { if (!appSource.includes(snippet)) { throw new Error( diff --git a/apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs b/apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs new file mode 100644 index 000000000..a8f35a453 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs @@ -0,0 +1,28 @@ +// Jenkins Windows 预检入口:在数分钟的 Rust 编译之前完成 NSIS 工具链预置。 +// +// 预置失败必须在此之前失败关闭,避免 bundler 用 `io: unexpected end of file` +// 把网络问题伪装成打包问题。 + +import { ensureNsisToolset, LOG_PREFIX } from './nsis-toolset.mjs'; + +// Jenkins 阶段用 `$ErrorActionPreference = 'Stop'` 执行 Powershell:重试告警走 +// stderr 时可能被 PowerShell 当成终止错误,因此重试与进度一律写 stdout,只有 +// 最终失败才写 stderr 并以退出码 1 失败关闭。 +const logger = { + log: (message) => console.log(message), + warn: (message) => console.log(`${message}(将重试)`), +}; + +try { + const result = await ensureNsisToolset({ logger }); + console.log(`${LOG_PREFIX} NSIS 工具链目录:${result.nsisDir}`); + console.log(`${LOG_PREFIX} NSIS 原始归档缓存:${result.cacheDir}`); + console.log( + result.reused + ? `${LOG_PREFIX} NSIS 工具链复用已有目录,未访问网络` + : `${LOG_PREFIX} NSIS 工具链本次预置:${result.downloaded.join('、')}`, + ); +} catch (error) { + console.error(`${LOG_PREFIX} NSIS 工具链预置失败:${error.message}`); + process.exit(1); +} diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs new file mode 100644 index 000000000..d79c9f8a7 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs @@ -0,0 +1,342 @@ +// Tauri Windows bundler 的 NSIS 工具链预置。 +// +// 背景:`tauri build` 打 Windows NSIS 包时会现场从 GitHub 下载 `nsis-3.11.zip` +// 与 `nsis_tauri_utils.dll`(见 tauri-bundler `bundle/windows/nsis/mod.rs`)。 +// 构建机每个检出(`git clean -fdx`)都会丢掉 `target/.tauri` 缓存,于是每次 +// 发布都要重新下载;响应一旦被截断,bundler 只会报 `io: unexpected end of file`, +// 整条流水线在 Rust 编译数分钟之后才失败。 +// +// 这里在打包前用固定哈希 + 重试预置同一份工具链目录:bundler 检查到必需文件齐全 +// 且 `nsis_tauri_utils.dll` 哈希一致后就不会再自行下载。原始归档(两个文件) +// 额外缓存在工作区之外,构建机重复构建时不再依赖 GitHub 连通性。 + +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import JSZip from 'jszip'; + +export const LOG_PREFIX = '[ai-game-creator-shell]'; + +/** bundler 把工具链解到 `/.tauri/NSIS`(`bundle.useLocalToolsDir: true`)。 */ +export const NSIS_TOOLSET_DIR_NAME = 'NSIS'; + +export const NSIS_ARCHIVE_ASSET_NAME = 'nsis-3.11.zip'; +export const NSIS_ARCHIVE_URL = + 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip'; +export const NSIS_ARCHIVE_SHA1 = 'ef7ff767e5cbd9edd22add3a32c9b8f4500bb10d'; +export const NSIS_ARCHIVE_TOP_LEVEL_DIR = 'nsis-3.11'; + +export const NSIS_TAURI_UTILS_ASSET_NAME = 'nsis_tauri_utils.dll'; +export const NSIS_TAURI_UTILS_URL = + 'https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll'; +export const NSIS_TAURI_UTILS_SHA1 = '75197fee3c6a814fe035788d1c34ead39349b860'; +export const NSIS_TAURI_UTILS_REQUIRED_FILE = + 'Plugins/x86-unicode/additional/nsis_tauri_utils.dll'; + +/** + * 与 tauri-bundler 2.9.x 的 `NSIS_REQUIRED_FILES` 逐条对齐:少一条 bundler 就会 + * 删掉整个目录重新下载,等于预置失效。升级 `@tauri-apps/cli` 时要同步核对。 + */ +export const NSIS_REQUIRED_FILES = [ + 'makensis.exe', + 'Bin/makensis.exe', + 'Stubs/lzma-x86-unicode', + 'Stubs/lzma_solid-x86-unicode', + NSIS_TAURI_UTILS_REQUIRED_FILE, + 'Include/MUI2.nsh', + 'Include/FileFunc.nsh', + 'Include/x64.nsh', + 'Include/nsDialogs.nsh', + 'Include/WinMessages.nsh', + 'Include/Win/COM.nsh', + 'Include/Win/Propkey.nsh', + 'Include/Win/RestartManager.nsh', +]; + +/** 需要预置的原始归档;测试可注入同结构描述替换其中的地址与哈希。 */ +export const NSIS_ASSETS = [ + { + assetName: NSIS_ARCHIVE_ASSET_NAME, + url: NSIS_ARCHIVE_URL, + sha1: NSIS_ARCHIVE_SHA1, + }, + { + assetName: NSIS_TAURI_UTILS_ASSET_NAME, + url: NSIS_TAURI_UTILS_URL, + sha1: NSIS_TAURI_UTILS_SHA1, + }, +]; + +const DEFAULT_DOWNLOAD_ATTEMPTS = 4; +const DEFAULT_RETRY_DELAY_MS = 3000; +const DEFAULT_DOWNLOAD_TIMEOUT_MS = 180_000; + +export function defaultAppRoot() { + return fileURLToPath(new URL('..', import.meta.url)); +} + +export function resolveTauriToolsDir(appRoot = defaultAppRoot()) { + // 必须与 `src-tauri/tauri.windows.conf.json` 的 `bundle.useLocalToolsDir: true` + // 保持一致,否则预置的文件不在 bundler 的查找路径上。 + return path.join(appRoot, 'src-tauri', 'target', '.tauri'); +} + +export function resolveNsisCacheDir( + env = process.env, + platform = process.platform, +) { + const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim(); + if (explicit) return path.resolve(explicit); + // Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响; + // 缓存里只有待解压的原始归档,不会从该目录执行任何程序。 + if (platform === 'win32') { + const programData = env.ProgramData?.trim() || 'C:\\ProgramData'; + return path.join(programData, 'genarrative', 'tauri-nsis-cache'); + } + return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache'); +} + +/** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */ +export function resolveDownloadUrl(url, env = process.env) { + if (!url.startsWith('https://github.com/')) return url; + const template = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE?.trim(); + const match = + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/download\/([^/]+)\/(.+)$/u.exec( + url, + ); + if (template && match) { + return template + .replaceAll('', match[1]) + .replaceAll('', match[2]) + .replaceAll('', match[3]) + .replaceAll('', match[4]); + } + const base = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR?.trim(); + if (base) return `${base.replace(/\/+$/u, '')}/${url}`; + return url; +} + +export function sha1Of(data) { + return createHash('sha1').update(data).digest('hex'); +} + +function sha1OfFile(filePath) { + try { + return sha1Of(fs.readFileSync(filePath)); + } catch { + return null; + } +} + +export function verifyNsisToolset( + nsisDir, + { utilsSha1 = NSIS_TAURI_UTILS_SHA1 } = {}, +) { + const missing = NSIS_REQUIRED_FILES.filter( + (relativePath) => !fs.existsSync(path.join(nsisDir, relativePath)), + ); + const hashMismatch = + missing.length === 0 && + sha1OfFile(path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE)) !== + utilsSha1; + return { ok: missing.length === 0 && !hashMismatch, missing, hashMismatch }; +} + +/** 解析 zip 条目落盘位置,并拒绝 `../` 这类越界路径。 */ +export function resolveArchiveEntryTarget(rootDir, entryName) { + const root = path.resolve(rootDir); + const target = path.resolve(root, entryName); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error(`NSIS 归档包含越界路径:${entryName}`); + } + return target; +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function downloadBuffer(url, { fetchImpl, timeoutMs }) { + const response = await fetchImpl(url, { + redirect: 'follow', + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`.trim()); + } + const data = Buffer.from(await response.arrayBuffer()); + if (data.length === 0) throw new Error('响应为空'); + return data; +} + +async function downloadVerifiedAsset({ + assetName, + url, + sha1, + env, + fetchImpl, + attempts, + retryDelayMs, + timeoutMs, + logger, +}) { + const downloadUrl = resolveDownloadUrl(url, env); + let lastError; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const data = await downloadBuffer(downloadUrl, { fetchImpl, timeoutMs }); + const actual = sha1Of(data); + if (actual !== sha1) { + throw new Error(`SHA1 不匹配(期望 ${sha1},实际 ${actual})`); + } + logger.log( + `${LOG_PREFIX} NSIS 工具链:已下载 ${assetName}(${data.length} 字节,第 ${attempt} 次尝试)`, + ); + return data; + } catch (error) { + lastError = error; + logger.warn( + `${LOG_PREFIX} NSIS 工具链:下载 ${assetName} 失败(第 ${attempt}/${attempts} 次):${error.message}`, + ); + if (attempt < attempts) await sleep(retryDelayMs * attempt); + } + } + throw new Error( + `下载 ${assetName} 失败(已重试 ${attempts} 次):${lastError?.message ?? '未知错误'}\n` + + `下载地址:${downloadUrl}\n` + + `可先把该文件放入缓存目录(AGC_TAURI_NSIS_CACHE_DIR)或配置 ` + + `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE 后重试。`, + ); +} + +async function ensureCachedAsset(options) { + const { assetName, sha1, cacheDir, logger } = options; + const cachePath = path.join(cacheDir, assetName); + if (sha1OfFile(cachePath) === sha1) { + logger.log(`${LOG_PREFIX} NSIS 工具链:命中缓存 ${cachePath}`); + return cachePath; + } + if (fs.existsSync(cachePath)) { + logger.warn( + `${LOG_PREFIX} NSIS 工具链:缓存文件校验失败,重新下载 ${cachePath}`, + ); + } + const data = await downloadVerifiedAsset(options); + fs.mkdirSync(cacheDir, { recursive: true }); + const tempPath = `${cachePath}.tmp-${process.pid}`; + fs.writeFileSync(tempPath, data); + fs.rmSync(cachePath, { force: true }); + fs.renameSync(tempPath, cachePath); + return cachePath; +} + +export async function extractNsisArchive(archivePath, destinationDir) { + const archive = await JSZip.loadAsync(fs.readFileSync(archivePath)); + for (const [entryName, entry] of Object.entries(archive.files)) { + if (entry.dir) continue; + const target = resolveArchiveEntryTarget(destinationDir, entryName); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, await entry.async('nodebuffer')); + } +} + +/** + * 预置 `target/.tauri/NSIS`:已就绪时零网络直接返回,否则用缓存或重试下载补齐。 + * 返回结构用于测试与日志,不参与发布产物。 + */ +export async function ensureNsisToolset({ + appRoot = defaultAppRoot(), + toolsDir = resolveTauriToolsDir(appRoot), + cacheDir = resolveNsisCacheDir(process.env), + env = process.env, + fetchImpl = globalThis.fetch, + assets = NSIS_ASSETS, + attempts = DEFAULT_DOWNLOAD_ATTEMPTS, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS, + logger = console, +} = {}) { + const nsisDir = path.join(toolsDir, NSIS_TOOLSET_DIR_NAME); + // 生产路径下这里恒等于 bundler 固定的 `nsis_tauri_utils.dll` SHA1;测试注入 + // 自己的归档描述时,校验口径必须与被注入的资产一致。 + const missingAsset = [ + NSIS_ARCHIVE_ASSET_NAME, + NSIS_TAURI_UTILS_ASSET_NAME, + ].find((assetName) => !assets.some((asset) => asset.assetName === assetName)); + if (missingAsset) throw new Error(`NSIS 资产描述缺少 ${missingAsset}`); + const utilsSha1 = + assets.find((asset) => asset.assetName === NSIS_TAURI_UTILS_ASSET_NAME) + ?.sha1 ?? NSIS_TAURI_UTILS_SHA1; + const existing = verifyNsisToolset(nsisDir, { utilsSha1 }); + if (existing.ok) { + logger.log(`${LOG_PREFIX} NSIS 工具链已就绪:${nsisDir}`); + return { nsisDir, toolsDir, cacheDir, reused: true, downloaded: [] }; + } + logger.log( + `${LOG_PREFIX} NSIS 工具链需要预置:${nsisDir}` + + (existing.missing.length > 0 + ? `(缺少 ${existing.missing.length} 个文件)` + : '(哈希不符)'), + ); + + const downloadOptions = { + env, + fetchImpl, + attempts, + retryDelayMs, + timeoutMs, + cacheDir, + logger, + }; + const assetPaths = {}; + for (const asset of assets) { + assetPaths[asset.assetName] = await ensureCachedAsset({ + ...asset, + ...downloadOptions, + }); + } + + fs.rmSync(nsisDir, { recursive: true, force: true }); + await extractNsisArchive(assetPaths[NSIS_ARCHIVE_ASSET_NAME], toolsDir); + const extractedDir = path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR); + if (!fs.existsSync(extractedDir)) { + throw new Error( + `NSIS 归档结构不符合预期:${assetPaths[NSIS_ARCHIVE_ASSET_NAME]} 未解出 ${NSIS_ARCHIVE_TOP_LEVEL_DIR}`, + ); + } + fs.renameSync(extractedDir, nsisDir); + + const utilsTarget = path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE); + fs.mkdirSync(path.dirname(utilsTarget), { recursive: true }); + fs.copyFileSync(assetPaths[NSIS_TAURI_UTILS_ASSET_NAME], utilsTarget); + + const installed = verifyNsisToolset(nsisDir, { utilsSha1 }); + if (!installed.ok) { + throw new Error( + `NSIS 工具链预置不完整:缺少 ${installed.missing.join(', ') || '无'};` + + `哈希不符=${installed.hashMismatch}`, + ); + } + logger.log(`${LOG_PREFIX} NSIS 工具链预置完成:${nsisDir}`); + return { + nsisDir, + toolsDir, + cacheDir, + reused: false, + downloaded: assets.map((asset) => asset.assetName), + }; +} + +/** `buildRelease` 用:只在 Windows 目标且需要打包时预置 NSIS 工具链。 */ +export async function prepareNsisToolsetForRelease( + context, + { bundling = true, ...deps } = {}, +) { + if (!bundling || !context.target.includes('windows')) return null; + return ensureNsisToolset(deps); +} diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs new file mode 100644 index 000000000..26faca27e --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs @@ -0,0 +1,331 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import JSZip from 'jszip'; + +import { + ensureNsisToolset, + extractNsisArchive, + NSIS_ARCHIVE_ASSET_NAME, + NSIS_ARCHIVE_TOP_LEVEL_DIR, + NSIS_REQUIRED_FILES, + NSIS_TAURI_UTILS_ASSET_NAME, + NSIS_TOOLSET_DIR_NAME, + prepareNsisToolsetForRelease, + resolveArchiveEntryTarget, + resolveDownloadUrl, + resolveNsisCacheDir, + resolveTauriToolsDir, + sha1Of, + verifyNsisToolset, +} from './nsis-toolset.mjs'; + +const appRoot = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + '..', +); +const silentLogger = { log() {}, warn() {} }; + +function createSandbox() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'agc-nsis-toolset-')); +} + +/** 与真实归档同构的最小 zip:只保留 bundler 必需文件。 */ +async function createArchiveFixture(extraEntries = {}) { + const zip = new JSZip(); + for (const relativePath of NSIS_REQUIRED_FILES) { + zip.file( + `${NSIS_ARCHIVE_TOP_LEVEL_DIR}/${relativePath}`, + `fixture:${relativePath}`, + ); + } + for (const [name, contents] of Object.entries(extraEntries)) { + zip.file(name, contents); + } + return zip.generateAsync({ type: 'nodebuffer' }); +} + +function fixtureAssets({ archive, utils }) { + return [ + { + assetName: NSIS_ARCHIVE_ASSET_NAME, + url: `https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/${NSIS_ARCHIVE_ASSET_NAME}`, + sha1: sha1Of(archive), + }, + { + assetName: NSIS_TAURI_UTILS_ASSET_NAME, + url: `https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/${NSIS_TAURI_UTILS_ASSET_NAME}`, + sha1: sha1Of(utils), + }, + ]; +} + +function fixtureFetch({ archive, utils, failures = 0 }) { + let remainingFailures = failures; + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + if (remainingFailures > 0) { + remainingFailures -= 1; + throw new Error('network truncated'); + } + const body = url.includes(NSIS_TAURI_UTILS_ASSET_NAME) ? utils : archive; + return { + ok: true, + status: 200, + statusText: 'OK', + arrayBuffer: async () => body, + }; + }; + return { fetchImpl, calls }; +} + +test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => { + const config = JSON.parse( + fs.readFileSync( + path.join(appRoot, 'src-tauri', 'tauri.windows.conf.json'), + 'utf8', + ), + ); + assert.equal(config.bundle.useLocalToolsDir, true); + assert.equal( + resolveTauriToolsDir(appRoot), + path.join(appRoot, 'src-tauri', 'target', '.tauri'), + ); +}); + +test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => { + assert.equal( + resolveNsisCacheDir( + { AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' }, + 'linux', + ), + '/tmp/agc-cache', + ); + assert.equal( + resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'), + path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'), + ); + assert.ok( + resolveNsisCacheDir({}, 'linux').endsWith( + path.join('.cache', 'genarrative', 'tauri-nsis-cache'), + ), + ); +}); + +test('下载地址支持 tauri bundler 的两套 GitHub 镜像开关', () => { + const url = + 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip'; + assert.equal(resolveDownloadUrl(url, {}), url); + assert.equal( + resolveDownloadUrl(url, { + TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE: + 'https://mirror.example.com////', + }), + 'https://mirror.example.com/tauri-apps/binary-releases/nsis-3.11/nsis-3.11.zip', + ); + assert.equal( + resolveDownloadUrl(url, { + TAURI_BUNDLER_TOOLS_GITHUB_MIRROR: 'https://mirror.example.com/', + }), + `https://mirror.example.com/${url}`, + ); +}); + +test('工具链已就绪时零下载复用', async () => { + const sandbox = createSandbox(); + const toolsDir = path.join(sandbox, '.tauri'); + const cacheDir = path.join(sandbox, 'cache'); + const archive = await createArchiveFixture(); + const utils = Buffer.from('nsis-tauri-utils-dll'); + const assets = fixtureAssets({ archive, utils }); + + await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl: fixtureFetch({ archive, utils }).fetchImpl, + logger: silentLogger, + }); + + let fetchCalls = 0; + const reused = await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl: async () => { + fetchCalls += 1; + throw new Error('工具链已就绪时不应访问网络'); + }, + logger: silentLogger, + }); + assert.equal(reused.reused, true); + assert.deepEqual(reused.downloaded, []); + assert.equal(fetchCalls, 0); + assert.equal(reused.nsisDir, path.join(toolsDir, NSIS_TOOLSET_DIR_NAME)); +}); + +test('冷启动时下载、校验、解压并落缓存,重跑走缓存', async () => { + const sandbox = createSandbox(); + const toolsDir = path.join(sandbox, '.tauri'); + const cacheDir = path.join(sandbox, 'cache'); + const archive = await createArchiveFixture(); + const utils = Buffer.from('nsis-tauri-utils-dll'); + const assets = fixtureAssets({ archive, utils }); + const { fetchImpl, calls } = fixtureFetch({ archive, utils }); + + const result = await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl, + logger: silentLogger, + }); + assert.deepEqual(calls.length, 2); + assert.deepEqual(result.downloaded, [ + NSIS_ARCHIVE_ASSET_NAME, + NSIS_TAURI_UTILS_ASSET_NAME, + ]); + assert.equal( + verifyNsisToolset(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), { + utilsSha1: sha1Of(utils), + }).ok, + true, + ); + assert.equal( + fs.readFileSync( + path.join( + toolsDir, + NSIS_TOOLSET_DIR_NAME, + 'Plugins/x86-unicode/additional/nsis_tauri_utils.dll', + ), + 'utf8', + ), + 'nsis-tauri-utils-dll', + ); + + // 第二次构建:清空工作区工具目录后仍应零下载恢复(模拟 Jenkins git clean -fdx)。 + fs.rmSync(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), { + recursive: true, + force: true, + }); + const offline = await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl: async () => { + throw new Error('命中缓存时不应访问网络'); + }, + logger: silentLogger, + }); + assert.equal( + verifyNsisToolset(offline.nsisDir, { utilsSha1: sha1Of(utils) }).ok, + true, + ); +}); + +test('下载失败按次数重试,最终成功', async () => { + const sandbox = createSandbox(); + const archive = await createArchiveFixture(); + const utils = Buffer.from('dll'); + const { fetchImpl, calls } = fixtureFetch({ archive, utils, failures: 2 }); + const result = await ensureNsisToolset({ + toolsDir: path.join(sandbox, '.tauri'), + cacheDir: path.join(sandbox, 'cache'), + assets: fixtureAssets({ archive, utils }), + fetchImpl, + attempts: 3, + retryDelayMs: 1, + logger: silentLogger, + }); + assert.equal(result.downloaded.length, 2); + assert.equal(calls.length, 4); +}); + +test('哈希不匹配时报错并给出可操作提示', async () => { + const sandbox = createSandbox(); + const { fetchImpl } = fixtureFetch({ + archive: Buffer.from('corrupted'), + utils: Buffer.from('corrupted'), + }); + await assert.rejects( + ensureNsisToolset({ + toolsDir: path.join(sandbox, '.tauri'), + cacheDir: path.join(sandbox, 'cache'), + assets: [ + { + assetName: NSIS_ARCHIVE_ASSET_NAME, + url: 'https://github.com/a/b/releases/download/1/n.zip', + sha1: 'deadbeef', + }, + { + assetName: NSIS_TAURI_UTILS_ASSET_NAME, + url: 'https://github.com/a/b/releases/download/1/n.dll', + sha1: 'deadbeef', + }, + ], + fetchImpl, + attempts: 2, + retryDelayMs: 1, + logger: silentLogger, + }), + /SHA1 不匹配/u, + ); + assert.equal( + fs.existsSync(path.join(sandbox, 'cache', NSIS_ARCHIVE_ASSET_NAME)), + false, + ); +}); + +test('归档越界路径与缺失必需文件都会失败关闭', async () => { + const sandbox = createSandbox(); + assert.throws( + () => + resolveArchiveEntryTarget(path.join(sandbox, 'extract'), '../escape.txt'), + /越界路径/u, + ); + assert.equal( + resolveArchiveEntryTarget(path.join(sandbox, 'extract'), 'nsis-3.11/a/b'), + path.resolve(sandbox, 'extract', 'nsis-3.11/a/b'), + ); + + const emptyArchive = new JSZip() + .file(`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/makensis.exe`, 'only-one') + .generateAsync({ type: 'nodebuffer' }); + const emptyPath = path.join(sandbox, 'incomplete.zip'); + fs.writeFileSync(emptyPath, await emptyArchive); + const toolsDir = path.join(sandbox, 'incomplete-tools'); + await extractNsisArchive(emptyPath, toolsDir); + const status = verifyNsisToolset( + path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR), + ); + assert.equal(status.ok, false); + assert.ok(status.missing.includes('Bin/makensis.exe')); +}); + +test('非 Windows 目标或 --no-bundle 不预置工具链', async () => { + let called = 0; + const deps = { + ensure: async () => { + called += 1; + }, + }; + assert.equal( + await prepareNsisToolsetForRelease( + { target: 'x86_64-pc-windows-msvc' }, + { bundling: false, ...deps }, + ), + null, + ); + assert.equal( + await prepareNsisToolsetForRelease( + { target: 'aarch64-apple-darwin' }, + deps, + ), + null, + ); + assert.equal(called, 0); +}); diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs index b62fe76a1..fc3546be7 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -174,8 +174,16 @@ test('macOS release entry and smoke script derive product names from config and new URL('./build-macos-ci.mjs', import.meta.url), 'utf8', ); - // 产品名决定 *.app、updater 归档与 DMG 卷名:写死会在改名后静默找错对象。 - assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名'); + // 产品名决定 *.app、updater 归档与 DMG 卷名:它必须从渠道安装身份派生, + // 写死会在换渠道或改名后静默找错对象。 + assert.ok( + entry.includes('resolveChannelInstallIdentity'), + '入口必须从渠道安装身份派生产品名', + ); + assert.ok( + entry.includes('resolveProductName(context.channel)'), + '产品名必须按当前发布渠道解析', + ); assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名'); assert.ok( entry.includes("const macTarget = 'aarch64-apple-darwin'"), diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 68fc0909a..20cf4a1ea 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -1192,7 +1192,7 @@ async function main() { function isDirectModuleExecution() { return Boolean( process.argv[1] && - resolve(process.argv[1]) === fileURLToPath(import.meta.url), + resolve(process.argv[1]) === fileURLToPath(import.meta.url), ); } diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/developer.json b/apps/ai-game-creator-shell/src-tauri/capabilities/developer.json deleted file mode 100644 index ca9fdae42..000000000 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/developer.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "developer", - "description": "开发窗口允许打开本地素材选择对话框。", - "windows": ["developer"], - "permissions": ["dialog:allow-open"] -} diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/events.json b/apps/ai-game-creator-shell/src-tauri/capabilities/events.json index 8e31538b8..fa5b4f1d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/events.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/events.json @@ -2,6 +2,6 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "events", "description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。", - "windows": ["client", "developer", "main", "launcher", "supervisor-chat"], + "windows": ["client", "main", "launcher"], "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"] } diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/window-chrome.json b/apps/ai-game-creator-shell/src-tauri/capabilities/window-chrome.json index a23607f6f..f7d700272 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/window-chrome.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/window-chrome.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "window-chrome", "description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。", - "windows": ["client", "developer", "main", "launcher", "supervisor-chat"], + "windows": ["client", "main", "launcher"], "permissions": [ "core:window:allow-close", "core:window:allow-is-maximized", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json index b452cdca0..d259be149 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json @@ -13,7 +13,7 @@ "taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求", "taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate", "agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。", - "agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可", + "agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,去除首尾空白后的描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令", "agc_generate_image.parameters.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图", "agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称", "agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json index d0a2ce412..bb96a7205 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json @@ -23,8 +23,6 @@ "icon_spec_generation": "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}", "ui_design_generation": "根据下方当前项目 UI 需求生成一张完整的游戏 UI/UX 原型图,玩法与界面结构以这些需求为准。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言。\n\n当前项目 UI 需求:{}", "scene_generation": "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}", - "default_art_brief": "需要一张可直接用于 Web 小游戏首版原型的核心美术素材。", - "spritesheet_generation": "为 Web 小游戏首版原型生成一张可切分的原创透明核心美术素材图集,适合放入本地 assets 并被游戏直接引用。严格从用户需求和美术 brief 提取当前项目自己的标题、玩法实体、目标物、收集物、障碍、状态与反馈,素材类别与数量以当前项目需求为准。所有元素沿用当前规范图的轮廓、配色、材质和光照,分区排布并留出清楚切分间距。角色轮廓、图标排布与配色采用项目原创设计。\n用户需求:{}\n美术资产 brief:{}", "ui_inspection_focus": "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、概念图、地图、海报或只展示角色而没有可玩界面的插画必须判定失败;按当前项目的玩法识别界面结构与关键要素。逐项检查:informationHud=清楚显示当前玩法需要的分数、资源、生命、关卡或局内状态;gameplaySurface=主要可玩区域及空间规则清楚;objectiveEntities=玩家主体、目标/收集/危险物、谜题或文本选项、轨道等当前玩法等价关键要素可辨;primaryControls=当前玩法需要的开始、移动、暂停或操作控件清楚;failureRestartFlow=存在可识别的结束态表现意图或明确重开入口;responsiveLayout=能从可见布局、触控目标和可重排分组判断移动适配意图,实际双视口另由浏览器验证;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSS;originalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,字段必须严格为:{\"checks\":{\"informationHud\":true,\"gameplaySurface\":true,\"objectiveEntities\":true,\"primaryControls\":true,\"failureRestartFlow\":true,\"responsiveLayout\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。", "default_inspection_focus": "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。", "custom_inspection_focus": "检查重点:{question}", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json index 1d93990b3..75bf4c9f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json @@ -36,7 +36,7 @@ "preview.start.description": "启动当前项目的 loopback HTTP 预览。", "preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect.description": "让视觉模型检查一至两张项目内图片。", - "canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。", + "canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。", "ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。", "cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。", "unity.editor.execute.description": "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index f2f206f72..05b25f127 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -13,7 +13,7 @@ mod codex_app_server; mod codex_cli; mod codex_provider_proxy; mod design_runtime; -mod design_tools; +pub(crate) mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_user_item; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index ad24af2a1..35c52a8bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -1180,7 +1180,6 @@ fn direct_codex_thread_delta_event( ) -> DirectThreadEvent { DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta)) } - /// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 /// /// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外 @@ -3358,8 +3357,22 @@ impl CodexAppServerConnection { codex_app_server_text_prompt(&request) .map_err(platform_llm::LlmError::InvalidRequest)? }; - let mut input = - codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?; + let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if let Some(item) = direct_user_item { + let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + .map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?; + direct_codex_user_item_to_codex_turn_input( + &self.inner.workspace_path, + &canonical, + self.inner._skill_roots.as_deref().unwrap_or_default(), + ) + .map_err(platform_llm::LlmError::InvalidRequest)? + } else { + codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await? + } + } else { + codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await? + }; if thread_created && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if let Some(client_turn_id) = direct_client_turn_id { @@ -7684,7 +7697,7 @@ done &temp.path().join("host"), &project, "turn-0001", - "fixture-request", + &format!("{:x}", Sha256::digest("请创建菜单".as_bytes())), false, &super::super::direct_validation::DirectValidationConfig::default(), ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index cf9501888..460ac0dad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use tauri::Manager; const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts"; +const DESIGN_REFERENCES_ROOT: &str = "references"; const SEARCH_HIT_LIMIT: usize = 200; #[derive(Clone, Debug, Serialize)] @@ -547,6 +549,123 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result, +) -> Result { + let root = PathBuf::from(project_path.trim()); + let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?; + let _ = app.emit( + "design-agent-update", + serde_json::json!({ + "projectPath": root.to_string_lossy(), + "clientTurnId": "", + "kind": "workspace", + "messageId": null, + "text": null, + "view": null, + }), + ); + Ok(relative_path) +} + +fn import_design_workspace_file_at( + root: &Path, + file_name: &str, + bytes: &[u8], +) -> Result { + enforce_project_permission_policy(root, "conversation.write")?; + read_existing_manifest_for_project(root)?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + + if file_name.contains(['/', '\\']) { + return Err("附件文件名必须是单个文件名,不能包含目录".to_string()); + } + let normalized_name = + normalize_relative_path(file_name).map_err(|error| format!("附件文件名无效:{error}"))?; + if normalized_name != file_name { + return Err("附件文件名无效".to_string()); + } + + let (_, references) = resolve_design_workspace_path(root, DESIGN_REFERENCES_ROOT)?; + crate::ensure_game_creator_private_directory_tree(&references, "策划参考附件目录")?; + crate::prepare_game_creator_private_path_for_read(&references, true, "策划参考附件目录")?; + + let mut sequence = 1_u64; + loop { + let candidate_name = design_reference_file_name(&normalized_name, sequence); + let relative_path = format!("{DESIGN_REFERENCES_ROOT}/{candidate_name}"); + let (_, target) = resolve_design_workspace_path(root, &relative_path)?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = match options.open(&target) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + sequence = sequence + .checked_add(1) + .ok_or_else(|| "无法为同名附件分配新序号".to_string())?; + continue; + } + Err(error) => { + return Err(format!( + "创建策划参考附件失败:{}: {error}", + target.display() + )); + } + }; + if let Err(error) = + crate::harden_new_game_creator_private_path(&target, false, "策划参考附件") + { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + let write_result = file.write_all(bytes).and_then(|_| file.sync_all()); + drop(file); + if let Err(error) = write_result { + let _ = fs::remove_file(&target); + return Err(format!( + "写入策划参考附件失败:{}: {error}", + target.display() + )); + } + return Ok(relative_path); + } +} + +fn design_reference_file_name(file_name: &str, sequence: u64) -> String { + if sequence == 1 { + return file_name.to_string(); + } + let path = Path::new(file_name); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(file_name); + match path.extension().and_then(|value| value.to_str()) { + Some(extension) if !extension.is_empty() => { + format!("{stem} ({sequence}).{extension}") + } + _ => format!("{stem} ({sequence})"), + } +} + fn load_design_catalog(root: &Path) -> Result, String> { let catalog_path = root.join("resources/catalog.json"); let data: DesignCatalogFile = serde_json::from_str( @@ -756,6 +875,13 @@ mod tests { tempfile::tempdir().expect("tempdir") } + fn initialized_test_root() -> tempfile::TempDir { + let temp = test_root(); + init_local_game_project_at(temp.path(), "design-import-test", "策划附件导入测试") + .expect("init project"); + temp + } + fn pack_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent") } @@ -868,6 +994,138 @@ mod tests { ); } + #[test] + fn imported_reference_is_visible_to_workspace_list_and_read() { + let temp = initialized_test_root(); + let root = temp.path(); + let manifest_before = read_existing_manifest_for_project(root).expect("read manifest"); + let revision_before = + read_game_creator_agent_runtime_project_revision(root).expect("read revision"); + + let relative = + import_design_workspace_file_at(root, "玩法构想.txt", "横版解谜\n".as_bytes()) + .expect("import text reference"); + + assert_eq!(relative, "references/玩法构想.txt"); + assert!(list_design_workspace_files(root) + .expect("list workspace") + .iter() + .any(|entry| entry.path == relative && entry.kind == "file")); + assert_eq!( + read_design_workspace_file_at(root, &relative).expect("read imported reference"), + "横版解谜\n" + ); + assert_eq!( + read_existing_manifest_for_project(root).expect("read manifest after import"), + manifest_before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .expect("read revision after import"), + revision_before + ); + } + + #[test] + fn import_keeps_existing_names_and_accepts_empty_and_binary_bytes() { + let temp = initialized_test_root(); + let root = temp.path(); + + let first = import_design_workspace_file_at(root, "brief.md", b"first") + .expect("import first reference"); + let second = import_design_workspace_file_at(root, "brief.md", b"second") + .expect("import repeated reference"); + let empty = import_design_workspace_file_at(root, "empty.bin", b"") + .expect("import empty reference"); + let binary_bytes = [0_u8, 0xff, 0x10, 0x80]; + let binary = import_design_workspace_file_at(root, "bytes.bin", &binary_bytes) + .expect("import binary reference"); + + assert_eq!(first, "references/brief.md"); + assert_eq!(second, "references/brief (2).md"); + assert_eq!(empty, "references/empty.bin"); + assert_eq!(binary, "references/bytes.bin"); + assert_eq!( + fs::read(root.join("design_artifacts").join(&first)).expect("read first"), + b"first" + ); + assert_eq!( + fs::read(root.join("design_artifacts").join(&second)).expect("read second"), + b"second" + ); + assert!(fs::read(root.join("design_artifacts").join(&empty)) + .expect("read empty") + .is_empty()); + assert_eq!( + fs::read(root.join("design_artifacts").join(&binary)).expect("read binary"), + binary_bytes + ); + } + + #[test] + fn import_rejects_unsafe_names_and_denied_project_permission() { + let temp = initialized_test_root(); + let root = temp.path(); + for file_name in [ + "../outside.txt", + "nested/file.txt", + r"nested\file.txt", + "C:stream", + ] { + let error = import_design_workspace_file_at(root, file_name, b"blocked") + .expect_err("reject unsafe file name"); + assert!(error.contains("文件名"), "unexpected error: {error}"); + } + assert!(!root.join("outside.txt").exists()); + + let mut policy = ProjectPermissionPolicy::default(); + policy + .denied_commands + .push("conversation.write".to_string()); + write_project_permission_policy_at(root, policy).expect("deny conversation write"); + let error = import_design_workspace_file_at(root, "denied.txt", b"blocked") + .expect_err("respect project permission policy"); + assert!(error.contains("conversation.write")); + assert!(!root.join("design_artifacts/references/denied.txt").exists()); + } + + #[cfg(unix)] + #[test] + fn import_rejects_linked_references_directory() { + use std::os::unix::fs::symlink; + + let temp = initialized_test_root(); + let root = temp.path(); + let outside = tempfile::tempdir().expect("outside tempdir"); + fs::create_dir_all(root.join("design_artifacts")).expect("create workspace"); + symlink(outside.path(), root.join("design_artifacts/references")) + .expect("link references directory"); + + let error = import_design_workspace_file_at(root, "escape.txt", b"blocked") + .expect_err("reject linked references directory"); + assert!(error.contains("符号链接") || error.contains("reparse point")); + assert!(!outside.path().join("escape.txt").exists()); + } + + #[cfg(windows)] + #[test] + fn import_rejects_windows_linked_references_directory_when_supported() { + use std::os::windows::fs::symlink_dir; + + let temp = initialized_test_root(); + let root = temp.path(); + let outside = tempfile::tempdir().expect("outside tempdir"); + fs::create_dir_all(root.join("design_artifacts")).expect("create workspace"); + if symlink_dir(outside.path(), root.join("design_artifacts/references")).is_err() { + return; + } + + let error = import_design_workspace_file_at(root, "escape.txt", b"blocked") + .expect_err("reject linked references directory"); + assert!(error.contains("符号链接") || error.contains("reparse point")); + assert!(!outside.path().join("escape.txt").exists()); + } + #[test] fn phase_context_injects_current_skill_only() { let resources = DesignResources::new(pack_root()).expect("pack"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs index 0ef72c092..59639e93d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs @@ -2,9 +2,9 @@ //! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。 pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8; -const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160; -const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; -const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512; +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160; +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512; const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader"); const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index e33bfae72..946cb4de3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -5,11 +5,11 @@ mod validation; mod wire; pub(crate) use model::{ - DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem, - DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, + DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem, + DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, }; pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ - direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, - direct_codex_user_item_to_wire_input, + direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, + direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index 266f330cc..b65f25ba4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -4,7 +4,7 @@ use ts_rs::TS; /// DirectProject 本轮 user input 的唯一结构化入口。 #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(tag = "type", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserItem { #[serde(rename = "message")] Message(DirectCodexUserMessageItem), @@ -12,7 +12,7 @@ pub(crate) enum DirectCodexUserItem { #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserMessageItem { pub(crate) role: DirectCodexUserRole, pub(crate) content: Vec, @@ -21,26 +21,43 @@ pub(crate) struct DirectCodexUserMessageItem { #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "lowercase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserRole { User, } #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "input_text")] InputText { text: String }, #[serde(rename = "agc_resource_reference")] AgcResourceReference { resource_id: String }, + #[serde(rename = "agc_skill_reference")] + AgcSkillReference { name: String }, #[serde(rename = "agc_runtime_region_reference")] AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), + /// Uploaded project attachment kept inline in canonical content. + #[serde(rename = "agc_attachment_reference")] + AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), } #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct DirectCodexUserAttachmentReferencePart { + pub(crate) name: String, + pub(crate) media_type: String, + #[ts(type = "number")] + pub(crate) size: u64, + pub(crate) local_path: String, + pub(crate) status: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserRuntimeRegionPart { pub(crate) label: String, #[serde(default)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index 01f6a6868..f63a13da1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -4,15 +4,20 @@ use super::model::{ }; use crate::agent::{ read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest, + MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS, + MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS, }; use std::path::Path; pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32; +// Skill 引用也是非文本 part,但没有走 reference_count:它每一条都会触发一次 +// `root//SKILL.md` 文件探测并往 turn input 里加一项,所以单独设上限。 +pub(crate) const MAX_DIRECT_CODEX_SKILL_REFERENCES: usize = 32; pub(crate) fn validate_direct_codex_user_item( root: &Path, item: &DirectCodexUserItem, -) -> Result<(), String> { +) -> Result { let DirectCodexUserItem::Message(message) = item; if !matches!(message.role, DirectCodexUserRole::User) { return Err("DirectProject 只接受 user message item".to_string()); @@ -20,32 +25,98 @@ pub(crate) fn validate_direct_codex_user_item( if message.id.trim().is_empty() { return Err("DirectProject user item 缺少稳定 id".to_string()); } - if message.content.is_empty() { - return Err("DirectProject user item content 不能为空".to_string()); + // 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落 + // 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。 + if !content_has_meaningful_input(&message.content) { + return Err("聊天内容不能为空".to_string()); } let manifest = read_manifest_for_project(root)?; let mut reference_count = 0usize; + let mut attachment_count = 0usize; + let mut skill_count = 0usize; for part in &message.content { match part { - DirectCodexUserContentPart::InputText { text } => { - if text.trim().is_empty() { - return Err("DirectProject input_text 不能为空".to_string()); - } - } + DirectCodexUserContentPart::InputText { .. } => {} DirectCodexUserContentPart::AgcResourceReference { resource_id } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; } + DirectCodexUserContentPart::AgcSkillReference { name } => { + skill_count = skill_count.saturating_add(1); + if skill_count > MAX_DIRECT_CODEX_SKILL_REFERENCES { + return Err(format!( + "一次最多引用 {MAX_DIRECT_CODEX_SKILL_REFERENCES} 个 Skill" + )); + } + let name = name.trim(); + if name.is_empty() + || name.chars().count() > 120 + || matches!(name, "." | "..") + || name.chars().any(|character| { + character.is_control() + || character.is_whitespace() + || matches!(character, '/' | '\\' | ':' | '$') + }) + { + return Err("引用的 Skill 名称无效,请移除后重新选择".to_string()); + } + } DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { reference_count = reference_count.saturating_add(1); validate_runtime_region_reference(&manifest, reference)?; } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + attachment_count = attachment_count.saturating_add(1); + if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS { + return Err(format!( + "一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件" + )); + } + if reference.name.trim().is_empty() { + return Err("附件缺少文件名".to_string()); + } + let name = reference.name.trim(); + if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + || name.chars().any(char::is_control) + { + return Err("附件文件名无效或过长".to_string()); + } + let media_type = reference.media_type.trim(); + if media_type.is_empty() + || media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS + || media_type.chars().any(|character| { + !(character.is_ascii_alphanumeric() + || matches!(character, '/' | '+' | '-' | '.' | '_')) + }) + { + return Err("附件媒体类型无效或过长".to_string()); + } + let status = reference.status.trim(); + if status == "imported" && reference.local_path.trim().is_empty() { + return Err("已导入附件缺少项目路径".to_string()); + } + if !reference.local_path.trim().is_empty() { + sanitize_attachment_local_path(&reference.local_path) + .ok_or_else(|| "附件项目路径无效".to_string())?; + } + if !matches!(status, "imported" | "failed") { + return Err("附件状态无效".to_string()); + } + } } } if reference_count > MAX_DIRECT_CODEX_REFERENCES { return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); } - Ok(()) + Ok(manifest) +} + +/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。 +pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool { + content.iter().any(|part| match part { + DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(), + _ => true, + }) } pub(crate) fn validate_resource_id_and_manifest( @@ -86,3 +157,173 @@ fn validate_runtime_region_reference( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ + content_has_meaningful_input, validate_direct_codex_user_item, + MAX_DIRECT_CODEX_SKILL_REFERENCES, + }; + use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart; + use serde_json::json; + + fn input_text(text: &str) -> DirectCodexUserContentPart { + DirectCodexUserContentPart::InputText { + text: text.to_string(), + } + } + + #[test] + fn only_all_blank_content_counts_as_empty_input() { + // 空数组与「整条只有空白」是同一种空输入。 + assert!(!content_has_meaningful_input(&[])); + assert!(!content_has_meaningful_input(&[input_text(" \n ")])); + assert!(!content_has_meaningful_input(&[ + input_text("\n"), + input_text(" "), + ])); + } + + #[test] + fn whitespace_parts_are_valid_next_to_meaningful_input() { + // 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。 + assert!(content_has_meaningful_input(&[ + input_text("\n"), + input_text("看"), + ])); + assert!(content_has_meaningful_input(&[ + input_text("看"), + input_text("\n\n"), + ])); + } + + #[test] + fn non_text_parts_always_count_as_input() { + assert!(content_has_meaningful_input(&[ + DirectCodexUserContentPart::AgcResourceReference { + resource_id: "asset-hero".to_string(), + }, + ])); + } + + #[test] + fn inline_attachment_count_is_bounded_independently() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "validation-test", "校验测试") + .expect("init project"); + let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS) + .map(|index| { + json!({ + "type": "agc_attachment_reference", + "name": format!("attachment-{index}.txt"), + "mediaType": "text/plain", + "size": 1, + "localPath": "", + "status": "failed" + }) + }) + .collect::>(); + let item = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": content, + "id": "turn-1:user" + })) + .expect("deserialize user item"); + let error = validate_direct_codex_user_item(root.path(), &item) + .expect_err("too many inline attachments must be rejected"); + assert!(error.contains("最多携带"), "{error}"); + } + + #[test] + fn imported_attachment_requires_a_project_path() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "validation-test", "校验测试") + .expect("init project"); + let item = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_attachment_reference", + "name": "attachment.txt", + "mediaType": "text/plain", + "size": 1, + "localPath": "", + "status": "imported" + }], + "id": "turn-1:user" + })) + .expect("deserialize user item"); + let error = validate_direct_codex_user_item(root.path(), &item) + .expect_err("imported attachment without a project path must fail"); + assert!(error.contains("缺少项目路径"), "{error}"); + } + + #[test] + fn inline_skill_reference_count_is_bounded_independently() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "validation-test", "校验测试") + .expect("init project"); + let content = (0..=MAX_DIRECT_CODEX_SKILL_REFERENCES) + .map(|index| json!({ "type": "agc_skill_reference", "name": format!("skill-{index}") })) + .collect::>(); + let item = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": content, + "id": "turn-1:user" + })) + .expect("deserialize user item"); + let error = validate_direct_codex_user_item(root.path(), &item) + .expect_err("too many skill references must be rejected"); + assert!(error.contains("最多引用"), "{error}"); + } + + #[test] + fn attachment_name_and_media_type_are_bounded_and_well_formed() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "validation-test", "校验测试") + .expect("init project"); + let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1); + let cases = [ + ( + json!({ + "name": "bad\nname.txt", + "mediaType": "text/plain" + }), + "文件名", + ), + ( + json!({ + "name": "ok.txt", + "mediaType": "text/plain\nsecret" + }), + "媒体类型", + ), + ( + json!({ + "name": long_name, + "mediaType": "text/plain" + }), + "文件名", + ), + ]; + for (metadata, expected) in cases { + let mut value = metadata; + value["type"] = json!("agc_attachment_reference"); + value["size"] = json!(1); + value["localPath"] = json!(""); + value["status"] = json!("failed"); + let item = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [value], + "id": "turn-1:user" + })) + .expect("deserialize user item"); + let error = validate_direct_codex_user_item(root.path(), &item) + .expect_err("invalid attachment metadata must fail"); + assert!(error.contains(expected), "{error}"); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index c718a9ee5..d3e94388a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -1,6 +1,12 @@ -use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem}; +use super::model::{ + DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem, + DirectCodexUserMessageItem, DirectCodexUserRuntimeRegionPart, +}; use super::validation::validate_direct_codex_user_item; -use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; +use crate::agent::{ + read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type, + sanitize_attachment_name, GameCreationAppManifest, +}; use crate::ui_editor::persistence::{ generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND, UI_DESIGN_DOC_MEDIA_TYPE, @@ -55,54 +61,90 @@ fn direct_codex_user_item_to_response_content( .collect() } +fn resource_reference_summary( + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result { + let resource_id = resource_id.trim(); + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + Ok(format!( + "[素材引用 resourceId={resource_id};项目路径={path}]" + )) +} + +fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String { + let resources = reference + .resource_ids + .iter() + .map(|id| id.trim()) + .collect::>() + .join(","); + let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); + if let Some(run_id) = reference.run_id.as_deref() { + summary.push_str(&format!("运行标识={} ", run_id.trim())); + } + if let Some(role) = reference.element_role.as_deref() { + summary.push_str(&format!("角色={} ", role.trim())); + } + if let Some(text) = reference.text.as_deref() { + summary.push_str(&format!("文本={} ", text.trim())); + } + if !resources.is_empty() { + summary.push_str(&format!("关联素材={resources}")); + } + summary.push(']'); + summary +} + +/// 附件引用的安全摘要。 +/// +/// turn 输入与 history/prompt 投影共用这一份清洗:文件名取 basename 并去控制字符、 +/// media type 与项目路径同样过白名单,避免两条路径对同一个引用给出不同摘要。 +fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePart) -> String { + let name = sanitize_attachment_name(&reference.name); + let media_type = sanitize_attachment_media_type(&reference.media_type); + let mut summary = format!( + "[附件:名称={name};类型={media_type};大小={} 字节", + reference.size + ); + if let Some(local_path) = sanitize_attachment_local_path(&reference.local_path) { + summary.push_str(&format!(";项目路径={local_path}")); + } + summary.push_str(&format!(";状态={}", reference.status.trim())); + summary.push(']'); + summary +} + /// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 /// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 pub(crate) fn direct_codex_user_item_to_wire_input( root: &Path, item: &DirectCodexUserItem, ) -> Result { - validate_direct_codex_user_item(root, item)?; - let manifest = read_manifest_for_project(root)?; + // validate 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。 + let manifest = validate_direct_codex_user_item(root, item)?; let DirectCodexUserItem::Message(message) = item; let mut input = Vec::with_capacity(message.content.len()); for part in &message.content { let text = match part { DirectCodexUserContentPart::InputText { text } => text.clone(), DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id.trim()) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - let path = sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - format!( - "[素材引用 resourceId={};项目路径={path}]", - resource_id.trim() - ) + resource_reference_summary(&manifest, resource_id)? + } + DirectCodexUserContentPart::AgcSkillReference { name } => { + format!("${}", name.trim()) } DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { - let resources = reference - .resource_ids - .iter() - .map(|id| id.trim()) - .collect::>() - .join(","); - let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); - if let Some(run_id) = reference.run_id.as_deref() { - summary.push_str(&format!("运行标识={} ", run_id.trim())); - } - if let Some(role) = reference.element_role.as_deref() { - summary.push_str(&format!("角色={} ", role.trim())); - } - if let Some(text) = reference.text.as_deref() { - summary.push_str(&format!("文本={} ", text.trim())); - } - if !resources.is_empty() { - summary.push_str(&format!("关联素材={resources}")); - } - summary.push(']'); - summary + runtime_region_summary(reference) + } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + attachment_reference_summary(reference) } }; input.push(serde_json::json!({ "type": "text", "text": text })); @@ -110,6 +152,55 @@ pub(crate) fn direct_codex_user_item_to_wire_input( Ok(Value::Array(input)) } +pub(crate) fn direct_codex_user_item_to_codex_turn_input( + root: &Path, + item: &DirectCodexUserItem, + skill_roots: &[std::path::PathBuf], +) -> Result { + let manifest = validate_direct_codex_user_item(root, item)?; + let DirectCodexUserItem::Message(message) = item; + let mut input = Vec::with_capacity(message.content.len()); + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => { + input.push(serde_json::json!({ "type": "text", "text": text })); + } + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + input.push(serde_json::json!({ + "type": "text", + "text": resource_reference_summary(&manifest, resource_id)?, + })); + } + DirectCodexUserContentPart::AgcSkillReference { name } => { + let name = name.trim(); + let path = skill_roots + .iter() + .map(|root| root.join(name).join("SKILL.md")) + .find(|path| path.is_file()) + .ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?; + input.push(serde_json::json!({ + "type": "skill", + "name": name, + "path": path, + })); + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + input.push(serde_json::json!({ + "type": "text", + "text": runtime_region_summary(reference), + })); + } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + input.push(serde_json::json!({ + "type": "text", + "text": attachment_reference_summary(reference), + })); + } + } + } + Ok(Value::Array(input)) +} + pub(crate) fn direct_codex_user_item_to_prompt( root: &Path, item: &DirectCodexUserItem, @@ -118,17 +209,10 @@ pub(crate) fn direct_codex_user_item_to_prompt( let DirectCodexUserItem::Message(message) = item; let mut prompt = wire .as_array() - .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) - .and_then(|parts| { - parts - .iter() - .map(|part| { - part.get("text") - .and_then(Value::as_str) - .ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string()) - }) - .collect::>() - })?; + .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())? + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::(); if let Some(code_context) = render_ui_design_code_context(root, message)? { prompt.push('\n'); prompt.push_str(&code_context); @@ -197,7 +281,11 @@ fn render_ui_design_code_context( #[cfg(test)] mod tests { - use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item}; + use super::{ + direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, + direct_codex_user_item_to_wire_input, validate_direct_codex_user_item, + }; + use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; use serde_json::json; use shared_contracts::game_creation_app::{ @@ -280,47 +368,6 @@ mod tests { }) } - #[test] - fn standard_response_item_passes_through_without_agc_private_parts() { - let item = json!({ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "完成"}] - }); - assert_eq!( - direct_codex_user_item_to_response_item(Path::new("/unused"), &item) - .expect("assistant response item should pass through"), - item - ); - } - - #[test] - fn response_item_projection_uses_input_text_not_turn_input_text() { - let root = tempfile::tempdir().expect("temp project"); - crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") - .expect("init project"); - let item = json!({ - "type": "message", - "role": "user", - "id": "turn-1:user", - "content": [{"type": "input_text", "text": "你好"}] - }); - let projected = direct_codex_user_item_to_response_item(root.path(), &item) - .expect("user response item should project"); - assert_eq!(projected["content"][0]["type"], "input_text"); - assert_ne!(projected["content"][0]["type"], "text"); - } - - #[test] - fn history_item_without_type_fails_closed() { - let error = direct_codex_user_item_to_response_item( - Path::new("/unused"), - &json!({"role": "assistant"}), - ) - .expect_err("history item without type must fail"); - assert!(error.contains("缺少 type"), "{error}"); - } - #[test] fn ui_design_doc_reference_appends_generated_code_context() { let (project, asset_id) = ui_design_doc_fixture(true); @@ -397,4 +444,277 @@ mod tests { ); assert!(!prompt.contains("生成代码遇到错误"), "{prompt}"); } + + #[test] + fn standard_response_item_passes_through_without_agc_private_parts() { + let item = json!({ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "完成"}] + }); + assert_eq!( + direct_codex_user_item_to_response_item(Path::new("/unused"), &item) + .expect("assistant response item should pass through"), + item + ); + } + + #[test] + fn response_item_projection_uses_input_text_not_turn_input_text() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [{"type": "input_text", "text": "你好"}] + }); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("user response item should project"); + assert_eq!(projected["content"][0]["type"], "input_text"); + assert_ne!(projected["content"][0]["type"], "text"); + } + + #[test] + fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": "你好"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": "第二段"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] + }); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("user response item should project"); + assert_eq!(projected["content"], item["content"]); + let canonical = serde_json::from_value(item).expect("canonical user item"); + assert_eq!( + direct_codex_user_item_to_prompt(root.path(), &canonical).expect("multiline prompt"), + "你好\n第二段\n " + ); + } + + #[test] + fn user_input_rejects_empty_or_whitespace_only_messages() { + let root = prompt_context_project(); + for content in [ + json!([]), + json!([{"type": "input_text", "text": ""}]), + json!([ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": " \t\r\n\u{3000}"} + ]), + ] { + let item = serde_json::from_value(json!({ + "type": "message", "role": "user", "id": "turn-1:user", "content": content + })) + .expect("canonical user item"); + assert_eq!( + validate_direct_codex_user_item(root.path(), &item), + Err("聊天内容不能为空".to_string()) + ); + } + } + + #[test] + fn valid_references_allow_missing_text_and_whitespace_parts() { + let root = prompt_context_project(); + let asset_id = register_fixture_asset( + root.path(), + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ); + for (reference, expected_text) in [ + ( + json!({"type": "agc_resource_reference", "resourceId": asset_id}), + format!("[素材引用 resourceId={asset_id};项目路径=assets/hero.png]"), + ), + ( + json!({"type": "agc_runtime_region_reference", "label": "主画面"}), + "[运行画面区域:名称=主画面 ]".to_string(), + ), + ] { + for (content, expected_prompt) in [ + (json!([reference.clone()]), expected_text.clone()), + ( + json!([ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": "\n"}, + reference, + {"type": "input_text", "text": " "} + ]), + format!("\n{expected_text} "), + ), + ] { + let item = json!({ + "type": "message", "role": "user", "id": "turn-1:user", "content": content + }); + let canonical = serde_json::from_value(item.clone()).expect("canonical user item"); + assert_eq!( + direct_codex_user_item_to_prompt(root.path(), &canonical) + .expect("reference prompt"), + expected_prompt + ); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("reference history projection"); + assert_eq!( + projected["content"].as_array().unwrap().len(), + content.as_array().unwrap().len() + ); + } + } + } + + #[test] + fn nonempty_text_does_not_bypass_invalid_reference_validation() { + let root = prompt_context_project(); + for (reference, expected_error) in [ + ( + json!({"type": "agc_resource_reference", "resourceId": " "}), + "引用的素材 ID 无效,请移除后重新选择", + ), + ( + json!({"type": "agc_resource_reference", "resourceId": "missing"}), + "引用的素材已不存在,请移除后重新选择", + ), + ( + json!({"type": "agc_runtime_region_reference", "label": " "}), + "运行画面区域缺少名称", + ), + ( + json!({"type": "agc_runtime_region_reference", "label": "主画面", "resourceIds": ["missing"]}), + "引用的素材已不存在,请移除后重新选择", + ), + ] { + let item = serde_json::from_value(json!({ + "type": "message", "role": "user", "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "有真实文字"}, + reference, + {"type": "input_text", "text": " "} + ] + })) + .expect("canonical user item"); + assert_eq!( + validate_direct_codex_user_item(root.path(), &item), + Err(expected_error.to_string()) + ); + } + } + + #[test] + fn history_item_without_type_fails_closed() { + let error = direct_codex_user_item_to_response_item( + Path::new("/unused"), + &json!({"role": "assistant"}), + ) + .expect_err("history item without type must fail"); + assert!(error.contains("缺少 type"), "{error}"); + } + + #[test] + fn attachment_parts_remain_in_canonical_order_when_projected() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "先看"}, + {"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"} + ] + }); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("user response item should project"); + let content = projected["content"].as_array().expect("content array"); + assert_eq!(content.len(), 2); + assert!(content[0]["text"].as_str().unwrap().contains("先看")); + assert!(content[1]["text"].as_str().unwrap().contains("notes.txt")); + } + + #[test] + fn attachment_metadata_is_sanitized_before_prompt_projection() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [{ + "type": "agc_attachment_reference", + "name": "C:\\tmp\\notes.md", + "mediaType": "text/plain", + "size": 4, + "localPath": "assets\\.\\notes.txt", + "status": "imported" + }] + }); + let wire = super::direct_codex_user_item_to_wire_input( + root.path(), + &serde_json::from_value(item).expect("deserialize user item"), + ) + .expect("attachment metadata should project"); + let text = wire[0]["text"].as_str().expect("wire text"); + assert!(text.contains("名称=notes.md"), "{text}"); + assert!(text.contains("类型=text/plain"), "{text}"); + assert!(text.contains("项目路径=assets/notes.txt"), "{text}"); + } + + #[test] + fn whitespace_only_text_parts_survive_validation() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "先看"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] + })) + .expect("deserialize user item"); + let wire = direct_codex_user_item_to_wire_input(root.path(), &item) + .expect("whitespace-only part next to real text must pass"); + let parts = wire.as_array().expect("wire input array"); + assert_eq!(parts.len(), 3); + assert_eq!(parts[1]["text"].as_str(), Some("\n")); + assert_eq!(parts[2]["text"].as_str(), Some(" ")); + } + + #[test] + fn all_blank_content_is_rejected() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] + })) + .expect("deserialize user item"); + let error = direct_codex_user_item_to_wire_input(root.path(), &item) + .expect_err("all-blank content must fail closed"); + assert!(error.contains("不能为空"), "{error}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 467b70fc1..43554cd85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -30,11 +30,9 @@ pub(crate) fn normalize_direct_client_turn_id( #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, - prompt: String, - mut user_item: DirectCodexUserItem, + user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, - attachments: Option>, ) -> Result { let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; @@ -43,74 +41,33 @@ pub(crate) async fn chat_with_game_creator_direct_codex( redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) })?; let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - let mut audit = DirectCodexTurnAudit::start( - root, - &turn_id, - &prompt, - attachments.as_deref().unwrap_or_default(), - ); - let attachments = attachments.unwrap_or_default(); - if !attachments.is_empty() { - let attachment_context = match render_direct_codex_user_prompt("", &attachments) { - Ok(context) => context, - Err(error) => { - audit.finish(false); - audit.flush().await; - return Err(error); - } - }; - let DirectCodexUserItem::Message(message) = &mut user_item; - message.content.push(DirectCodexUserContentPart::InputText { - text: attachment_context, - }); - } - if let Err(error) = validate_direct_codex_user_item(root, &user_item) { - audit.finish(false); - audit.flush().await; - return Err(error); - } - let user_prompt = match direct_codex_user_item_to_prompt(root, &user_item) { - Ok(prompt) => prompt, - Err(error) => { - audit.finish(false); - audit.flush().await; - return Err(error); - } - }; + validate_direct_codex_user_item(root, &user_item)?; + let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?; if user_prompt.trim().is_empty() { - audit.finish(false); - audit.flush().await; return Err("聊天内容不能为空".to_string()); } let canonical_user_item = // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 - match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()).await { + match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) + .await + { Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?), - Err(error) => { - audit.finish(false); - audit.flush().await; - return Err(redact_agent_runtime_error(root, &error, 1800)); - } + Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)), }; let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, &user_prompt, creation_type.as_deref(), Some(&turn_emitter), - Some(&mut audit), + // DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。 + None, canonical_user_item, ) .await { Ok(reply) => reply, - Err(error) => { - audit.finish(false); - audit.flush().await; - return Err(error); - } + Err(error) => return Err(error), }; - audit.finish(true); - audit.flush().await; turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None); Ok(reply) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index d5a874c76..354dc8466 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -1,6 +1,6 @@ //! DirectProject 聊天事件的线上模型与投影。 //! -//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/generated/`, +//! 前端消费的类型由 ts-rs 导出到 `src/view/project-development/chat/generated/`, //! 与 Rust 定义同源:加一个字段不会只改一边。 //! //! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与 @@ -29,7 +29,7 @@ const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300; /// 一条文件变更。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadFileChange { pub(crate) path: String, /// `add` | `update` | `delete` @@ -45,7 +45,7 @@ pub(crate) struct DirectThreadFileChange { /// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectThreadItem { #[serde(rename = "message")] Message { @@ -179,7 +179,7 @@ impl DirectThreadItem { /// 增量正文属于哪类条目。 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectThreadDeltaKind { /// assistant 正文。 Message, @@ -190,7 +190,7 @@ pub(crate) enum DirectThreadDeltaKind { /// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectThreadRequestKind { #[serde(rename = "approval.requested")] ApprovalRequested, @@ -235,7 +235,7 @@ impl DirectThreadRequestKind { /// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectThreadEvent { #[serde(rename = "turn.started")] TurnStarted { @@ -398,7 +398,7 @@ impl DirectThreadEvent { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadSubscriptionBootstrap { pub(crate) subscription_id: String, /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 @@ -410,14 +410,14 @@ pub(crate) struct DirectThreadSubscriptionBootstrap { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadConsumeResult { pub(crate) events: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadHistorySlice { /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 pub(crate) items: Vec, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index ee87f122e..19790ef61 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -3407,7 +3407,7 @@ async fn start_tool_bridge_for_source( } #[cfg(test)] -pub(in crate::agent) struct DirectExecutionTestFixture { +pub(crate) struct DirectExecutionTestFixture { execution: Option, _host: tempfile::TempDir, } @@ -3426,7 +3426,7 @@ impl Drop for DirectExecutionTestFixture { } #[cfg(test)] -pub(in crate::agent) async fn direct_execution_fixture( +pub(crate) async fn direct_execution_fixture( root: &Path, turn: &str, ) -> DirectExecutionTestFixture { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 74046bc2e..5b09bc5d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -72,7 +72,8 @@ pub(crate) use canvas_generation::{ platform_art_asset_art_spec, platform_art_asset_output_extension_matches, platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path, project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call, - PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS, + validate_platform_art_icon_prompt, PlatformArtAssetGenerationOptions, + PLATFORM_ART_ASSET_GENERATION_KINDS, }; #[allow(unused_imports)] pub(crate) use draft_validation::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 49df719b9..e79f40ac9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -687,7 +687,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( let snapshot = platform_art_generation_runtime_request_snapshot(&state)?; // 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。 if snapshot.generation_kind == generation_kind - && snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &[], &options) + && snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &options) && options.asset_kind == kind && options.aspect_ratio == ratio && options.image_size == size @@ -2261,39 +2261,16 @@ async fn resolve_platform_art_generation_references_at( Ok(PlatformArtGenerationReferences { canonical, ordered }) } -fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { - // External Editor validates each description independently (currently at - // 200 Unicode characters). Keep the gameplay context short enough that a - // long creation request cannot reject the atlas before it is queued. - const MAX_DESCRIPTION_CHARS: usize = 200; - const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:"; - let category = - "按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定"; - let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( - category - .chars() - .count() - .saturating_add(CONTEXT_PREFIX.chars().count()), - ); - let project_context = truncate_inline_bounded(prompt.trim(), context_budget); - vec![format!("{category}{CONTEXT_PREFIX}{project_context}")] -} - -fn truncate_inline_bounded(value: &str, max_chars: usize) -> String { - let normalized = value.split_whitespace().collect::>().join(" "); - let actual_chars = normalized.chars().count(); - if actual_chars <= max_chars { - return normalized; +pub(crate) fn validate_platform_art_icon_prompt(prompt: &str) -> Result<(), String> { + // 与 External Editor 的单条 iconDescriptions 合同一致;超限拒绝,不截断或拆条。 + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("图标素材描述不能为空".to_string()); } - if max_chars <= 3 { - return ".".repeat(max_chars); + if prompt.chars().count() > 200 { + return Err("图标素材描述不能超过 200 个字符".to_string()); } - let mut output = normalized - .chars() - .take(max_chars.saturating_sub(3)) - .collect::(); - output.push_str("..."); - output + Ok(()) } fn decode_platform_art_image_with_limits( @@ -2565,10 +2542,10 @@ pub(in crate::agent) struct AdmittedPlatformArtGeneration { pub(in crate::agent) fn admit_platform_art_generation_at( root: &Path, prompt: &str, - briefs: &[AgentGroupBrief], + _briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; let guard = super::external_generation_state::acquire_durable_platform_art_generation_guard( @@ -2590,7 +2567,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at( options: &PlatformArtAssetGenerationOptions, admission: AdmittedPlatformArtGeneration, ) -> Result { - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; if admission.context != context @@ -2618,7 +2595,7 @@ pub(crate) async fn generate_platform_art_asset_with_options_at( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let runtime_context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( @@ -2645,7 +2622,7 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); } - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let runtime_context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( @@ -3106,6 +3083,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at options: &PlatformArtAssetGenerationOptions, runtime_context: Option<&PlatformArtGenerationRuntimeContext>, ) -> Result { + if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { + validate_platform_art_icon_prompt(prompt)?; + } enforce_project_permission_policy(root, "canvas.asset_generate")?; let persisted_runtime_state = runtime_context .map(|context| { @@ -3150,7 +3130,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT) .build() .map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?; - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let ( generated, canvas_context, @@ -3348,7 +3328,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "/api/external/v1/editor/icon-spritesheets/generations", serde_json::json!({ "referenceId": reference_id, - "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), + "iconDescriptions": [generation_prompt], "sliceCount": options.slice_count, "sliceMode": options.slice_mode, "gridX": options.grid_x, @@ -8413,7 +8393,6 @@ pub(crate) fn platform_art_asset_art_spec( pub(crate) fn build_platform_art_asset_prompt( prompt: &str, - briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> String { if options.asset_kind == GameCreationAppAssetKind::Image { @@ -8452,20 +8431,8 @@ pub(crate) fn build_platform_art_asset_prompt( truncate_prompt_context(prompt.trim()) ); } - let art_asset_brief = briefs - .iter() - .flat_map(|brief| brief.role_briefs.iter()) - .find(|role_brief| { - role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset" - }) - .map(|role_brief| role_brief.markdown.trim()) - .filter(|markdown| !markdown.is_empty()) - .unwrap_or(prompt_text!("media.default_art_brief")); - format!( - prompt_text!("media.spritesheet_generation"), - truncate_inline(prompt, 240), - truncate_prompt_context(art_asset_brief) - ) + // 图集的工程化生图指令由 API Server 统一添加,客户端仅保留用户原文。 + prompt.trim().to_string() } #[cfg(test)] @@ -8533,15 +8500,15 @@ mod canvas_generation_tests { } #[test] - fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() { - // 放行 icon-spritesheet 必须真的走到图集提示词与图集请求合同, - // 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。 + fn generation_kind_catalog_keeps_art_spritesheet_description_and_art_spec() { + // 图集描述由用户提供,工程化生图提示词由 API Server 添加。 let options = PlatformArtAssetGenerationOptions { asset_kind: GameCreationAppAssetKind::IconSpritesheet, ..PlatformArtAssetGenerationOptions::default() }; - assert!( - build_platform_art_asset_prompt("原创收集玩法", &[], &options).contains("素材图集") + assert_eq!( + build_platform_art_asset_prompt("原创收集玩法", &options), + "原创收集玩法" ); assert_eq!( platform_art_asset_art_spec(&options)["assetType"], @@ -8945,7 +8912,7 @@ mod canvas_generation_tests { asset_label: "失败后可重试素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &[], &options); + let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &options); let runtime_context = standalone_platform_art_generation_runtime_context(&generated_prompt, &options, false) .expect("standalone retry context"); @@ -9121,8 +9088,8 @@ mod canvas_generation_tests { asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &[], &options); - let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &[], &options); + let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &options); + let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &options); let first = standalone_platform_art_generation_runtime_context(&first_prompt, &options, false) .expect("first standalone slot context"); @@ -9556,7 +9523,7 @@ mod canvas_generation_tests { asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &[], &options); + let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &options); let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false) .expect("current standalone context"); let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false) @@ -9676,8 +9643,7 @@ mod canvas_generation_tests { asset_label: "另一个图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let other_prompt = - build_platform_art_asset_prompt("另一个旧槽手工请求", &[], &other_options); + let other_prompt = build_platform_art_asset_prompt("另一个旧槽手工请求", &other_options); let other_context = standalone_platform_art_generation_runtime_context( &other_prompt, &other_options, @@ -10877,7 +10843,7 @@ mod canvas_generation_tests { let current_prompt = "恢复时使用同一个生成意图"; let current_options = PlatformArtAssetGenerationOptions::default(); let durable_generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + build_platform_art_asset_prompt(current_prompt, ¤t_options); let request_body = serde_json::json!({ "prompt": "持久化且必须原样重发的生成正文", "kind": "spec", @@ -11143,7 +11109,7 @@ mod canvas_generation_tests { screen_color: None, }; let prompt = "生成同一套整包美术"; - let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); + let generation_prompt = build_platform_art_asset_prompt(prompt, &options); let request_body = serde_json::json!({ "prompt": generation_prompt, "kind": "spec", @@ -11530,8 +11496,7 @@ mod canvas_generation_tests { asset_label: "账号隔离测试".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options); let request_body = serde_json::json!({ "prompt": generation_prompt, "kind": "spec", @@ -11739,7 +11704,7 @@ mod canvas_generation_tests { ..PlatformArtAssetGenerationOptions::default() }; let durable_generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + build_platform_art_asset_prompt(current_prompt, ¤t_options); let request_body = serde_json::json!({ "prompt": "持久化的原始生成正文", "kind": "spec", @@ -11883,7 +11848,7 @@ mod canvas_generation_tests { }; let options = PlatformArtAssetGenerationOptions::default(); let old_generation_prompt = - build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &[], &options); + build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &options); let binding_access = ExternalEditorBindingAccess::new(&base_url, "changed-intent-key", None) .expect("prepare changed-intent binding access"); @@ -12050,7 +12015,7 @@ mod canvas_generation_tests { screen_color: None, }; let prompt = "保持同一个生成提示词"; - let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); + let generation_prompt = build_platform_art_asset_prompt(prompt, &options); let binding_access = ExternalEditorBindingAccess::new(&base_url, "changed-reference-key", None) .expect("prepare changed-reference binding access"); @@ -12313,8 +12278,7 @@ mod canvas_generation_tests { .expect("create accepted failure ledger access"); let current_prompt = "失败恢复提示词"; let current_options = PlatformArtAssetGenerationOptions::default(); - let generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options); let (state, _) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -12517,7 +12481,7 @@ mod canvas_generation_tests { screen_color: None, }; let prompt = "恢复已受理视觉规范图"; - let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); + let generation_prompt = build_platform_art_asset_prompt(prompt, &options); let (_, _, frozen_platform_session) = resolve_canvas_sync_api_credentials(None, None).expect("freeze recovery account"); let frozen_platform_session = @@ -12831,21 +12795,32 @@ mod canvas_generation_tests { } #[test] - fn canonical_art_spritesheet_request_preserves_project_requirements() { - let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法"); - assert_eq!(descriptions.len(), 1); - assert!(descriptions[0].contains("原创收集玩法")); - assert!(descriptions[0].contains("数量、类别、排列和切片方式由本次需求决定")); + fn canonical_art_spritesheet_request_preserves_description_and_internal_newlines() { + let options = PlatformArtAssetGenerationOptions { + asset_kind: GameCreationAppAssetKind::IconSpritesheet, + ..PlatformArtAssetGenerationOptions::default() + }; + let prompt = "\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}"; + validate_platform_art_icon_prompt(prompt).expect("valid multiline description"); + assert_eq!( + build_platform_art_asset_prompt(prompt, &options), + "金币\n木制宝箱\n银色钥匙" + ); } #[test] - fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() { - let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128)); - - assert!(!descriptions.is_empty()); - assert!(descriptions - .iter() - .all(|description| description.chars().count() <= 200)); + fn canonical_art_spritesheet_description_obeys_external_editor_unicode_item_limit() { + let boundary = "🪙".repeat(200); + validate_platform_art_icon_prompt(&format!("\u{0085} {boundary}\n")) + .expect("200 Unicode characters after Rust trim must pass"); + let error = validate_platform_art_icon_prompt(&"🪙".repeat(201)) + .expect_err("201 Unicode characters must be rejected instead of truncated"); + assert!(error.contains("200"), "{error}"); + for blank in ["", " \r\n\t", "\u{0085}\u{2003}"] { + assert!(validate_platform_art_icon_prompt(blank).is_err()); + } + validate_platform_art_icon_prompt("\u{feff}") + .expect("BOM is not whitespace under the API Server Rust trim contract"); } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index 632cddbb7..7354b7ce8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::borrow::Cow; use std::collections::BTreeSet; @@ -143,6 +143,13 @@ struct AgcSkillManifestEntry { sha256: String, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgcSkillCatalogEntry { + pub(crate) name: String, + pub(crate) description: String, +} + fn is_safe_skill_relative_path(value: &str) -> bool { let path = Path::new(value); !value.is_empty() @@ -256,6 +263,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result { Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref()))) } +/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。 +/// +/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。 +#[tauri::command] +pub(crate) fn list_agc_skill_catalog() -> Result, String> { + Ok(validated_skill_pack_manifest()? + .skills + .into_iter() + .map(|entry| AgcSkillCatalogEntry { + name: entry.name, + description: entry.purpose, + }) + .collect()) +} + pub(crate) fn render_agc_skill_pack_index() -> Result { let manifest = validated_skill_pack_manifest()?; let mut lines = vec![format!( @@ -350,6 +372,19 @@ mod tests { } } + #[test] + fn skill_catalog_is_derived_from_the_validated_manifest() { + let catalog = list_agc_skill_catalog().expect("skill catalog"); + assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len()); + for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES { + let entry = catalog + .iter() + .find(|entry| entry.name == expected_name) + .expect("expected bundled skill"); + assert!(!entry.description.trim().is_empty()); + } + } + #[test] fn skill_content_digest_is_stable_across_lf_and_crlf() { fn digest(bytes: &[u8]) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 5c5168d14..c03b02a91 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -241,11 +241,72 @@ impl ProjectCommandTree { #[cfg(not(windows))] { let _ = child; + #[cfg(target_os = "linux")] + { + let Self::Group { pid, .. } = self; + // 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。 + // 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。 + if !linux_project_command_group_has_live_members(*pid)? { + return Ok(()); + } + } self.request_owned_group_termination().map(|_| ()) } } } +#[cfg(target_os = "linux")] +fn linux_project_command_group_has_live_members(group: u32) -> Result { + let inspect = || -> std::io::Result { + let process_group = i32::try_from(group) + .ok() + .filter(|group| *group > 0) + .ok_or_else(|| std::io::Error::other("受控命令进程组身份无效"))?; + if unsafe { libc::kill(-process_group, 0) } != 0 { + let error = std::io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ESRCH) { + Ok(false) + } else { + Err(error) + }; + } + for entry in fs::read_dir("/proc")? { + let entry = entry?; + if entry.file_name().to_string_lossy().parse::().is_err() { + continue; + } + let stat = match fs::read(entry.path().join("stat")) { + Ok(stat) => stat, + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + || error.raw_os_error() == Some(libc::ESRCH) => + { + continue; + } + Err(error) => return Err(error), + }; + let invalid_stat = || std::io::Error::other("无法解析 /proc 进程组状态"); + // comm 可以包含括号和非 UTF-8 字节;只解析最后一个分隔符后的 ASCII 字段。 + let end = stat + .windows(2) + .rposition(|pair| pair == b") ") + .ok_or_else(invalid_stat)?; + let tail = std::str::from_utf8(&stat[end + 2..]).map_err(|_| invalid_stat())?; + let mut fields = tail.split_whitespace(); + let state = fields.next().ok_or_else(invalid_stat)?; + let process_group = fields + .nth(1) + .and_then(|value| value.parse::().ok()) + .ok_or_else(invalid_stat)?; + if process_group == group && state != "Z" && state != "X" { + return Ok(true); + } + } + Ok(false) + }; + inspect().map_err(|error| format!("读取受控进程组存活状态失败:{error}")) +} + #[cfg(any(unix, test))] fn owned_project_command_group_identity_matches( expected: Option<&str>, @@ -2504,6 +2565,67 @@ where mod tests { use super::*; + #[cfg(target_os = "linux")] + #[tokio::test] + async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() { + use std::os::unix::process::CommandExt; + + const TEST: &str = + "command_exec::tests::exited_group_accepts_orphan_zombies_but_rejects_live_members"; + const FIXTURE: &str = "AGC_COMMAND_ORPHAN_FIXTURE"; + if std::env::var_os(FIXTURE).is_none() { + // subreaper 只影响隔离夹具,避免接管并行测试的子进程。 + let output = tokio::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST, "--nocapture"]) + .env(FIXTURE, "1") + .output() + .await + .unwrap(); + assert!(output.status.success(), "{output:?}"); + return; + } + assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0); + let mut command = tokio::process::Command::new("/bin/sh"); + command + .args(["-c", "sleep 60 & echo $!; read release"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + command.as_std_mut().process_group(0); + let mut child = command.spawn().unwrap(); + let tree = ProjectCommandTree::attach(&child).unwrap(); + let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap()); + let mut line = String::new(); + tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line) + .await + .unwrap(); + let descendant: i32 = line.trim().parse().unwrap(); + drop(child.stdin.take()); + child.wait().await.unwrap(); + let live_result = tree.after_main_exit(&mut child).await; + assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0); + let mut info = unsafe { std::mem::zeroed::() }; + assert_eq!( + unsafe { + libc::waitid( + libc::P_PID, + descendant as u32, + &mut info, + libc::WEXITED | libc::WNOWAIT, + ) + }, + 0 + ); + let zombie_result = tree.after_main_exit(&mut child).await; + assert_eq!( + unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) }, + descendant + ); + let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理"); + assert!(error.contains("leader 身份未确认"), "{error}"); + zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对"); + tree.after_main_exit(&mut child).await.unwrap(); + } + #[test] fn owned_process_group_refuses_missing_or_reused_leader_identity() { assert!(owned_project_command_group_identity_matches( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a079cca8b..fa4163a4d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -652,7 +652,7 @@ pub(crate) fn import_local_godot_project( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; if discover_local_godot_project_root(root)?.is_none() { - return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()); + return Err("所选工作区未在根目录或一层子目录发现 project.godot".to_string()); } let _lock = acquire_project_write_lock(root, "project.create")?; import_local_godot_project_at(root, project_id.trim(), name.trim()) @@ -887,37 +887,6 @@ pub(crate) async fn pick_local_project_directory( Ok(Some(path.to_string_lossy().into_owned())) } -#[tauri::command] -pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result, String> { - let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app.dialog().file().set_title("选择本地文件"); - if let Some(window) = app.get_webview_window("client") { - dialog = dialog.set_parent(&window); - } - dialog.pick_file(move |path| { - let _ = sender.send(path); - }); - let Some(path) = receiver - .await - .map_err(|_| "本地文件选择器意外关闭".to_string())? - else { - return Ok(None); - }; - let path = path - .into_path() - .map_err(|error| format!("读取本地文件失败:{error}"))?; - #[cfg(windows)] - crate::register_game_creator_user_selected_path(&path, false); - if let Err(error) = - crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择文件") - { - #[cfg(windows)] - crate::revoke_game_creator_user_selected_path(&path); - return Err(error); - } - Ok(Some(path.to_string_lossy().into_owned())) -} - #[tauri::command] pub(crate) fn open_local_project_directory( app: tauri::AppHandle, @@ -4963,6 +4932,9 @@ pub(crate) fn prepare_local_project_asset_generation( } let asset_kind = normalize_platform_art_asset_generation_kind(kind) .ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?; + if asset_kind == GameCreationAppAssetKind::IconSpritesheet { + crate::agent::validate_platform_art_icon_prompt(prompt)?; + } // 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝, // 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。 let reference_asset_ids = @@ -4972,7 +4944,12 @@ pub(crate) fn prepare_local_project_asset_generation( let target_category = normalize_platform_art_target_category(target_category)?; Ok(LocalProjectAssetGenerationRequest { root: PathBuf::from(project_path), - prompt: local_project_asset_prompt(prompt)?, + prompt: if asset_kind == GameCreationAppAssetKind::IconSpritesheet { + // 图集只采用 API Server 的 trim + 单条长度合同,不套普通图片的控制字符限制。 + prompt.trim().to_string() + } else { + local_project_asset_prompt(prompt)? + }, options: PlatformArtAssetGenerationOptions { output_path: local_project_asset_single_line( output_path, @@ -5076,6 +5053,28 @@ mod local_project_asset_generation_tests { ) } + #[test] + fn icon_prompt_matches_api_server_single_description_contract() { + for prompt in [ + " 金币\n\n宝箱\t钥匙 ".to_string(), + "😀".repeat(200), + "\u{85}金币\u{85}".to_string(), + "\u{feff}".to_string(), + "金币\u{0}宝箱".to_string(), + ] { + assert_eq!( + prepare("icon-spritesheet", &prompt) + .expect("valid server description") + .prompt, + prompt.trim() + ); + } + for prompt in ["\u{85}\n ".to_string(), "😀".repeat(201)] { + assert!(prepare("icon-spritesheet", &prompt).is_err()); + } + assert!(prepare("image", &"图".repeat(201)).is_ok()); + } + #[test] fn toolbar_kinds_all_reach_the_shared_generation_channel() { for kind in [ @@ -5658,18 +5657,6 @@ pub(crate) fn write_local_project_file( write_local_project_file_at(root, relative_path.trim(), &content) } -#[tauri::command] -pub(crate) fn delete_local_project_file( - project_path: String, - relative_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "file.delete")?; - let _lock = acquire_project_write_lock(root, "file.delete")?; - advance_agent_runtime_project_revision_locked(root)?; - delete_local_project_file_at(root, relative_path.trim()) -} - #[tauri::command] pub(crate) fn read_local_game_memory( project_path: String, @@ -5718,18 +5705,6 @@ pub(crate) fn write_local_game_memory( write_local_game_memory_at(root, scope.trim(), &content) } -#[tauri::command] -pub(crate) fn delete_local_game_memory( - project_path: String, - scope: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "memory.delete")?; - let _lock = acquire_project_write_lock(root, "memory.delete")?; - advance_agent_runtime_project_revision_locked(root)?; - delete_local_game_memory_at(root, scope.trim()) -} - #[tauri::command] pub(crate) fn list_game_creator_agent_sessions( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index d3f31417c..839311a3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -862,6 +862,48 @@ pub(crate) fn validate_game_creator_private_path_ancestors( Ok(()) } +/// 客户端安装身份基线:`productName` / `identifier` 由构建期按渠道注入。 +/// +/// 默认渠道保持基线身份,其它渠道派生 `<基线>.<渠道>`,因此同一台设备上 +/// 不同渠道各自拥有独立的安装目录与 AppData 数据目录。 +#[cfg(windows)] +const GAME_CREATOR_APP_IDENTIFIER: &str = "world.genarrative.ai-game-creator"; + +#[cfg(windows)] +fn is_game_creator_packaged_app_data_leaf(name: &std::ffi::OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + let Some(remainder) = name.strip_prefix(GAME_CREATOR_APP_IDENTIFIER) else { + return false; + }; + if remainder.is_empty() { + return true; + } + // 渠道名是小写字母开头的 32 位以内小写字母、数字与连字符。 + remainder + .strip_prefix('.') + .is_some_and(|channel| !channel.is_empty() && channel.len() <= 32) +} + +/// 路径是否位于 `<平台配置根>/<安装身份目录>` 之内。提权助手是独立进程, +/// 看不到父进程的配置目录覆盖,因此这里必须按目录名识别全部渠道身份。 +#[cfg(windows)] +fn path_is_inside_game_creator_packaged_app_data(root: &Path, path: &Path) -> bool { + let root = normalize_windows_policy_path(root); + let path = normalize_windows_policy_path(path); + let Ok(relative) = path.strip_prefix(&root) else { + return false; + }; + relative + .components() + .next() + .is_some_and(|component| match component { + std::path::Component::Normal(name) => is_game_creator_packaged_app_data_leaf(name), + _ => false, + }) +} + /// Automatic ACL repair for managed paths is limited to objects AGC owns. A /// separate, explicit user-selected scope below covers native picker/project /// root results, including projects stored outside the current profile. @@ -900,21 +942,20 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool { // elevated helper runs in a fresh process, so the in-memory runtime // config-dir override is unavailable there; recognize the packaged // path from the user's profile as well. - let packaged_app_data = home - .join("AppData") - .join("Local") - .join("world.genarrative.ai-game-creator"); - if starts_with_path(&packaged_app_data) { + #[cfg(windows)] + if path_is_inside_game_creator_packaged_app_data(&home.join("AppData").join("Local"), &path) + { return true; } } + #[cfg(windows)] for environment_name in ["LOCALAPPDATA", "APPDATA"] { if let Some(root) = std::env::var_os(environment_name) .map(PathBuf::from) .filter(|candidate| candidate.is_absolute()) { - if starts_with_path(&root.join("world.genarrative.ai-game-creator")) { + if path_is_inside_game_creator_packaged_app_data(&root, &path) { return true; } } @@ -1096,10 +1137,9 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop .filter(|candidate| candidate.is_absolute()) { if is_builtin_root(home.join(".config").join("genarrative")) - || is_builtin_root( - home.join("AppData") - .join("Local") - .join("world.genarrative.ai-game-creator"), + || path_is_inside_game_creator_packaged_app_data( + &home.join("AppData").join("Local"), + &path, ) { return WindowsAclRepairScope::Managed; @@ -1110,7 +1150,7 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop .map(PathBuf::from) .filter(|candidate| candidate.is_absolute()) { - if is_builtin_root(root.join("world.genarrative.ai-game-creator")) { + if path_is_inside_game_creator_packaged_app_data(&root, &path) { return WindowsAclRepairScope::Managed; } } @@ -5044,18 +5084,38 @@ mod private_path_elevation_policy_tests { #[cfg(windows)] #[test] - fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() { + fn packaged_appdata_paths_keep_managed_repair_scope_for_every_channel() { let root = std::env::var_os("LOCALAPPDATA") .or_else(|| std::env::var_os("APPDATA")) .map(PathBuf::from) .expect("local appdata"); - let packaged = root.join("world.genarrative.ai-game-creator"); - let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display())); - assert!(game_creator_private_path_allows_auto_elevation(&verbatim)); + // 默认渠道是基线目录,其它渠道派生 `<基线>.<渠道>`;提权助手按目录名识别, + // 两种身份都必须落在 managed 赋权范围内。 + for leaf in [ + "world.genarrative.ai-game-creator", + "world.genarrative.ai-game-creator.release", + "world.genarrative.ai-game-creator.beta-2", + ] { + let packaged = root.join(leaf).join("diagnostics"); + let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display())); + assert!( + game_creator_private_path_allows_auto_elevation(&verbatim), + "{leaf}" + ); + assert_eq!( + game_creator_runtime_config_repair_scope(&verbatim), + WindowsAclRepairScope::Managed, + "{leaf}" + ); + } + + // 相似前缀不是安装身份目录,不能落进 managed 赋权范围。 + let foreign = root.join("world.genarrative.ai-game-creator-backup"); + assert!(!game_creator_private_path_allows_auto_elevation(&foreign)); assert_eq!( - game_creator_runtime_config_repair_scope(&verbatim), - WindowsAclRepairScope::Managed + game_creator_runtime_config_repair_scope(&foreign), + WindowsAclRepairScope::UserSelected ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index cb3f75eb7..151a18cfe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -157,6 +157,7 @@ mod tool_plan_handoff; mod user_input; mod windows; +use agent::design_tools::*; use agent::*; use agent_native_tools::*; use asset_generation_tasks::*; @@ -2005,6 +2006,13 @@ fn show_startup_error_dialog(log_path: Option<&Path>) { } } +/// 客户端产品名跟随构建期渠道身份:默认渠道是「陶泥儿」,其它渠道带渠道后缀 +/// (例如「陶泥儿 Release」)。同机并存的渠道客户端因此在窗口标题、任务栏与 +/// Alt-Tab 里可区分;默认渠道结果不变。 +pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String { + app.package_info().name.clone() +} + /// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` /// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 /// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 @@ -2468,6 +2476,16 @@ fn main() { setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); error })?; + // 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只 + // 由构建期身份决定,因此必须在这里按产品名覆盖。 + match app.get_webview_window("client") { + Some(window) => { + if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) { + app_log!("startup.window-title.failed: {error}"); + } + } + None => app_log!("startup.window-title.failed: 缺少 client 主窗口"), + } spawn_project_snapshot_scheduler(app.handle().clone()); if let Err(error) = builtin_plugins::initialize(&config_dir) { app_log!("startup.builtin-plugins.initialize.failed: {error}"); @@ -2560,10 +2578,10 @@ fn main() { rename_local_game_project, suggest_automatic_project_name, polish_local_project_prompt, - pick_local_file, pick_client_extension_file, pick_client_extension_directory, list_client_extensions, + list_agc_skill_catalog, import_client_extension, set_client_extension_enabled, rename_client_extension, @@ -2596,6 +2614,7 @@ fn main() { debug_fast_forward_design_session, continue_design_agent_session, decide_design_phase, + import_design_workspace_file, list_design_workspace, read_design_workspace_file, start_game_creator_agent_runtime_task, @@ -2677,12 +2696,10 @@ fn main() { read_local_project_media_preview, cancel_local_project_resource_preview_scope, write_local_project_file, - delete_local_project_file, read_local_game_memory, read_local_agent_memory, write_local_agent_memory, write_local_game_memory, - delete_local_game_memory, list_game_creator_agent_sessions, create_game_creator_agent_session, fork_game_creator_agent_session, @@ -2707,7 +2724,6 @@ fn main() { write_project_permission_policy, open_game_creator_workspace_window, open_game_creator_launcher_window, - open_project_supervisor_chat_window, start_local_game_preview, activate_local_game_preview, stop_local_game_preview, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 2094ef492..ae4ad0550 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -84,9 +84,15 @@ fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool { metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata) } +/// Godot 工程标记:`project.godot` 解析为普通文件即命中。 +/// +/// 2026-09-21 解除“必须是普通文件”的限制:符号链接、Windows reparse point 与 +/// 硬链接一律跟随,不再因为 `project.godot` 本身是链接而拒绝整个工作区。判据只 +/// 保留“解析后仍是文件”,目录或悬空链接仍然不算命中。 fn inspect_godot_project_marker(root: &Path) -> Result { let project_file = root.join("project.godot"); - let metadata = match fs::symlink_metadata(&project_file) { + // `fs::metadata` 跟随符号链接 / reparse point,因此链接指向的真实对象才是判据。 + let metadata = match fs::metadata(&project_file) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(error) => { @@ -96,64 +102,12 @@ fn inspect_godot_project_marker(root: &Path) -> Result { )); } }; - if godot_metadata_is_link(&metadata) { - return Err(format!( - "Godot 项目文件不能是符号链接或 reparse point:{}", - project_file.display() - )); - } if !metadata.is_file() { return Err(format!( - "Godot 项目文件必须是普通文件:{}", + "Godot 项目文件必须是文件:{}", project_file.display() )); } - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if metadata.nlink() != 1 { - return Err(format!( - "Godot 项目文件不能是硬链接文件:{}", - project_file.display() - )); - } - } - #[cfg(windows)] - { - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, - }; - - const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - let file = fs::File::open(&project_file).map_err(|error| { - format!( - "打开 Godot 项目文件失败:{}: {error}", - project_file.display() - ) - })?; - // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. - let mut information = unsafe { std::mem::zeroed::() }; - // SAFETY: file owns a live handle and information is a valid output pointer. - // 取不到句柄信息、目录与 reparse point 一律按拒绝处理,保持 fail-closed。 - if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 - || information.dwFileAttributes - & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) - != 0 - { - return Err(format!( - "读取 Godot 项目文件 Windows 身份失败:{}", - std::io::Error::last_os_error() - )); - } - if information.nNumberOfLinks != 1 { - return Err(format!( - "Godot 项目文件不能是硬链接文件:{}", - project_file.display() - )); - } - } Ok(true) } @@ -166,6 +120,62 @@ fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result Result<(), String> { + let path = root.join("project.godot"); + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "读取 Godot 工程配置失败:{}: {error}", + path.display() + )); + } + }; + let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let ends_with_newline = text.ends_with('\n'); + let mut replaced = false; + let mut lines = Vec::new(); + for line in text.lines() { + let trimmed = line.trim_start(); + if !replaced { + if let Some(rest) = trimmed.strip_prefix("config/name") { + let rest = rest.trim_start(); + if let Some(value) = rest.strip_prefix('=') { + let value = value.trim(); + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + let indent = &line[..line.len() - trimmed.len()]; + lines.push(format!( + "{indent}config/name=\"{}\"", + escape_godot_project_string(name) + )); + replaced = true; + continue; + } + } + } + } + lines.push(line.to_string()); + } + if !replaced { + return Ok(()); + } + let mut updated = lines.join(newline); + if ends_with_newline { + updated.push_str(newline); + } + write_game_creator_private_file(&path, updated.as_bytes(), "Godot 工程配置") +} + +fn escape_godot_project_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> { let Some(value) = value else { return Ok(()); @@ -346,13 +356,9 @@ pub(crate) fn discover_local_godot_project_root( matches.push(validate_godot_project_child_name(&entry.file_name())?); } matches.sort(); - if matches.len() > 1 { - return Err(format!( - "工作区一层子目录中发现多个 Godot 项目:{}", - matches.join("、") - )); - } - let Some(relative_root) = matches.pop() else { + // 2026-09-21 解除“必须唯一”的限制:一层子目录出现多个 Godot 工程时按名称排序 + // 取第一个,结果确定且可复现,不再因为存在第二个工程而整体失败关闭。 + let Some(relative_root) = matches.into_iter().next() else { return Ok(None); }; @@ -694,9 +700,8 @@ pub(crate) fn import_local_godot_project_at( if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { return Err("Godot 工作区目录不存在或不是普通文件夹".to_string()); } - let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| { - "所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string() - })?; + let godot_project_root = discover_local_godot_project_root(root)? + .ok_or_else(|| "所选工作区未在根目录或一层子目录发现 project.godot".to_string())?; if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs index 434adb727..ab0f99690 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -133,17 +133,18 @@ fn root_godot_project_takes_priority_over_direct_child_projects() { } #[test] -fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() { +fn picks_the_first_direct_child_godot_project_in_name_order() { let workspace = godot_import_test_path("multiple-children"); write_godot_project(&workspace.join("alpha"), "Alpha"); write_godot_project(&workspace.join("beta"), "Beta"); - let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") - .expect_err("multiple direct child Godot projects must fail"); + let result = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") + .expect("multiple direct child Godot projects must resolve deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); - assert!(!workspace.join(".agent").exists()); - assert!(!workspace.join("alpha/.agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha")); + assert_manifest_godot_root(&workspace, "alpha"); + assert!(workspace.join(".agent/manifest.json").is_file()); + // 未选中的候选工程不写任何 AGC 元数据。 assert!(!workspace.join("beta/.agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -187,23 +188,21 @@ fn calibrates_existing_manifest_to_the_discovered_godot_root() { } #[test] -fn ambiguous_layout_does_not_rewrite_an_existing_manifest() { +fn ambiguous_layout_calibrates_an_existing_manifest_deterministically() { let workspace = godot_import_test_path("ambiguous-existing"); init_local_game_project_at(&workspace, "existing-project", "Existing Project") .expect("initialize existing workspace"); write_godot_project(&workspace.join("game"), "Game"); write_godot_project(&workspace.join("other"), "Other"); - let manifest_path = workspace.join(".agent/manifest.json"); - let original = fs::read(&manifest_path).expect("read original manifest"); - let error = import_local_godot_project_at(&workspace, "ignored", "Ignored") - .expect_err("ambiguous existing workspace must fail"); + let result = import_local_godot_project_at(&workspace, "ignored", "Ignored") + .expect("ambiguous existing workspace must calibrate to one candidate"); - assert!(error.contains("多个 Godot 项目"), "{error}"); - assert_eq!( - fs::read(&manifest_path).expect("read unchanged manifest"), - original - ); + assert_eq!(result.manifest.project_id, "existing-project"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + assert!(!workspace.join("game/.agent").exists()); + assert!(!workspace.join("other/.agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -286,7 +285,7 @@ fn manifest_read_rejects_unsafe_persisted_godot_project_root() { #[cfg(unix)] #[test] -fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { +fn accepts_symbolic_link_project_marker() { use std::os::unix::fs::symlink; let workspace = godot_import_test_path("linked-marker"); @@ -294,11 +293,51 @@ fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); symlink("real.godot", workspace.join("project.godot")).expect("link project marker"); - let error = import_local_godot_project_at(&workspace, "linked", "Linked") - .expect_err("linked project.godot must fail"); + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("linked project.godot must be accepted"); - assert!(error.contains("符号链接"), "{error}"); - assert!(!workspace.join(".agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn accepts_windows_hard_link_project_marker() { + let workspace = godot_import_test_path("windows-hard-link-marker"); + fs::create_dir_all(&workspace).expect("create hard link marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + fs::hard_link( + workspace.join("real.godot"), + workspace.join("project.godot"), + ) + .expect("hard link project marker"); + + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("hard linked project.godot must be accepted"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn accepts_windows_reparse_project_marker() { + let workspace = godot_import_test_path("windows-reparse-marker"); + fs::create_dir_all(&workspace).expect("create reparse marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + if std::os::windows::fs::symlink_file("real.godot", workspace.join("project.godot")).is_err() { + // 未开启开发者模式的机器创建文件符号链接需要额外权限,跳过而不是误报通过。 + fs::remove_dir_all(workspace).ok(); + return; + } + + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("reparse point project.godot must be accepted"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); fs::remove_dir_all(workspace).ok(); } @@ -344,7 +383,7 @@ fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() { #[cfg(unix)] #[test] -fn rejects_linked_marker_inside_a_regular_child_candidate() { +fn accepts_linked_marker_inside_a_regular_child_candidate() { use std::os::unix::fs::symlink; let workspace = godot_import_test_path("linked-child-marker"); @@ -353,11 +392,12 @@ fn rejects_linked_marker_inside_a_regular_child_candidate() { fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker"); symlink("real.godot", godot_root.join("project.godot")).expect("link project marker"); - let error = import_local_godot_project_at(&workspace, "linked", "Linked") - .expect_err("linked marker in a regular child must fail"); + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("linked marker in a regular child must be accepted"); - assert!(error.contains("符号链接"), "{error}"); - assert!(!workspace.join(".agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + assert!(!godot_root.join(".agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -397,6 +437,42 @@ fn rejects_non_godot_directory_without_writing_agent_metadata() { fs::remove_dir_all(root).ok(); } +#[test] +fn rewrites_only_the_godot_display_name_line() { + let root = godot_import_test_path("display-name"); + fs::create_dir_all(&root).expect("create project root"); + fs::write( + root.join("project.godot"), + "config_version=5\n\n[application]\n\nconfig/name=\"模板名\"\nrun/main_scene=\"res://scenes/main.tscn\"\n", + ) + .expect("write project.godot"); + + apply_godot_project_display_name(&root, "我的\"平台跳跃\"").expect("rewrite display name"); + + assert_eq!( + fs::read_to_string(root.join("project.godot")).expect("read project.godot"), + "config_version=5\n\n[application]\n\nconfig/name=\"我的\\\"平台跳跃\\\"\"\nrun/main_scene=\"res://scenes/main.tscn\"\n" + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn keeps_a_godot_project_without_a_display_name_line_untouched() { + let root = godot_import_test_path("no-display-name"); + fs::create_dir_all(&root).expect("create project root"); + let original = + "config_version=5\n\n[application]\n\nrun/main_scene=\"res://scenes/main.tscn\"\n"; + fs::write(root.join("project.godot"), original).expect("write project.godot"); + + apply_godot_project_display_name(&root, "我的项目").expect("no display name is not an error"); + + assert_eq!( + fs::read_to_string(root.join("project.godot")).expect("read project.godot"), + original + ); + fs::remove_dir_all(root).ok(); +} + fn write_raw_manifest_fixture(workspace: &Path, payload: &serde_json::Value) -> (PathBuf, String) { let manifest_path = workspace.join(".agent/manifest.json"); fs::create_dir_all(manifest_path.parent().expect("manifest parent")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index b9a4e644f..fbf8fbc10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -807,6 +807,45 @@ async fn ensure_template_installed( }) } +/// 清单来源:远端读取或本机缓存兜底。快照里的 `source` 原样回传,前端据此提示「本机缓存」。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TemplateIndexSource { + Network, + Cache, +} + +impl TemplateIndexSource { + fn as_str(self) -> &'static str { + match self { + Self::Network => "network", + Self::Cache => "cache", + } + } +} + +/// 远端清单不可用时回退本机缓存。 +/// +/// 两条路径都要过同一份 schema 校验:远端已经答话但正文非法时直接失败关闭, +/// 不用缓存掩盖;缓存自己损坏时也不能被当成可用清单。 +fn resolve_template_index( + remote: Result, + cached: Option, +) -> Result<(String, TemplateIndexSource), String> { + match remote { + Ok(body) => { + parse_game_template_library_index(&body)?; + Ok((body, TemplateIndexSource::Network)) + } + Err(error) => match cached { + Some(cached) => { + parse_game_template_library_index(&cached)?; + Ok((cached, TemplateIndexSource::Cache)) + } + None => Err(error), + }, + } +} + #[tauri::command] pub(crate) async fn fetch_game_template_library( app: tauri::AppHandle, @@ -820,26 +859,21 @@ pub(crate) async fn fetch_game_template_library( TEMPLATE_LIBRARY_INDEX_KEY ); let client = build_template_library_client(); - let (body, source) = + let remote = match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await { + // 响应回来了但正文不是合法 UTF-8 属于「远端答非所问」,不能用缓存掩盖。 Ok(bytes) => { - let body = - String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?; - parse_game_template_library_index(&body)?; - with_validated_platform_session_identity(&identity, || { - write_cached_index(&cache_root, &body); - Ok(()) - })?; - (body, "network") + Ok(String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?) } - Err(error) => match read_cached_index(&cache_root) { - Some(cached) => { - parse_game_template_library_index(&cached)?; - (cached, "cache") - } - None => return Err(error), - }, + Err(error) => Err(error), }; + let (body, source) = resolve_template_index(remote, read_cached_index(&cache_root))?; + if source == TemplateIndexSource::Network { + with_validated_platform_session_identity(&identity, || { + write_cached_index(&cache_root, &body); + Ok(()) + })?; + } validate_platform_session_identity(&identity)?; let (header, templates) = parse_game_template_library_index(&body)?; let installed = collect_installed_records(&cache_root); @@ -859,7 +893,7 @@ pub(crate) async fn fetch_game_template_library( library_version: header.library_version, updated_at: header.updated_at, fetched_at_millis: now_millis(), - source: source.to_string(), + source: source.as_str().to_string(), templates: entries, }) } @@ -956,6 +990,16 @@ pub(crate) fn create_project_from_installed_template_at( &project_name, ); } + // Godot 模板同样按工程文件识别:走既有 Godot 导入流程,写入 + // `godotProjectRoot` 并按用户输入改写工程显示名,不生成 Web 占位入口。 + if discover_local_godot_project_root(&project_root)?.is_some() { + apply_godot_project_display_name(&project_root, &project_name)?; + return import_local_godot_project_at( + &project_root, + &format!("gameagent-{workspace_id}"), + &project_name, + ); + } init_local_game_project_at( &project_root, &format!("gameagent-{workspace_id}"), @@ -1181,6 +1225,50 @@ mod tests { )) } + #[test] + fn template_index_prefers_the_network_body_and_labels_its_source() { + let body = sample_index_body(); + let (resolved, source) = + resolve_template_index(Ok(body.clone()), Some("{not json".to_string())) + .expect("合法远端正文优先于缓存"); + assert_eq!(source.as_str(), "network"); + assert_eq!(resolved, body); + } + + #[test] + fn template_index_falls_back_to_the_cache_body_when_the_remote_fetch_fails() { + let cached = sample_index_body(); + let (resolved, source) = + resolve_template_index(Err("网络不可用".to_string()), Some(cached.clone())) + .expect("远端不可用时回退本机缓存"); + assert_eq!(source.as_str(), "cache"); + assert_eq!(resolved, cached); + } + + #[test] + fn template_index_keeps_the_remote_error_when_no_cache_exists() { + let error = resolve_template_index(Err("远端超时".to_string()), None) + .expect_err("没有缓存时必须暴露远端错误"); + assert_eq!(error, "远端超时"); + } + + #[test] + fn template_index_rejects_an_invalid_remote_body_without_masking_it_with_cache() { + let cached = sample_index_body(); + let invalid = cached.replace(TEMPLATE_LIBRARY_SCHEMA_VERSION, "agc-template-library.v2"); + let error = resolve_template_index(Ok(invalid), Some(cached)) + .expect_err("远端已答话但正文非法时不得用缓存掩盖"); + assert!(error.contains("版本不受支持"), "{error}"); + } + + #[test] + fn template_index_rejects_a_corrupt_cache_instead_of_serving_it() { + let error = + resolve_template_index(Err("网络不可用".to_string()), Some("{not json".to_string())) + .expect_err("损坏的缓存必须失败关闭"); + assert!(error.contains("不是有效 JSON"), "{error}"); + } + #[test] fn rejects_index_with_unsupported_schema_or_duplicate_templates() { let unsupported = @@ -1323,6 +1411,8 @@ mod tests { .unwrap() .join("escape.txt") .exists()); + // 越界归档失败后不能留下安装记录:目录残留不算「已下载」。 + assert!(collect_installed_records(destination.path()).is_empty()); } #[test] @@ -1371,6 +1461,13 @@ mod tests { let error = install_template_archive(cache_root.path(), &summary, &archive) .expect_err("digest mismatch rejected"); assert!(error.contains("完整性校验失败"), "{error}"); + + // 大小或摘要不一致必须在任何落盘之前失败:不留安装目录,也不产生「已下载」判据。 + let directory = + installed_template_dir(cache_root.path(), &summary.id, &summary.template_version) + .expect("install dir"); + assert!(!directory.exists(), "拒绝的模板不得留下安装目录"); + assert!(collect_installed_records(cache_root.path()).is_empty()); } #[test] @@ -1405,6 +1502,49 @@ mod tests { fs::remove_dir_all(&projects_root).ok(); } + #[test] + fn godot_template_creates_native_project_with_relative_root_and_display_name() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let projects_root = unique_projects_root(); + let config_source = "config_version=5\n\n[application]\n\nconfig/name=\"Godot 模板\"\nrun/main_scene=\"res://scenes/main.tscn\"\n"; + let archive = build_archive(&[ + ("project.godot", config_source.as_bytes()), + ("scenes/main.tscn", b"[gd_scene format=3]\n"), + ]); + let mut summary = sample_summary(); + summary.id = "godot-fixture-template".to_string(); + summary.runtime = "godot".to_string(); + summary.entry = "project.godot".to_string(); + summary.zip_size_bytes = archive.len() as u64; + summary.zip_sha256 = sha256_hex(&archive); + let record = install_template_archive(cache_root.path(), &summary, &archive) + .expect("install Godot template"); + + let result = create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + Some("我的平台跳跃"), + false, + ) + .expect("create Godot project from template"); + + // Godot 模板走 Godot 导入:记录相对根,且不生成 Web 占位入口与并行目录。 + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_eq!(result.manifest.name, "我的平台跳跃"); + let project_root = Path::new(&result.project_path); + assert!(!project_root.join("game").exists()); + assert!(project_root.join("scenes/main.tscn").is_file()); + let config = + fs::read_to_string(project_root.join("project.godot")).expect("read project.godot"); + assert!(config.contains("config/name=\"我的平台跳跃\""), "{config}"); + assert!( + config.contains("run/main_scene=\"res://scenes/main.tscn\""), + "只改显示名,其余行逐字保留:{config}" + ); + assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists()); + fs::remove_dir_all(&projects_root).ok(); + } + #[test] fn refuses_to_create_project_when_template_is_not_installed() { let projects_root = tempfile::tempdir().expect("temp dir"); @@ -1415,6 +1555,38 @@ mod tests { assert!(error.contains("模板尚未安装"), "{error}"); } + #[test] + fn failed_project_creation_removes_the_partial_project_directory() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let projects_root = unique_projects_root(); + // 安装目录里只有安装记录、没有任何可复制文件:复制阶段必须失败关闭, + // 且已经创建的项目目录要被清掉,不能把半成品留在自动工作区里。 + let directory = installed_template_dir(cache_root.path(), "empty-template", "1.0.0") + .expect("install dir"); + ensure_game_creator_private_directory_tree(&directory, "模板安装目录") + .expect("create install dir"); + write_game_creator_private_file( + &directory.join(TEMPLATE_INSTALLED_MARKER_FILE), + b"{\"templateId\":\"empty-template\"}", + "模板安装记录", + ) + .expect("write install marker"); + + let error = + create_project_from_installed_template_at(&projects_root, &directory, None, false) + .expect_err("模板没有可复制文件时必须失败关闭"); + assert!(error.contains("没有可复制的文件"), "{error}"); + let leftovers = fs::read_dir(&projects_root) + .map(|entries| { + entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .collect::>() + }) + .unwrap_or_default(); + assert!(leftovers.is_empty(), "失败不得留下项目目录:{leftovers:?}"); + } + fn template_source_files(root: &Path) -> Vec<(String, Vec)> { fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, Vec)>) { for entry in fs::read_dir(directory).expect("read template directory") { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index a24590eb0..b0bbdcde2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -451,18 +451,6 @@ fn dispatch_static_delegate_plain_repair( ) } -#[test] -fn supervisor_chat_window_carries_encoded_project_path() { - assert_eq!( - supervisor_chat_window_url("/tmp/AI Game 项目").to_string(), - "index.html?supervisor-chat&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE" - ); - assert_eq!( - supervisor_chat_window_url("/tmp/a&b?#%+c").to_string(), - "index.html?supervisor-chat&projectPath=%2Ftmp%2Fa%26b%3F%23%25%2Bc" - ); -} - #[test] fn project_supervisor_runtime_id_is_normalized_and_collected() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index b64d1a51d..42aee42ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -1827,7 +1827,7 @@ async fn agent_runtime_command_exec_diagnostic_command_cannot_pass_verification_ ) .await; - assert_eq!(observation.status, "ok"); + assert_observation_status(&observation, "ok"); assert!(observation.summary.contains("只作为诊断结果")); assert!(observation .detail diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index b722582a2..6e9abb9e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1649,6 +1649,7 @@ async fn direct_image_generation_notifies_after_manifest_commit() { }, ) .expect("allow generation"); + let execution = direct_execution_fixture(&root, "direct-image-refresh-turn").await; let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).expect("bind event receiver"); let sink = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); @@ -1691,6 +1692,12 @@ async fn direct_image_generation_notifies_after_manifest_commit() { .await .expect("read rejected result"); assert_eq!(rejected["isError"], true); + assert!( + rejected["content"][0]["text"] + .as_str() + .is_some_and(|message| message.contains("prompt")), + "{rejected}" + ); assert_eq!( read_manifest_invalidation_relay_payload_with_deadline(&listener) .expect_err("rejected generation must not emit a commit") @@ -1698,6 +1705,7 @@ async fn direct_image_generation_notifies_after_manifest_commit() { io::ErrorKind::TimedOut ); drop(bridge); + drop(execution); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); } @@ -2532,42 +2540,45 @@ fn project_directory_status_reports_workspace_relative_godot_root() { } #[test] -fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() { +fn project_directory_status_resolves_ambiguous_direct_child_godot_projects() { let root = unique_project_path(); - for child in ["alpha", "beta"] { + // 目录枚举顺序不可信:故意倒序创建,证明选择按名称而不是按创建顺序。 + for child in ["beta", "alpha"] { let godot_root = root.join(child); fs::create_dir_all(&godot_root).expect("create nested Godot project"); fs::write(godot_root.join("project.godot"), "[application]\n") .expect("write nested project.godot"); } - let error = inspect_local_project_directory_sync(root.to_string_lossy().to_string()) - .expect_err("ambiguous Godot workspace must fail inspection"); + let status = inspect_local_project_directory_sync(root.to_string_lossy().to_string()) + .expect("multiple direct child Godot projects must resolve deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); + assert!(status.is_godot_project); + assert_eq!(status.godot_project_root.as_deref(), Some("alpha")); fs::remove_dir_all(root).ok(); } #[test] -fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() { +fn godot_import_command_resolves_child_ambiguity_and_releases_the_lock() { let root = unique_project_path(); - for child in ["alpha", "beta"] { + for child in ["beta", "alpha"] { let godot_root = root.join(child); fs::create_dir_all(&godot_root).expect("create nested Godot project"); fs::write(godot_root.join("project.godot"), "[application]\n") .expect("write nested project.godot"); } - let error = import_local_godot_project( + let result = import_local_godot_project( root.to_string_lossy().into_owned(), "ambiguous".to_string(), "Ambiguous".to_string(), ) - .expect_err("ambiguous Godot workspace must fail before locking"); + .expect("ambiguous Godot workspace must import the first candidate deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha")); + assert!(root.join(".agent/manifest.json").is_file()); + assert!(!root.join("beta/.agent").exists()); assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists()); - assert!(!root.join(".agent").exists()); fs::remove_dir_all(root).ok(); } @@ -3542,7 +3553,8 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th { let root = unique_project_path(); let config_dir = unique_project_path(); - let base_url = spawn_mock_external_canvas_generation_api_server(None); + let (request_sender, request_receiver) = mpsc::channel(); + let base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); let _platform_session = crate::platform_session::install_test_platform_session( "toolbar-spritesheet-user", "editor-toolbar-spritesheet-key", @@ -3597,7 +3609,7 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th let asset = generate_local_project_asset( root.to_string_lossy().into_owned(), "icon-spritesheet".to_string(), - "原创收集玩法素材图集".to_string(), + "\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}".to_string(), Some("1:1".to_string()), Some("1K".to_string()), Some("游戏首版图集".to_string()), @@ -3608,6 +3620,23 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th .await .expect("toolbar spritesheet generation"); + let generation_request = request_receiver + .try_iter() + .find(|request| request.starts_with("POST /api/editor/icon-spritesheets/generations ")) + .expect("captured toolbar spritesheet request"); + let generation_body: Value = serde_json::from_str( + generation_request + .split_once("\r\n\r\n") + .expect("generation request body") + .1, + ) + .expect("generation request json"); + assert_eq!( + generation_body["iconDescriptions"], + serde_json::json!(["金币\n木制宝箱\n银色钥匙"]), + "客户端只去除首尾空白,完整保留用户描述,不注入或截断提示词" + ); + assert!(asset.local_path.starts_with("assets/canvas-generated/")); let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) @@ -6358,7 +6387,6 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", - &[], &options, ); for expected in [ @@ -6400,7 +6428,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { } #[test] -fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() { +fn art_spritesheet_generation_prompt_preserves_user_description() { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), asset_kind: GameCreationAppAssetKind::IconSpritesheet, @@ -6409,21 +6437,12 @@ fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_de }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物", - &[], &options, ); - for expected in [ - "原创透明核心美术素材图集", - "严格从用户需求和美术 brief 提取", - "素材类别与数量以当前项目需求为准", - "蛇头、直身、转角、尾部和四类食物", - "角色轮廓、图标排布与配色采用项目原创设计", - ] { - assert!( - prompt.contains(expected), - "art generation prompt missing {expected}" - ); - } + assert_eq!( + prompt, + "原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物" + ); let art_spec = platform_art_asset_art_spec(&options).to_string(); for expected in [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index 7ebba5ce9..af671e1c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -2777,7 +2777,7 @@ fn agent_run_history_prunes_to_latest_hundred_traces() { } #[test] -fn developer_project_file_and_memory_mutations_advance_project_revision() { +fn developer_project_file_and_memory_writes_advance_project_revision() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "开发面板 revision").expect("project init"); let project_path = root.to_string_lossy().into_owned(); @@ -2800,16 +2800,11 @@ fn developer_project_file_and_memory_mutations_advance_project_revision() { "项目记忆".to_string(), ) .expect("write project memory through command"); - delete_local_game_memory(project_path.clone(), "long".to_string()) - .expect("delete project memory through command"); - delete_local_project_file(project_path, "game/revision.txt".to_string()) - .expect("delete project file through command"); - assert_eq!( read_game_creator_agent_runtime_project_revision(&root) .expect("read developer mutation revision") .revision, - 5 + 3 ); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index fc146f387..4406f2e29 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -119,13 +119,6 @@ pub(crate) fn launcher_window_url() -> tauri::WebviewUrl { tauri::WebviewUrl::App(PathBuf::from("index.html?launcher")) } -pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUrl { - tauri::WebviewUrl::App(PathBuf::from(format!( - "index.html?supervisor-chat&projectPath={}", - percent_encode_query_value(project_path) - ))) -} - pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> { let project_path = project_path.trim(); if project_path.is_empty() { @@ -164,7 +157,7 @@ pub(crate) fn open_game_creator_workspace_window( existing.close().map_err(|error| error.to_string())?; } tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) - .title("陶泥儿") + .title(crate::game_creator_product_name(&app)) .decorations(false) .inner_size(1180.0, 820.0) .min_inner_size(760.0, 560.0) @@ -183,7 +176,7 @@ pub(crate) fn open_game_creator_launcher_window( existing.set_focus().map_err(|error| error.to_string())?; } else { tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) - .title("陶泥儿") + .title(crate::game_creator_product_name(&app)) .decorations(false) .inner_size(820.0, 640.0) .min_inner_size(720.0, 520.0) @@ -193,52 +186,3 @@ pub(crate) fn open_game_creator_launcher_window( window.close().map_err(|error| error.to_string())?; Ok(()) } - -#[tauri::command] -pub(crate) fn open_project_supervisor_chat_window( - app: tauri::AppHandle, - project_path: String, -) -> Result<(), String> { - let project_path = validate_workspace_window_project_path(&project_path)?; - #[cfg(not(debug_assertions))] - { - let _ = app; - let _ = project_path; - return Err("项目总控对话窗口仅在开发构建中可用".to_string()); - } - #[cfg(debug_assertions)] - { - if let Some(existing) = app.get_webview_window("supervisor-chat") { - let mut current_url = existing.url().map_err(|error| error.to_string())?; - let current_project_path = current_url - .query_pairs() - .find_map(|(key, value)| (key == "projectPath").then(|| value.into_owned())); - if current_project_path.as_deref() != Some(project_path) { - current_url.set_query(Some(&format!( - "supervisor-chat&projectPath={}", - percent_encode_query_value(project_path) - ))); - current_url.set_fragment(None); - existing - .navigate(current_url) - .map_err(|error| error.to_string())?; - } - existing.show().map_err(|error| error.to_string())?; - existing.unminimize().map_err(|error| error.to_string())?; - existing.set_focus().map_err(|error| error.to_string())?; - return Ok(()); - } - tauri::WebviewWindowBuilder::new( - &app, - "supervisor-chat", - supervisor_chat_window_url(project_path), - ) - .title("项目总控 Agent 对话") - .decorations(false) - .inner_size(820.0, 720.0) - .min_inner_size(560.0, 480.0) - .build() - .map_err(|error| error.to_string())?; - Ok(()) - } -} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4d28f041a..2403d3913 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1,7 +1,6 @@ /* eslint-disable react-refresh/only-export-components -- Testable pure helpers currently share this legacy app module. */ import { - type ChangeEvent, type FormEvent, type UIEvent, useCallback, @@ -12,288 +11,86 @@ import { } from 'react'; import { - GAME_CREATION_AGENT_CAPABILITIES, - GAME_CREATION_APP_COMMANDS, - GAME_CREATION_APP_LIMITED_RUN_COMMANDS, - type GameCreationAgentCapabilityDescriptor, - type GameCreationAgentRunTrace, - type GameCreationAgentToolCallTrace, - type GameCreationAppAssetKind, type GameCreationAppCommandDescriptor, - type GameCreationAppLimitedRunCommandDescriptor, type GameCreationAppManifest, - type GameCreationAppPermission, type GameCreationAppPreviewState, type GameCreationAppPreviewStatus, - parseGameCreationAppAssetKind, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD, - AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT, - AGENT_RUN_HISTORY_MAX_COUNT, - AGENT_RUN_HISTORY_VISIBLE_STEP, CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, createLocalProjectId, - PROJECT_SUPERVISOR_AGENT_ID, seedManifest, } from './app/constants'; import { useEscapeToClose } from './app/dialogs'; +import { claimInitialTurnForPage } from './app/initialTurnClaims'; import { resolveTauriInvoke } from './app/tauri'; import type { - AgentBackgroundSubmitMode, - AgentProgressEvent, - AgentRunControlResult, - AgentRunHistoryItem, - AgentRuntimeResponseStream, AgentRuntimeResult, AgentRuntimeState, - AgentRuntimeSteerResult, - AgentRuntimeUserInputRequest, - AgentStatusCard, ChatMessage, DesignAgentInput, DesignClarificationRequest, DesignEvent, DesignView, - DirectTurnCancelView, - GameCreatorAgentRuntimeUpdateEvent, - GameCreatorChatAgentReply, - GameCreatorLlmConfigStatus, - GameCreatorManifestInvalidatedEvent, - GameCreatorRoleAgentChatStreamEvent, - GenerateLocalGameDraftResult, - ImportCanvasExportResult, InitLocalProjectResult, LauncherImportedAttachment, - LimitedLocalCommandResult, - ListLocalProjectFilesResult, - LocalAgentMemoryResult, - LocalConversationMessageRecord, LocalConversationResult, - LocalGameMemoryResult, LocalGameProjectRevisionStatus, LocalPreviewResult, LocalPreviewStatus, - LocalProjectCheckpointResult, - LocalProjectCheckpointSummary, - LocalProjectDiffResult, - LocalProjectExportPackageResult, - LocalProjectExportPackagesResult, - LocalProjectFileEntry, - LocalProjectFileMutationResult, LocalProjectFileResult, - LocalProjectIndexResult, LocalProjectKind, - LocalProjectRestoreResult, - MemoryScope, - MemoryWriteMode, - OpenCanvasProjectResult, - PendingCommand, PendingUiConfirmation, - ProjectPermissionPolicy, ProjectPermissionPolicyView, - SyncCanvasProjectAssetsResult, TauriInvoke, - UploadLocalAssetResult, } from './app/types'; -import { useWindowChrome } from './components/windowChromeContext'; import { agentConversationId, - agentRuntimeCancelStatus, - agentRuntimeConversationStatus, - agentRuntimeNeedsUserInput, - agentRuntimeStartedRunId, - agentRuntimeStartStatus, agentRuntimeStateFromResult, - agentRuntimeSteerStatus, - conversationContainsProjectSupervisorResponseStream, createAgentChatRunId, createDefaultChatMessages, - createLocalConversationDraftMessage, - ensureProjectSupervisorActiveSessionId, isAgentFinalizationMessageId, - isAgentRuntimeTerminalState, isMissingAgentRuntimeResumeCommandError, isRuntimeConfigMissingError, - matchingAgentRuntimeForSteer, mergeAgentRuntimeStateIntoMap, - mergeProjectSupervisorConversation, - mergeProjectSupervisorResponseStream, - normalizeAgentRuntimeState, - projectNameFromPath, projectProfessionalAgentLabel, - projectRuntimeVisibleError, - projectSupervisorPendingRepairMatchesProfessional, - readProjectSupervisorActiveSessionId, - resolveProjectSupervisorRuntimeSubmission, - sameAgentRuntimeRun, - submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; import { - type DirectCodexTurnAttachment, - toDirectCodexTurnAttachments, -} from './features/app-shell/directCodexTurnAttachments'; -import { - isDeveloperMode, isTransientProjectOpenMessage, latestVisibleItems, - persistSupervisorChatDraft, - type ProjectSupervisorComponentProps, + type ProjectChatComponentProps, readInitialProjectPath, - readSupervisorChatDraft, type WorkspaceLauncherProps, writeRecentWorkspace, } from './features/app-shell/model'; -import { uploadLocalFilesAsAttachments } from './features/app-shell/useHomeProjectCreation'; import { WorkspaceLauncherShell } from './features/app-shell/WorkspaceLauncher'; import { - agentConversationReadDraftsFromManifest, - agentMemoryReadDraftsFromManifest, - commandDraftFromSuggestedToolCall, - deriveAgentStatusCards, - formatAgentDialogLlmStatus, - formatAgentLlmConfigWarning, - formatAgentRunControlError, - formatCodexRuntimeCapabilities, - formatLlmAgentStatusLine, - formatLlmRouteEndpoint, - isMissingAgentRunTraceError, projectAgentRuntimeSummaries, - readablePassArtifactsFromAgentRunTrace, - sameAgentStatusCard, - summarizeAgentAudit, - summarizeAgentCapabilities, - summarizeAgentConversationReadDrafts, - summarizeAgentLlmRoutes, - summarizeAgentMemoryReadDrafts, - summarizeAgentPassArtifactReadDrafts, summarizeAgentRunCompletionForChat, - summarizeAgentRunHistoryReadDrafts, - summarizeAgentStatusCardsForChat, - summarizeLimitedLocalCommands, - summarizeRunArtifactReadDrafts, } from './features/project-summary/agentPresentation'; import { - chatCommandHelp, - checkpointSummaryFromManifest, - commonAgentRunSupportReadDrafts, - commonProjectArtifactReadDrafts, - commonProjectInternalReadDrafts, - commonProjectLogReadDrafts, - firstReadableProjectAssetPath, - formatAgentRunStatus, - formatCanvasAssetSource, isAbsoluteProjectPath, - isSafeProjectRelativePath, - missingChatCommandArgumentMessage, - projectFileActionDrafts, projectPathHasControlCharacter, - projectPathsMatchForInvalidation, - readableArtifactsFromAgentRunTrace, - sortCheckpointManifestFiles, - summarizeAgentRunSupportFileReadDrafts, - summarizeCommonProjectArtifactReadDrafts, - summarizeCommonProjectLogReadDrafts, - summarizeMainProjectHeader, - summarizeProjectAssetCredits, - summarizeProjectAssets, - summarizeProjectAudioAssets, - summarizeProjectCheckpoint, - summarizeProjectCheckpoints, - summarizeProjectDiff, - summarizeProjectExportPackage, - summarizeProjectExportPackages, - summarizeProjectFileContent, - summarizeProjectFiles, - summarizeProjectIndex, - summarizeProjectInternalReadDrafts, - summarizeProjectPolicy, - summarizeProjectStatus, - summarizeProjectTasks, - summarizeProjectVisualAssets, } from './features/project-summary/projectSummary'; -import { AgentConversationOverlay } from './features/project-workspace/AgentConversationOverlay'; -import { - gameDraftStartedMessage, - isMissingProjectFileError, - parseAgentRunTrace, -} from './features/project-workspace/agentRunTrace'; -import { - chatQueueFullNotice, - createQueuedChatTurn, - dequeueChatTurn, - enqueueChatTurn, - isChatTurnQueueFull, - type QueuedChatTurn, - removeQueuedChatTurn, -} from './features/project-workspace/chatComposerQueue'; -import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; -import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; -import { - type DirectHistoryAnchorGate, - directHistoryAnchorGateToWaitFor, - reuseOrOpenDirectHistoryAnchorGate, -} from './features/project-workspace/directHistoryAnchorGate'; -import { readDirectHistoryPages } from './features/project-workspace/directHistoryPaging'; -import { - applyDirectThreadConsumeResult, - type DirectThreadChatState, - directThreadTurnMatchesUser, - emptyDirectThreadChatState, - finishDirectThreadTurn, - mergeDirectHistoryItems, - resolveDirectThreadBootstrap, - selectDirectChatEntries, -} from './features/project-workspace/directThreadChat'; -import { - type DirectThreadConsumeResult, - type DirectThreadHistorySlice, - type DirectThreadItem, - type DirectThreadSubscriptionBootstrap, -} from './features/project-workspace/directThreadEvents'; -import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; -import { - appendMemoryContent, - memoryScopeLabel, - memoryScopePath, - parseOptionalMemoryScope, - parseRememberInput, -} from './features/project-workspace/memoryCommands'; -import { pendingCommandDetail } from './features/project-workspace/pendingCommandPresentation'; +import { parseAgentRunTrace } from './features/project-workspace/agentRunTrace'; +import { importDesignFiles } from './features/project-workspace/importDesignFiles'; +import { parseRememberInput } from './features/project-workspace/memoryCommands'; import { isAgentTraceFilePath, - isProjectPolicyConfirmableCommandId, - isRegisteredGameCreationCommandId, - isSafeCanvasProjectId, - isSafeCheckpointId, needsInitializedChatProject, resolveChatProjectPath, - resolvePendingCommandProjectPath, } from './features/project-workspace/projectCommandPolicy'; -import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands'; -import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView'; -import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane'; import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput'; -import type { - ChatComposerDraft, - ChatReference, -} from './features/project-workspace/resourceReferences'; import { - chatComposerDraftToDirectCodexUserItem, + directCodexContentToLegacyContentDto, + hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, - RESOURCE_REFERENCE_INSERT_MANY_EVENT, type ResourceReferenceInsertEventDetail, - type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; -import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; -import { captureAgentRuntimeError } from './services/errorReporting'; -import { - currentPlatformSessionGeneration, - requestPlatformSessionRefresh, -} from './services/platformSession'; import { setAgcPluginProjectPath, startAvailableAgcEditorPlugins, @@ -307,87 +104,19 @@ import { type ProjectAgentResultSummary, type ProjectAgentRuntimeSummary, } from './view/project-development'; +import { DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX } from './view/project-development/chat/conversation/directCodexConversation'; +import { + directCodexAttachmentContentParts, + toDirectCodexTurnAttachments, +} from './view/project-development/chat/conversation/directCodexTurnAttachments'; +import { + type DirectProjectChatHandle, + DirectProjectChatView, + type DirectProjectInitialTurn, +} from './view/project-development/chat/DirectProjectChatView'; +import { PlanningChatView } from './view/project-development/planning/PlanningChatView'; import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel'; -const initialSupervisorMessageClaimsByPage = new WeakMap>(); -const DIRECT_CODEX_PRODUCT_RUNTIME = true; -const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; -const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = - 'direct-codex-turn-already-running:'; -/** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ -const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = - '当前项目已有另一条 Direct 客户端回合正在运行'; -// Platform access tokens are short lived. DirectProject can spend several -// minutes in image generation, build and browser validation, so keep the -// client-owned native session current while a turn is running. The singleflight -// refresh in platformSession.ts coalesces this with any 401-triggered refresh. -const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000; - -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(); - } -} - -function directCodexConversationMessageId( - turnId: string, - role: ChatMessage['role'], -) { - return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; -} - -export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; - -export function isDirectCodexTurnAlreadyRunningError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message - .trimStart() - .startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX); -} - -/** - * 另一条 Direct 回合占着这个项目时的拒绝。它与上面那条同 clientTurnId 的拒绝分属不同 - * 错误分类(Rust 侧刻意不带前缀),但对界面是同一件事:本项目现在有一条我们没接管的 - * 回合在跑。所以这里单独判定,让它也走"接管它 + 告诉用户出口"的处理。 - */ -export function isDirectCodexAnotherTurnRunningError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes(DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER); -} - -/** - * 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回 - * (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给 - * "已终止本次回合"而不是把中断当作异常写进运行错误与诊断。 - */ -export function isDirectCodexTurnInterruptedError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes('turn 已中断') || message.includes('已终止本次回合'); -} - function isPersistableDirectCodexConversationMessage(message: ChatMessage) { if (!message.runtimeOwned) { return false; @@ -407,20 +136,6 @@ function isPersistableDirectCodexConversationMessage(message: ChatMessage) { return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId); } -function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { - let claimedProjectPaths = initialSupervisorMessageClaimsByPage.get(window); - if (!claimedProjectPaths) { - claimedProjectPaths = new Set(); - initialSupervisorMessageClaimsByPage.set(window, claimedProjectPaths); - } - const claimKey = `${projectPath}\u0000${scope}`; - if (claimedProjectPaths.has(claimKey)) { - return false; - } - claimedProjectPaths.add(claimKey); - return true; -} - /** * 历史回读与"尚未落盘的运行时消息"合并。 * @@ -429,35 +144,8 @@ function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 */ -function mergeLoadedConversationWithPendingRuntimeMessages( - loaded: ChatMessage[], - current: ChatMessage[], -): ChatMessage[] { - if (current.length === 0) { - return loaded; - } - const loadedIds = new Set( - loaded - .map((message) => message.messageId) - .filter((id): id is string => Boolean(id)), - ); - const loadedTexts = new Set( - loaded.map((message) => `${message.role}\u0000${message.text}`), - ); - const pending = current.filter((message) => { - if (!message.runtimeOwned) { - return false; - } - if (message.messageId) { - return !loadedIds.has(message.messageId); - } - return !loadedTexts.has(`${message.role}\u0000${message.text}`); - }); - return pending.length > 0 ? [...loaded, ...pending] : loaded; -} export { AuthenticatedClient } from './app/AuthenticatedClient'; -export type { PendingCommand } from './app/types'; export { deriveAgentStatusCards, summarizeAgentAudit, @@ -467,30 +155,26 @@ export { isAbsoluteProjectPath } from './features/project-summary/projectSummary export { needsInitializedChatProject, parseRememberInput, - pendingCommandDetail, resolveChatProjectPath, - resolvePendingCommandProjectPath, }; export function WorkspaceLauncher(props: WorkspaceLauncherProps) { - return ; + return ; } type AppProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; initialProjectKind?: LocalProjectKind; - orchestrationMode?: 'single-supervisor' | 'professional-dag'; - projectSupervisorOnly?: boolean; planningStartMode?: boolean; - activeVersionId?: ProjectSupervisorComponentProps['activeVersionId']; - supervisorChatOnly?: boolean; - initialSupervisorMessage?: string; - initialSupervisorMessageClaimScope?: string; + activeVersionId?: ProjectChatComponentProps['activeVersionId']; + initialPlanningPrompt?: string; + initialPlanningPromptClaimScope?: string; initialCreationType?: HomeCreationType | null; initialAttachments?: LauncherImportedAttachment[]; - playRequest?: ProjectSupervisorComponentProps['playRequest']; - onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled']; + onDesignFilesImportingChange?: ProjectChatComponentProps['onDesignFilesImportingChange']; + playRequest?: ProjectChatComponentProps['playRequest']; + onPlayRequestHandled?: ProjectChatComponentProps['onPlayRequestHandled']; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -503,42 +187,28 @@ type AppProps = { onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void; }; +/** + * 工作台壳里由立项策划链路接管的提交入参。 + * + * DirectProject 的回合入参(`@` 引用、附件、权限确认重跑)归聊天容器自己的 + * `DirectProjectTurnInput`,不再从这里穿过。 + */ type ExecuteChatAgentReplyInput = { prompt: string; clientTurnId?: string; - creationType?: HomeCreationType | null; - attachments?: DirectCodexTurnAttachment[]; - directPolicyChecked?: boolean; - references?: ChatReference[]; - userItem?: ReturnType; }; -/** - * `conversation.write` 策略确认后重跑同一轮直连回合的入参。 - * - * 确认弹窗会在**同一轮输入**上二次进入 `executeChatAgentReply`。这里从首轮的入参整体派生, - * 而不是手写一遍字段:一旦重跑时漏掉某一项(历史缺陷就是漏了 `references`), - * 用户在确认之后拿到的就不是他原本提交的那一轮——`@` 引用会被静默丢掉。 - */ -export function directCodexPolicyRetryInput( - input: ExecuteChatAgentReplyInput, -): ExecuteChatAgentReplyInput { - return { ...input, directPolicyChecked: true }; -} - export function App({ initialProjectPath: initialProjectPathOverride = '', initialProjectManifest, initialProjectKind = 'web', - orchestrationMode = 'professional-dag', - projectSupervisorOnly = false, planningStartMode = false, activeVersionId = null, - supervisorChatOnly = false, - initialSupervisorMessage = '', - initialSupervisorMessageClaimScope = '', + initialPlanningPrompt = '', + initialPlanningPromptClaimScope = '', initialCreationType = null, initialAttachments = [], + onDesignFilesImportingChange, playRequest = null, onPlayRequestHandled, onManifestChange, @@ -546,13 +216,13 @@ export function App({ onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { - const { setTitle: setWindowTitle } = useWindowChrome(); const [designAgentActive, setDesignAgentActive] = useState(planningStartMode); const designAgentActiveRef = useRef(planningStartMode); designAgentActiveRef.current = designAgentActive; const [designAgentView, setDesignAgentView] = useState( null, ); + const designAgentLaneRef = useRef(planningStartMode); const designAgentTurnRef = useRef<{ projectPath: string; clientTurnId: string; @@ -563,26 +233,28 @@ export function App({ const designAgentEventSubscriptionResolveRef = useRef<(() => void) | null>( null, ); - const directCodexProductRuntime = - DIRECT_CODEX_PRODUCT_RUNTIME && - projectSupervisorOnly && - !supervisorChatOnly && - !planningStartMode && - !designAgentActive; - const [devMode] = useState(() => - projectSupervisorOnly ? false : isDeveloperMode(), - ); + /** + * 本轮策划回合的思考归属。 + * + * 与 `designAgentTurnRef` 分开:那个记录回合本身,回合结束就清空;这个只用来判定 + * 「这条 reasoning 事件是不是本轮的」,回合结束后仍保留,最后一条思考不会在视图 + * 落盘前的空隙里被丢掉。切项目时随其它聊天状态一起清空。 + */ + const designAgentReasoningTurnRef = useRef<{ + projectPath: string; + clientTurnId: string; + } | null>(null); + // 项目开发工作台的普通项目固定使用 DirectProject,立项策划项目走策划聊天;两条 + // 链路各自独立,不通过兼容分支互相承载。 + const directProjectMode = !designAgentActive; const [initialProjectPath] = useState( () => initialProjectPathOverride || readInitialProjectPath(), ); - const eagerSupervisorProject = - projectSupervisorOnly && Boolean(initialProjectPath); + const eagerProject = Boolean(initialProjectPath); const [projectPath, setProjectPath] = useState(initialProjectPath); - const [workspaceProjectKind, setWorkspaceProjectKind] = - useState(initialProjectKind); const [localProject, setLocalProject] = useState(() => - eagerSupervisorProject + eagerProject ? { projectPath: initialProjectPath, manifestPath: `${initialProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`, @@ -593,12 +265,13 @@ export function App({ const localProjectPathRef = useRef(null); useEffect(() => { - if (supervisorChatOnly) return; const nextProjectPath = localProject?.projectPath ?? null; const previousProjectPath = localProjectPathRef.current; localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 if (!nextProjectPath && !previousProjectPath) return; + // 工程类型只决定「有哪几个编辑器插件可用」,不影响要不要尝试拉起:可用性由插件宿主 + // 自己判(装配好的编辑器插件各自启动),未就绪时给出统一文案。 let active = true; void setAgcPluginProjectPath(nextProjectPath) .then(async () => { @@ -623,7 +296,7 @@ export function App({ } localProjectPathRef.current = null; }; - }, [localProject?.projectPath, supervisorChatOnly]); + }, [localProject?.projectPath]); const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( @@ -662,15 +335,10 @@ export function App({ if (JSON.stringify(snapshot) === JSON.stringify(current)) return; setManifest(snapshot); }, [initialProjectManifest]); - const [projectStatus, setProjectStatus] = useState( - eagerSupervisorProject ? '已初始化' : '未初始化', - ); - const [preview, setPreview] = useState(null); - const [previewStatus, setPreviewStatus] = useState('未启动'); - const initialSupervisorMessageLatchRef = useRef({ + const initialPlanningPromptLatchRef = useRef({ projectPath: initialProjectPath, - prompt: initialSupervisorMessage.trim(), - claimScope: initialSupervisorMessageClaimScope, + prompt: initialPlanningPrompt.trim(), + claimScope: initialPlanningPromptClaimScope, creationType: initialCreationType, attachments: toDirectCodexTurnAttachments(initialAttachments), }); @@ -680,7 +348,6 @@ export function App({ nextPreview: LocalPreviewResult | null, status: GameCreationAppPreviewStatus = nextPreview ? 'running' : 'stopped', ) { - setPreview(nextPreview); onPreviewChange?.( nextPreview ? { @@ -692,96 +359,9 @@ export function App({ ); } - const [chatInput, setChatInput] = useState(() => - supervisorChatOnly && initialProjectPath - ? readSupervisorChatDraft(initialProjectPath) - : '', - ); - const [chatReferences, setChatReferences] = useState([]); - /** - * 输入盒待发送附件(direct-codex 回合附件):上传成功后先生成 chip,随下次提交一起 - * 交给 `chat_with_game_creator_direct_codex` 的 `attachments`。附件只存在于前端状态, - * 提交后即清空——后端协议不变。 - */ - const [chatAttachments, setChatAttachments] = useState< - DirectCodexTurnAttachment[] - >([]); - const [chatAttachmentNotice, setChatAttachmentNotice] = useState(''); - /** 回合运行中再次发送的消息:FIFO 本地队列,当前回合结束后依次发出。 */ - const [chatTurnQueue, setChatTurnQueue] = useState([]); - const chatTurnQueueRef = useRef([]); - chatTurnQueueRef.current = chatTurnQueue; - const [chatComposerNotice, setChatComposerNotice] = useState(''); - const [directCodexTurnCancelling, setDirectCodexTurnCancelling] = - useState(false); - const queuedChatTurnSequenceRef = useRef(0); - const [chatContent, setChatContent] = useState( - [], - ); const chatComposerRef = useRef(null); - /** - * 切项目即清空只属于上一个项目的输入盒状态:待发附件的 `localPath` 是**项目相对**的, - * 队列也属于刚结束的那条对话;留着会把 A 项目的附件路径带进 B 项目的下一个回合。 - */ - useEffect(() => { - setChatAttachments([]); - setChatContent([]); - setChatAttachmentNotice(''); - setChatComposerNotice(''); - setChatTurnQueue([]); - chatTurnQueueRef.current = []; - }, [localProject?.projectPath]); const [chatAgentBusy, setChatAgentBusy] = useState(false); - const directCodexConversationTurnSequenceRef = useRef(0); - /** - * DirectProject 聊天真相源:历史切片与运行态事件归并成同一份条目。 - * - * 运行态只来自 Thread Manager(`subscribe` 的 bootstrap + `notify` 唤醒的 `consume`); - * 前端不再订阅 DirectRuntime 的回合进度事件,也不再各存一份回合流 / 工具卡片。 - */ - const [directThreadChat, setDirectThreadChat] = - useState(() => emptyDirectThreadChatState()); - /** 最新回合是否在跑:历史里留下的半截回合一律按已结束渲染。 */ - const directTurnRunning = directThreadChat.turnRunning; - /** 界面忙碌判定:本地正在跑这次 invoke,或订阅告诉还有一条回合没结束。 */ - const supervisorChatBusy = - chatAgentBusy || (directCodexProductRuntime && directTurnRunning); - const chatAgentBusyRef = useRef(chatAgentBusy); - const directTurnRunningRef = useRef(directTurnRunning); - chatAgentBusyRef.current = chatAgentBusy; - directTurnRunningRef.current = directTurnRunning; - const previousDirectTurnRunningRef = useRef(directTurnRunning); - const directTurnCompletionPendingRef = useRef(false); - const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< - string | null - >(null); - - function createDirectCodexConversationTurnId() { - directCodexConversationTurnSequenceRef.current += 1; - let randomId = ''; - try { - randomId = globalThis.crypto?.randomUUID?.().trim() ?? ''; - } catch { - // The timestamp and in-page sequence remain unique enough for a local - // conversation append when WebView crypto is unavailable. - } - if (/^[a-z0-9][a-z0-9-]{5,159}$/iu.test(randomId)) { - return randomId; - } - return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`; - } - - /** 换项目 / 清空对话:聊天条目回到初始态(运行态与已加载历史一起清掉)。 */ - function resetDirectThreadChat() { - setDirectThreadChat(emptyDirectThreadChatState()); - } - - const [projectSupervisorRuntime, setProjectSupervisorRuntime] = - useState(null); - const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] = - useState(null); - const [projectSupervisorRuntimeError, setProjectSupervisorRuntimeError] = - useState(''); + const [projectChatError, setProjectChatError] = useState(''); const [designAgentTransientReply, setDesignAgentTransientReplyVisible] = useState(''); const designAgentTransientReplyTargetRef = useRef(''); @@ -792,11 +372,6 @@ export function App({ view: DesignView; } | null>(null); const [designAgentReasoning, setDesignAgentReasoning] = useState(''); - const designAgentReasoningTurnRef = useRef<{ - projectPath: string; - clientTurnId: string; - text: string; - } | null>(null); function setDesignAgentTransientReplyTarget(next: string) { designAgentTransientReplyTargetRef.current = next; @@ -851,6 +426,83 @@ export function App({ return () => window.clearInterval(timer); }, []); + /** + * 策划 Agent 的实时事件流:本轮流式正文、思考过程和回合中途的视图都靠它推给界面。 + * + * 订阅建立是异步的,而回合由一个 invoke 发起;`designAgentEventSubscriptionReady()` + * 让回合等监听器挂好再开始,避免开头几个事件丢掉。事件只认当前项目;有在跑的回合时 + * 还要认本轮 `clientTurnId`,迟到的上一轮事件不会画到这一轮上。 + */ + useEffect(() => { + const ready = createDesignAgentEventSubscriptionReady(); + if (!canSubscribeTauriEvents() || !designAgentActive) { + resolveDesignAgentEventSubscriptionReady(); + return () => { + if (designAgentEventSubscriptionReadyRef.current === ready) { + designAgentEventSubscriptionReadyRef.current = null; + createDesignAgentEventSubscriptionReady(); + } + }; + } + let cleanup: (() => void) | null = null; + let disposed = false; + void subscribeTauriEvent('design-agent-update', (event) => { + const payload = event.payload; + const tracked = designAgentTurnRef.current; + if ( + payload.projectPath !== localProjectPathRef.current || + (tracked && payload.clientTurnId !== tracked.clientTurnId) + ) { + return; + } + if ( + (payload.kind === 'text' || payload.kind === 'tool') && + payload.text + ) { + setDesignAgentTransientReplyTarget(payload.text); + } + if (payload.reasoningText != null) { + const reasoningTurn = designAgentReasoningTurnRef.current; + if ( + reasoningTurn?.projectPath === payload.projectPath && + reasoningTurn.clientTurnId === payload.clientTurnId + ) { + setDesignAgentReasoning(payload.reasoningText); + } + } + if (payload.view) { + applyDesignAgentViewAfterTransient( + payload.view, + payload.projectPath, + payload.clientTurnId, + ); + } + }) + .then((unlisten) => { + resolveDesignAgentEventSubscriptionReady(); + if (disposed) { + unlisten(); + return; + } + cleanup = unlisten; + }) + .catch(() => { + resolveDesignAgentEventSubscriptionReady(); + }); + return () => { + disposed = true; + cleanup?.(); + resolveDesignAgentEventSubscriptionReady(); + if (designAgentEventSubscriptionReadyRef.current === ready) { + designAgentEventSubscriptionReadyRef.current = null; + createDesignAgentEventSubscriptionReady(); + } + }; + // 订阅只跟着「是否走策划 Agent」这条链路;回调里的项目/回合判据都走 refs, + // 把它们写进依赖会在每轮回复时重订事件。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [designAgentActive]); + function designMessagesToChat(view: DesignView): ChatMessage[] { const reasoningByMessageId = new Map(); for (const entry of view.reasoningEntries ?? []) { @@ -871,7 +523,7 @@ export function App({ reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), updatedAt: Date.now(), })); - const initialPrompt = initialSupervisorMessageLatchRef.current.prompt; + const initialPrompt = initialPlanningPromptLatchRef.current.prompt; if ( initialPrompt && !messages.some( @@ -889,10 +541,11 @@ export function App({ } function applyDesignView(view: DesignView, projectPath: string) { + designAgentLaneRef.current = true; + setDesignAgentView(view); designAgentActiveRef.current = true; setDesignAgentActive(true); - setDesignAgentView(view); - setProjectSupervisorRuntimeError(view.session.lastError ?? ''); + setProjectChatError(view.session.lastError ?? ''); setChatAgentBusy(view.running); const conversation = designMessagesToChat(view); setMessages(conversation); @@ -988,7 +641,7 @@ export function App({ ) { const invoke = resolveTauriInvoke(); if (!invoke) { - setProjectSupervisorRuntimeError('需要在 Tauri App 内运行。'); + setProjectChatError('需要在 Tauri App 内运行。'); return; } designAgentTurnRef.current = { @@ -998,12 +651,11 @@ export function App({ designAgentReasoningTurnRef.current = { projectPath: nextProjectPath, clientTurnId, - text: '', }; designAgentPendingViewRef.current = null; await designAgentEventSubscriptionReady(); setChatAgentBusy(true); - setProjectSupervisorRuntimeError(''); + setProjectChatError(''); setDesignAgentTransientReplyTarget(''); setDesignAgentReasoning(''); try { @@ -1024,7 +676,7 @@ export function App({ if (isRuntimeConfigMissingError(message)) { requestRuntimeConfigOpen(); } - setProjectSupervisorRuntimeError(message); + setProjectChatError(message); } finally { if (!designAgentPendingViewRef.current) { designAgentTurnRef.current = null; @@ -1034,59 +686,11 @@ export function App({ } } - const [projectSupervisorExpectedRunId, setProjectSupervisorExpectedRunId] = - useState(null); - const chatInputRef = useRef(null); - const supervisorChatMessagesRef = useRef(null); - const supervisorChatShouldFollowLatestRef = useRef(true); - const [assetStatus, setAssetStatus] = useState('未上传'); - const [uploadedAssets, setUploadedAssets] = useState< - UploadLocalAssetResult[] - >([]); - const [assetLocalPath, setAssetLocalPath] = useState('assets/hero.png'); - const [assetKind, setAssetKind] = useState('unknown'); - const [assetMediaType, setAssetMediaType] = useState( - 'application/octet-stream', - ); - const [assetSourceKind, setAssetSourceKind] = useState('generated'); - const [assetCanvasProjectId, setAssetCanvasProjectId] = useState(''); - const [assetResourceId, setAssetResourceId] = useState(''); - const [assetObjectId, setAssetObjectId] = useState(''); - const [assetGenerationPrompt, setAssetGenerationPrompt] = - useState('首版核心美术素材'); - const [canvasExportPath, setCanvasExportPath] = useState( - '/tmp/canvas-export.zip', - ); - const [editorBaseUrl, setEditorBaseUrl] = useState('http://127.0.0.1:3000'); - const [memoryScope, setMemoryScope] = useState('long'); - const [memoryDraft, setMemoryDraft] = useState(''); - const [memoryStatus, setMemoryStatus] = useState('未读取'); - const [limitedCommandStatus, setLimitedCommandStatus] = useState('未运行'); - const [limitedLocalCommands, setLimitedLocalCommands] = useState< - GameCreationAppLimitedRunCommandDescriptor[] - >([...GAME_CREATION_APP_LIMITED_RUN_COMMANDS]); - const [projectFiles, setProjectFiles] = useState([]); - const [projectCheckpoints, setProjectCheckpoints] = useState< - LocalProjectCheckpointSummary[] - >([]); - const [filePath, setFilePath] = useState('game/index.html'); - const [fileDraft, setFileDraft] = useState(''); - const [fileStatus, setFileStatus] = useState('未读取'); - const [agentRunTrace, setAgentRunTrace] = - useState(null); - const [agentRunHistory, setAgentRunHistory] = useState( - [], - ); - const [agentRunHistoryFiles, setAgentRunHistoryFiles] = useState< - LocalProjectFileEntry[] - >([]); - const [agentRunHistoryVisibleCount, setAgentRunHistoryVisibleCount] = - useState(AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT); - const [agentRunHistoryOverflowCount, setAgentRunHistoryOverflowCount] = - useState(0); - const [agentRunHistoryLoadingMore, setAgentRunHistoryLoadingMore] = - useState(false); - const [agentRunStatus, setAgentRunStatus] = useState('未运行'); + const [chatFilesImporting, setChatFilesImporting] = useState(false); + const [chatFileImportNotice, setChatFileImportNotice] = useState(''); + const planningChatMessagesRef = useRef(null); + const planningChatShouldFollowLatestRef = useRef(true); + const directProjectChatRef = useRef(null); const [agentRuntimeById, setAgentRuntimeById] = useState< Record >({}); @@ -1094,54 +698,15 @@ export function App({ agentRuntimeByIdRef.current = agentRuntimeById; const [professionalAgentResultsById, setProfessionalAgentResultsById] = useState>({}); - const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); - const [llmConfigStatus, setLlmConfigStatus] = - useState(null); const [workspaceStatus, setWorkspaceStatus] = useState( - eagerSupervisorProject ? `已打开:${initialProjectPath}` : '请选择工作区', + eagerProject ? `已打开:${initialProjectPath}` : '请选择工作区', ); - const [selectedAgent, setSelectedAgent] = useState( - null, - ); - const selectedAgentIdRef = useRef(null); - selectedAgentIdRef.current = selectedAgent?.id ?? null; - const [agentConversationInput, setAgentConversationInput] = useState(''); - const [agentConversationStatus, setAgentConversationStatus] = - useState('未选择 agent'); - const [agentConversationMessages, setAgentConversationMessages] = useState< - LocalConversationMessageRecord[] - >([]); - const [agentConversationRuntime, setAgentConversationRuntime] = - useState(null); - const [agentConversationSessionId, setAgentConversationSessionId] = useState< - string | null - >(null); - const [agentConversationRunSubmitMode, setAgentConversationRunSubmitMode] = - useState('steer'); - const [agentConversationRuntimeError, setAgentConversationRuntimeError] = - useState(''); - const [agentConversationVisibleCount, setAgentConversationVisibleCount] = - useState(CONVERSATION_INITIAL_VISIBLE_COUNT); - const [agentConversationSaving, setAgentConversationSaving] = useState(false); - const [agentConversationBackgroundBusy, setAgentConversationBackgroundBusy] = - useState(false); - const [agentMemoryStatus, setAgentMemoryStatus] = useState('未读取'); - const [agentMemoryContent, setAgentMemoryContent] = useState(''); const [messages, setMessages] = useState( createDefaultChatMessages, ); const [conversationVisibleCount, setConversationVisibleCount] = useState( CONVERSATION_INITIAL_VISIBLE_COUNT, ); - const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); - const directHistoryOldestItemIdRef = useRef(null); - const directHistoryLoadingRef = useRef(false); - const directHistoryAnchorGateRef = useRef( - null, - ); - const [pendingCommand, setPendingCommand] = useState( - null, - ); const [pendingUiConfirmation, setPendingUiConfirmation] = useState(null); const [pendingNonEmptyProjectCreate, setPendingNonEmptyProjectCreate] = @@ -1149,17 +714,12 @@ export function App({ projectPath: string; announceToChat: boolean; } | null>(null); - const [commandLog, setCommandLog] = useState([ - `${GAME_CREATION_APP_COMMANDS.length} 个命令已登记权限。`, - ]); - const [projectLogStatus, setProjectLogStatus] = useState('未读取'); - const [projectLogContent, setProjectLogContent] = useState(''); const [conversationWriteVersion, setConversationWriteVersion] = useState(0); const savedConversationCountRef = useRef( - eagerSupervisorProject ? createDefaultChatMessages().length : 0, + eagerProject ? createDefaultChatMessages().length : 0, ); const savedConversationProjectPathRef = useRef( - eagerSupervisorProject ? initialProjectPath : null, + eagerProject ? initialProjectPath : null, ); const projectConversationWriteConfirmedRef = useRef(null); const projectConversationWriteCancelledRef = useRef<{ @@ -1169,19 +729,6 @@ export function App({ const latestMessagesRef = useRef([]); const conversationWriteInFlightRef = useRef(false); const projectScopeVersionRef = useRef(0); - const projectSupervisorHistoryLoadVersionRef = useRef(0); - const projectSupervisorRuntimeResumeProjectPathRef = useRef( - null, - ); - const projectSupervisorSessionIdRef = useRef(null); - projectSupervisorSessionIdRef.current = projectSupervisorSessionId; - const projectSupervisorRuntimeRef = useRef(null); - projectSupervisorRuntimeRef.current = projectSupervisorRuntime; - const projectSupervisorExpectedRunIdRef = useRef(null); - projectSupervisorExpectedRunIdRef.current = projectSupervisorExpectedRunId; - const projectSupervisorResponseStreamRef = - useRef(null); - projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; const refreshManifest = useCallback( (nextProjectPath = localProjectPathRef.current ?? ''): Promise => { @@ -1251,143 +798,21 @@ export function App({ refreshStates.clear(); }; }, []); - const projectSupervisorRuntimeSyncingRef = useRef(new Set()); - const projectSupervisorRefreshConversationRef = useRef< - | (( - invoke: TauriInvoke, - projectPath: string, - sessionId: string, - ) => Promise) - | null - >(null); const executeChatAgentReplyRef = useRef< (input: ExecuteChatAgentReplyInput) => Promise >(async () => undefined); - const agentConversationSavingRef = useRef(false); - const agentConversationBackgroundBusyRef = useRef(false); - const agentConversationLoadVersionRef = useRef(0); - const agentConversationSessionIdRef = useRef(null); - agentConversationSessionIdRef.current = agentConversationSessionId; const agentRuntimeResumeProjectPathRef = useRef(null); - const agentRunHistoryLoadingMoreRef = useRef(false); const initialProjectOpenedRef = useRef(false); const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); - const queueRunLocalShortcutRef = useRef<() => void>(() => undefined); const executeRunLocalRef = useRef<(announceToChat: boolean) => void>( () => undefined, ); + const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); function requestRuntimeConfigOpen() { - if (projectSupervisorOnly && !supervisorChatOnly) { - return; - } setRuntimeConfigOpen(true); } - const updateProjectSupervisorRuntime = useCallback( - ( - runtime: AgentRuntimeState | null, - previous = projectSupervisorRuntimeRef.current, - ) => { - const nextRuntime = runtime - ? normalizeAgentRuntimeState(runtime, previous) - : null; - projectSupervisorRuntimeRef.current = nextRuntime; - setProjectSupervisorRuntime(nextRuntime); - }, - [], - ); - - const updateProjectSupervisorResponseStream = useCallback( - ( - incoming: AgentRuntimeResponseStream | null | undefined, - runtime: AgentRuntimeState, - ) => { - let nextStream = mergeProjectSupervisorResponseStream( - projectSupervisorResponseStreamRef.current, - incoming, - runtime, - ); - const candidateStream = nextStream; - if ( - candidateStream && - latestMessagesRef.current.some( - (message) => - message.runtimeOwned && - message.role === 'assistant' && - message.text === candidateStream.accumulatedText && - typeof message.updatedAt === 'number' && - message.updatedAt >= candidateStream.startedAt, - ) - ) { - // Keep the existing supervisor-chat behavior for restored history. - nextStream = null; - } - projectSupervisorResponseStreamRef.current = nextStream; - setProjectSupervisorResponseStream(nextStream); - }, - [], - ); - - function resetProjectSupervisorState() { - projectSupervisorHistoryLoadVersionRef.current += 1; - projectSupervisorRuntimeResumeProjectPathRef.current = null; - projectSupervisorSessionIdRef.current = null; - projectSupervisorRuntimeRef.current = null; - projectSupervisorExpectedRunIdRef.current = null; - projectSupervisorResponseStreamRef.current = null; - projectSupervisorRuntimeSyncingRef.current.clear(); - setProjectSupervisorSessionId(null); - setProjectSupervisorRuntime(null); - setProjectSupervisorExpectedRunId(null); - setProjectSupervisorResponseStream(null); - setProjectSupervisorRuntimeError(''); - designAgentPendingViewRef.current = null; - designAgentReasoningTurnRef.current = null; - setDesignAgentReasoning(''); - setDesignAgentActive(planningStartMode); - designAgentActiveRef.current = planningStartMode; - designAgentTurnRef.current = null; - setDesignAgentView(null); - } - - function syncTerminalProjectSupervisorConversation( - invoke: TauriInvoke, - projectPath: string, - runtime: AgentRuntimeState, - ) { - if (!isAgentRuntimeTerminalState(runtime) || !runtime.sessionId) { - return; - } - const syncKey = `${projectPath}\n${runtime.sessionId}\n${runtime.runId}`; - const syncAlreadyStarted = - projectSupervisorRuntimeSyncingRef.current.has(syncKey); - if (syncAlreadyStarted) { - return; - } - const refreshConversation = projectSupervisorRefreshConversationRef.current; - if (!refreshConversation) { - return; - } - projectSupervisorRuntimeSyncingRef.current.add(syncKey); - void refreshConversation(invoke, projectPath, runtime.sessionId) - .then(() => {}) - .catch((error) => { - projectSupervisorRuntimeSyncingRef.current.delete(syncKey); - if ( - localProjectPathRef.current === projectPath && - projectSupervisorSessionIdRef.current === runtime.sessionId - ) { - setProjectSupervisorRuntimeError( - `项目总控 Agent 对话刷新失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }); - } - - useEscapeToClose(closeAgentConversation, selectedAgent !== null); useEscapeToClose(cancelUiCommandConfirmation, pendingUiConfirmation !== null); useEscapeToClose( cancelProjectCreateInNonEmptyFolder, @@ -1411,702 +836,43 @@ export function App({ return; } initialProjectOpenedRef.current = true; - if (projectSupervisorOnly) { - if (directCodexProductRuntime) { - if (!isAbsoluteProjectPath(initialProjectPath)) { - setWorkspaceStatus('请提供工作区绝对路径'); - return; - } - if (projectPathHasControlCharacter(initialProjectPath)) { - setWorkspaceStatus('工作区路径不能包含控制字符'); - return; - } - // This component is mounted inside the already-created project - // workbench. Hydrate localProject before the first direct turn so the - // direct runtime cannot silently create a second workspace. - void openWorkspace( - initialProjectPath, - false, - 'open', - initialProjectKind, - ); - return; - } - if (!isAbsoluteProjectPath(initialProjectPath)) { - setWorkspaceStatus('请提供工作区绝对路径'); - return; - } - if (projectPathHasControlCharacter(initialProjectPath)) { - setWorkspaceStatus('工作区路径不能包含控制字符'); - return; - } - localProjectPathRef.current = initialProjectPath; - void loadProjectConversation(initialProjectPath).finally(() => { - if ( - localProjectPathRef.current === initialProjectPath && - !planningStartMode - ) { - void refreshAgentRunTrace(initialProjectPath); - } - }); + if (!isAbsoluteProjectPath(initialProjectPath)) { + setWorkspaceStatus('请提供工作区绝对路径'); return; } - void openWorkspace( - initialProjectPath, - false, - initialProjectKind !== 'web' ? 'open' : 'create', - initialProjectKind, - ); + if (projectPathHasControlCharacter(initialProjectPath)) { + setWorkspaceStatus('工作区路径不能包含控制字符'); + return; + } + if (directProjectMode) { + // This component is mounted inside the already-created project workbench. + // Hydrate localProject before the first direct turn so the direct runtime + // cannot silently create a second workspace. + void openWorkspace(initialProjectPath, false, 'open', initialProjectKind); + return; + } + localProjectPathRef.current = initialProjectPath; + void loadProjectConversation(initialProjectPath).finally(() => { + if ( + localProjectPathRef.current === initialProjectPath && + !planningStartMode + ) { + void refreshAgentRunTrace(initialProjectPath); + } + }); // Initial project opening is guarded by initialProjectOpenedRef. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialProjectPath, projectSupervisorOnly]); - - useEffect(() => { - if (!directCodexProductRuntime || !supervisorChatBusy) { - return; - } - const timer = window.setInterval(() => { - void requestPlatformSessionRefresh().catch(() => { - // The active DirectProject turn will surface the original auth error; - // keepalive must not replace it with an unrelated background error. - }); - }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); - return () => window.clearInterval(timer); - }, [supervisorChatBusy, directCodexProductRuntime]); - - /** - * DirectProject 聊天真相源。 - * - * `subscribe` 的 bootstrap 就是此刻要处理的事件(游标已在队尾),此后只由 - * `game-creator-direct-thread-notify` 唤醒 `consume`;前端不再订阅 DirectRuntime 的回合 - * 进度事件,也不轮询"有没有回合在跑"。 - */ - useEffect(() => { - if (!directCodexProductRuntime) { - return; - } - const projectPath = localProject?.projectPath ?? null; - const directInvoke = resolveTauriInvoke(); - // 闸门先建(或复用首屏读取已经开好的那道):同一轮渲染里首屏会等订阅回执里的锚点。 - const anchorGate = reuseOrOpenDirectHistoryAnchorGate( - directHistoryAnchorGateRef.current, - projectPath ?? '', - ); - directHistoryAnchorGateRef.current = anchorGate; - if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) { - // 订阅不可用:首屏退化成"取文件尾",不阻塞加载。 - anchorGate.settle(null); - return; - } - let disposed = false; - let cleanup: (() => void) | null = null; - let subscriptionId: string | null = null; - let consuming = false; - let consumeAgain = false; - // 通知先于 subscribe 回执到达时,前端还不知道自己的 subscriptionId,没法立刻 consume。 - // 记一笔欠账,拿到回执后立刻补一次,事件就不会卡在队伍里等下一次通知。 - let notifyBeforeSubscription = false; - - const bootstrap = async () => { - const result = await directInvoke( - 'subscribe_direct_project_thread', - { projectPath }, - ); - if (disposed) { - anchorGate.settle(null); - return; - } - subscriptionId = result.subscriptionId; - // 首屏边界:回执里这一刻的最后一条已完成条目(含该条)。 - anchorGate.settle(result.lastCompletedItemId ?? null); - setDirectThreadChat((state) => - resolveDirectThreadBootstrap(state, result), - ); - if (notifyBeforeSubscription) { - notifyBeforeSubscription = false; - void consume(); - } - }; - const consume = async () => { - if (!subscriptionId || disposed) return; - if (consuming) { - consumeAgain = true; - return; - } - consuming = true; - try { - do { - consumeAgain = false; - const result = await directInvoke( - 'consume_direct_project_thread', - { subscriptionId }, - ); - if (disposed) return; - setDirectThreadChat((state) => - applyDirectThreadConsumeResult(state, result), - ); - } while (consumeAgain && !disposed); - } catch (error) { - // 订阅被别人顶掉时重订一次;通知允许丢,下一次通知会再唤醒。 - if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) { - subscriptionId = null; - try { - await bootstrap(); - } catch { - /* 让下一次通知再试。 */ - } - } - } finally { - consuming = false; - } - }; - const setup = async () => { - try { - const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>( - 'game-creator-direct-thread-notify', - (event) => { - if (event.payload.subscriptionId === subscriptionId) { - void consume(); - return; - } - if (!subscriptionId) notifyBeforeSubscription = true; - }, - ); - if (disposed) { - unlisten(); - return; - } - cleanup = unlisten; - await bootstrap(); - } catch { - // 历史仍可使用;订阅失败不伪造忙碌态,锚点缺失时首屏按文件尾取尾屏。 - anchorGate.settle(null); - } - }; - // 换项目就是换一份聊天:先回到初始态,再订阅新线程。 - setDirectThreadChat(emptyDirectThreadChatState()); - void setup(); - return () => { - disposed = true; - cleanup?.(); - // 首屏可能还在等这道闸门(例如切项目打断了订阅),不能让它永远等下去。 - anchorGate.settle(null); - }; - }, [directCodexProductRuntime, localProject?.projectPath]); - - useEffect(() => { - if (projectSupervisorOnly && !directCodexProductRuntime) { - return; - } - if (!canSubscribeTauriEvents()) { - return; - } - let cleanup: (() => void) | null = null; - let disposed = false; - void subscribeTauriEvent( - 'game-creator-agent-progress', - (event) => { - if (event.payload.projectPath !== localProjectPathRef.current) { - return; - } - if (directCodexProductRuntime) { - // DirectProject 的进度已经从线程事件流进聊天条目,这条通用进度事件不再参与。 - return; - } - setMessages((current) => [ - ...current, - { role: 'assistant', text: event.payload.message }, - ]); - }, - ) - .then((unlisten) => { - if (disposed) { - unlisten(); - return; - } - cleanup = unlisten; - }) - .catch((error) => { - if (disposed) { - return; - } - setAgentRunStatus( - `实时状态不可用:${ - error instanceof Error ? error.message : String(error) - }`, - ); - }); - return () => { - disposed = true; - cleanup?.(); - }; - }, [directCodexProductRuntime, projectSupervisorOnly]); - - useEffect(() => { - const invoke = resolveTauriInvoke(); - if ( - !canSubscribeTauriEvents() || - directCodexProductRuntime || - designAgentActive - ) { - return; - } - let cleanup: (() => void) | null = null; - let disposed = false; - void subscribeTauriEvent( - 'game-creator-agent-runtime-update', - (event) => { - const payload = event.payload; - if (payload.projectPath !== localProjectPathRef.current) { - return; - } - if (payload.manifestInvalidated) { - void refreshManifest(payload.projectPath); - } - const nextRuntime = agentRuntimeStateFromResult(payload.runtime); - if (payload.agentId === PROJECT_SUPERVISOR_AGENT_ID) { - const expectedSessionId = projectSupervisorSessionIdRef.current; - const currentRuntime = projectSupervisorRuntimeRef.current; - const expectedRunId = projectSupervisorExpectedRunIdRef.current; - const sameRun = - currentRuntime !== null && - sameAgentRuntimeRun(nextRuntime, currentRuntime); - const isExpectedRun = - expectedRunId !== null && nextRuntime.runId === expectedRunId; - if ( - nextRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || - nextRuntime.runId !== payload.runId || - (expectedSessionId !== null && - nextRuntime.sessionId !== expectedSessionId) || - (currentRuntime !== null && - ((!sameRun && !isExpectedRun) || - (sameRun && nextRuntime.updatedAt < currentRuntime.updatedAt))) - ) { - return; - } - if (expectedSessionId === null && nextRuntime.sessionId) { - projectSupervisorSessionIdRef.current = nextRuntime.sessionId; - setProjectSupervisorSessionId(nextRuntime.sessionId); - } - updateProjectSupervisorRuntime(nextRuntime); - if (isExpectedRun) { - projectSupervisorExpectedRunIdRef.current = null; - setProjectSupervisorExpectedRunId(null); - } - updateProjectSupervisorResponseStream( - payload.runtime.responseStream, - nextRuntime, - ); - setProjectSupervisorRuntimeError(''); - if (invoke) { - syncTerminalProjectSupervisorConversation( - invoke, - payload.projectPath, - nextRuntime, - ); - } - } - rememberAgentRuntimeState(nextRuntime); - if (payload.agentId !== selectedAgentIdRef.current) { - return; - } - if ( - agentConversationSessionIdRef.current !== null && - payload.runtime.state.sessionId !== - agentConversationSessionIdRef.current - ) { - return; - } - setAgentConversationRuntime((current) => - normalizeAgentRuntimeState(nextRuntime, current), - ); - setAgentConversationRuntimeError(''); - }, - ) - .then((unlisten) => { - if (disposed) { - unlisten(); - return; - } - cleanup = unlisten; - }) - .catch((error) => { - if (disposed) { - return; - } - setAgentConversationRuntimeError( - `Runtime 订阅不可用:${ - error instanceof Error ? error.message : String(error) - }`, - ); - }); - return () => { - disposed = true; - cleanup?.(); - }; - // The subscription deliberately invokes the current render's helper; its - // mutable state is read through refs, so resubscribing for that helper is - // neither necessary nor safe. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - directCodexProductRuntime, - designAgentActive, - refreshManifest, - updateProjectSupervisorResponseStream, - updateProjectSupervisorRuntime, - ]); - - useEffect(() => { - const ready = createDesignAgentEventSubscriptionReady(); - if (!canSubscribeTauriEvents() || !designAgentActive) { - resolveDesignAgentEventSubscriptionReady(); - return () => { - if (designAgentEventSubscriptionReadyRef.current === ready) { - designAgentEventSubscriptionReadyRef.current = null; - createDesignAgentEventSubscriptionReady(); - } - }; - } - let cleanup: (() => void) | null = null; - let disposed = false; - void subscribeTauriEvent('design-agent-update', (event) => { - const payload = event.payload; - const tracked = designAgentTurnRef.current; - if ( - payload.projectPath !== localProjectPathRef.current || - (tracked && payload.clientTurnId !== tracked.clientTurnId) - ) { - return; - } - if (payload.kind === 'text' && payload.text != null) { - setDesignAgentTransientReplyTarget(payload.text); - } - if (payload.reasoningText != null) { - const reasoningTurn = designAgentReasoningTurnRef.current; - if ( - reasoningTurn && - reasoningTurn.projectPath === payload.projectPath && - reasoningTurn.clientTurnId === payload.clientTurnId - ) { - reasoningTurn.text = payload.reasoningText; - setDesignAgentReasoning(payload.reasoningText); - } - } - if (payload.kind === 'tool' && payload.text) { - setDesignAgentTransientReplyTarget(payload.text); - } - if (payload.view) { - applyDesignAgentViewAfterTransient( - payload.view, - payload.projectPath, - payload.clientTurnId, - ); - } - }) - .then((unlisten) => { - resolveDesignAgentEventSubscriptionReady(); - if (disposed) { - unlisten(); - return; - } - cleanup = unlisten; - }) - .catch(() => { - resolveDesignAgentEventSubscriptionReady(); - }); - return () => { - disposed = true; - cleanup?.(); - resolveDesignAgentEventSubscriptionReady(); - if (designAgentEventSubscriptionReadyRef.current === ready) { - designAgentEventSubscriptionReadyRef.current = null; - createDesignAgentEventSubscriptionReady(); - } - }; - // applyDesignView 读的是 refs 和当前项目路径, - // 把它写进依赖会在每轮回复时重订事件。 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [designAgentActive]); - - useEffect(() => { - if (!canSubscribeTauriEvents()) { - return; - } - let cleanup: (() => void) | null = null; - let disposed = false; - void subscribeTauriEvent( - 'game-creator-manifest-invalidated', - (event) => { - const activeProjectPath = localProjectPathRef.current; - if ( - !activeProjectPath || - !projectPathsMatchForInvalidation( - event.payload.projectPath, - activeProjectPath, - ) - ) { - return; - } - void refreshManifest(activeProjectPath); - }, - ) - .then((unlisten) => { - if (disposed) { - unlisten(); - return; - } - cleanup = unlisten; - }) - .catch(() => { - // In-process Runtime events continue to carry the same invalidation signal. - }); - return () => { - disposed = true; - cleanup?.(); - }; - }, [refreshManifest]); - - useEffect(() => { - const invoke = resolveTauriInvoke(); - const nextProjectPath = localProject?.projectPath ?? null; - const sessionId = projectSupervisorSessionId; - const trackedRunId = - projectSupervisorExpectedRunId ?? projectSupervisorRuntime?.runId ?? null; - if ( - !invoke || - directCodexProductRuntime || - designAgentActive || - !nextProjectPath || - !sessionId || - !trackedRunId || - (!projectSupervisorRuntime && !projectSupervisorExpectedRunId) || - (projectSupervisorRuntime !== null && - isAgentRuntimeTerminalState(projectSupervisorRuntime) && - !projectSupervisorExpectedRunId) - ) { - return; - } - let disposed = false; - let inFlight = false; - const pollRuntime = async () => { - if (disposed || inFlight) { - return; - } - inFlight = true; - const runtimeBeforePoll = projectSupervisorRuntimeRef.current; - const responseStreamBeforePoll = - projectSupervisorResponseStreamRef.current; - try { - const result = await invoke( - 'read_game_creator_agent_runtime', - { - projectPath: nextProjectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - sessionId, - }, - ); - const currentRuntime = projectSupervisorRuntimeRef.current; - if ( - disposed || - currentRuntime !== runtimeBeforePoll || - projectSupervisorResponseStreamRef.current !== - responseStreamBeforePoll || - localProjectPathRef.current !== nextProjectPath || - projectSupervisorSessionIdRef.current !== sessionId - ) { - return; - } - const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime); - if ( - nextRuntime.sessionId !== sessionId || - nextRuntime.runId !== trackedRunId || - (currentRuntime !== null && - sameAgentRuntimeRun(nextRuntime, currentRuntime) && - nextRuntime.updatedAt < currentRuntime.updatedAt) - ) { - return; - } - updateProjectSupervisorRuntime(nextRuntime); - if (nextRuntime.runId === projectSupervisorExpectedRunIdRef.current) { - projectSupervisorExpectedRunIdRef.current = null; - setProjectSupervisorExpectedRunId(null); - } - updateProjectSupervisorResponseStream( - result.responseStream, - nextRuntime, - ); - setProjectSupervisorRuntimeError(''); - syncTerminalProjectSupervisorConversation( - invoke, - nextProjectPath, - nextRuntime, - ); - } catch (error) { - if ( - !disposed && - localProjectPathRef.current === nextProjectPath && - projectSupervisorSessionIdRef.current === sessionId - ) { - const message = - error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { - requestRuntimeConfigOpen(); - } - setProjectSupervisorRuntimeError(message); - } - } finally { - inFlight = false; - } - }; - void pollRuntime(); - const timer = window.setInterval(() => { - void pollRuntime(); - }, 750); - return () => { - disposed = true; - window.clearInterval(timer); - }; - // Polling restarts only when the tracked runtime identity or phase changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - localProject?.projectPath, - directCodexProductRuntime, - designAgentActive, - projectSupervisorSessionId, - projectSupervisorRuntime?.phase, - projectSupervisorRuntime?.runId, - projectSupervisorRuntime?.status, - projectSupervisorExpectedRunId, - ]); - - useEffect(() => { - const invoke = resolveTauriInvoke(); - const nextProjectPath = localProject?.projectPath ?? null; - const supervisorRunId = projectSupervisorRuntime?.runId ?? null; - if ( - !projectSupervisorOnly || - directCodexProductRuntime || - designAgentActive || - !invoke || - !nextProjectPath || - !supervisorRunId - ) { - return; - } - let disposed = false; - let inFlight = false; - const pollProfessionalRuntimes = async () => { - if (disposed || inFlight) { - return; - } - inFlight = true; - try { - const runtimes = await invoke( - 'read_game_creator_agent_runtimes', - { projectPath: nextProjectPath }, - ); - if ( - disposed || - localProjectPathRef.current !== nextProjectPath || - projectSupervisorRuntimeRef.current?.runId !== supervisorRunId - ) { - return; - } - const nextRuntimes = runtimes.map((runtimeResult) => - agentRuntimeStateFromResult(runtimeResult), - ); - const authoritativeProfessionalAgentIds = new Set( - nextRuntimes - .filter( - (runtime) => - ['agent-delegate', 'agent-delegate-retry'].includes( - runtime.source, - ) && - runtime.parentAgentId === PROJECT_SUPERVISOR_AGENT_ID && - runtime.parentRunId === supervisorRunId, - ) - .map((runtime) => runtime.agentId), - ); - setAgentRuntimeById((current) => { - const reconciled = { ...current }; - for (const [key, runtime] of Object.entries(reconciled)) { - if ( - runtime && - ['agent-delegate', 'agent-delegate-retry'].includes( - runtime.source, - ) && - runtime.parentAgentId === PROJECT_SUPERVISOR_AGENT_ID && - runtime.parentRunId === supervisorRunId && - !authoritativeProfessionalAgentIds.has(runtime.agentId) - ) { - delete reconciled[key]; - } - } - return nextRuntimes.reduce( - (next, runtime) => - mergeAgentRuntimeStateIntoMap(next, runtime, true), - reconciled, - ); - }); - } catch { - // Preserve the last truthful professional Runtime snapshot on transient read failures. - } finally { - inFlight = false; - } - }; - void pollProfessionalRuntimes(); - const timer = window.setInterval(() => { - void pollProfessionalRuntimes(); - }, 1000); - return () => { - disposed = true; - window.clearInterval(timer); - }; - // Track the stable projection fields; the Runtime object identity changes on every merge. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - localProject?.projectPath, - directCodexProductRuntime, - designAgentActive, - projectSupervisorOnly, - projectSupervisorRuntime?.phase, - projectSupervisorRuntime?.runId, - projectSupervisorRuntime?.status, - ]); + }, [initialProjectPath]); useLayoutEffect(() => { - if ( - (!supervisorChatOnly && - !( - projectSupervisorOnly && - (directCodexProductRuntime || designAgentActive) - )) || - !supervisorChatShouldFollowLatestRef.current - ) { + if (!planningChatShouldFollowLatestRef.current) { return; } - const messageList = supervisorChatMessagesRef.current; + const messageList = planningChatMessagesRef.current; if (messageList) { messageList.scrollTop = messageList.scrollHeight; } - }, [ - messages, - projectSupervisorResponseStream?.sequence, - projectSupervisorRuntime?.updatedAt, - projectSupervisorRuntimeError, - directThreadChat, - directCodexProductRuntime, - designAgentActive, - projectSupervisorOnly, - supervisorChatOnly, - ]); - - useEffect(() => { - if (!supervisorChatOnly) { - return; - } - persistSupervisorChatDraft(initialProjectPath, chatInput); - }, [chatInput, initialProjectPath, supervisorChatOnly]); + }, [messages, projectChatError]); useEffect(() => { latestMessagesRef.current = messages; @@ -2123,7 +889,7 @@ export function App({ } // DirectProject history is written by Rust from raw app-server items. // The browser only renders that projection and must not append chat rows. - if (projectSupervisorOnly && directCodexProductRuntime) { + if (directProjectMode) { // `messages` is only an optimistic UI projection in Direct mode; it is // intentionally not proof of durability. Rust owns the raw response // history, so this effect must not route these rows through the generic @@ -2137,7 +903,6 @@ export function App({ return; } if ( - projectSupervisorOnly && pendingMessages.every( (message) => (message.runtimeOwned && @@ -2202,7 +967,6 @@ export function App({ } conversationWriteInFlightRef.current = true; void (async () => { - let wroteMessage = false; for (const [index, message] of pendingMessages.entries()) { if ( message.runtimeOwned && @@ -2231,7 +995,6 @@ export function App({ }, }, ); - wroteMessage = true; savedConversationCountRef.current = start + index + 1; } setWorkspaceStatus((current) => @@ -2240,9 +1003,6 @@ export function App({ ? `已打开:${nextProjectPath}` : current, ); - if (wroteMessage) { - setCommandLog((current) => [...current, 'conversation.write']); - } })() .catch((error) => { savedConversationCountRef.current = Math.min( @@ -2254,12 +1014,6 @@ export function App({ error instanceof Error ? error.message : String(error) }`, ); - setCommandLog((current) => [ - ...current, - `conversation.write.failed ${ - error instanceof Error ? error.message : String(error) - }`, - ]); }) .finally(() => { conversationWriteInFlightRef.current = false; @@ -2277,8 +1031,7 @@ export function App({ messages, conversationWriteVersion, pendingUiConfirmation, - projectSupervisorOnly, - directCodexProductRuntime, + directProjectMode, ]); function appendLocalPermissionLog( @@ -2294,39 +1047,17 @@ export function App({ if (!invoke || !projectPath) { return; } - let result: Promise; try { - result = Promise.resolve( + void Promise.resolve( invoke('append_local_permission_log', { projectPath, event, commandId, }), - ); - } catch (error) { - setCommandLog((current) => [ - ...current, - `permission.log.failed ${commandId}: ${ - error instanceof Error ? error.message : String(error) - }`, - ]); - return; + ).catch(() => undefined); + } catch { + // 权限日志只是审计旁路,写失败不能阻塞对话。 } - void result.catch((error) => { - setCommandLog((current) => [ - ...current, - `permission.log.failed ${commandId}: ${ - error instanceof Error ? error.message : String(error) - }`, - ]); - }); - } - - function resolvePermissionLogProjectPath(command: PendingCommand) { - if (command.id === 'project.create') { - return null; - } - return resolveChatProjectPath(localProject); } function resolveUiPermissionLogProjectPath( @@ -2357,49 +1088,6 @@ export function App({ return draftProjectPath; } - function queuePendingCommand(command: PendingCommand) { - setPendingCommand(command); - setCommandLog((current) => [ - ...current, - `permission.pending ${command.id}`, - ]); - appendLocalPermissionLog( - resolvePermissionLogProjectPath(command), - 'permission.pending', - command.id, - ); - } - - function requestCommandConfirmation( - commandId: GameCreationAppCommandDescriptor['id'], - detail: string, - onConfirm: () => void, - ) { - const permission = GAME_CREATION_APP_COMMANDS.find( - (command) => command.id === commandId, - )?.permission as GameCreationAppPermission | undefined; - - if (permission === 'deny' || !permission) { - setCommandLog((current) => [...current, `permission.deny ${commandId}`]); - return; - } - if (permission === 'confirm') { - pendingUiConfirmationActionRef.current = onConfirm; - setPendingUiConfirmation({ commandId, detail, projectPath: null }); - setCommandLog((current) => [ - ...current, - `permission.pending ${commandId}`, - ]); - appendLocalPermissionLog( - resolveUiPermissionLogProjectPath(commandId), - 'permission.pending', - commandId, - ); - return; - } - onConfirm(); - } - function requestProjectPolicyConfirmation( commandId: GameCreationAppCommandDescriptor['id'], projectPath: string, @@ -2408,7 +1096,6 @@ export function App({ ) { pendingUiConfirmationActionRef.current = onConfirm; setPendingUiConfirmation({ commandId, detail, projectPath }); - setCommandLog((current) => [...current, `permission.pending ${commandId}`]); appendLocalPermissionLog(projectPath, 'permission.pending', commandId); } @@ -2416,33 +1103,13 @@ export function App({ commandId: GameCreationAppCommandDescriptor['id'], message: string, ) { - if (commandId.startsWith('project.') || commandId === 'task.list') { - setProjectStatus(message); - setWorkspaceStatus(message); - } if ( - commandId.startsWith('file.') || - commandId === 'agent.trace_read' || - commandId === 'project.index' || - commandId === 'project.diff' || - commandId === 'project.export_package' || - commandId === 'project.export_list' + commandId.startsWith('project.') || + commandId === 'task.list' || + commandId.startsWith('preview.') ) { - setFileStatus(message); - } - if (commandId.startsWith('asset.') || commandId.startsWith('canvas.')) { - setAssetStatus(message); - } - if (commandId.startsWith('preview.')) { - setPreviewStatus(message); setWorkspaceStatus(message); } - if (commandId.startsWith('agent.')) { - setAgentRunStatus(message); - } - if (commandId.startsWith('memory.')) { - setMemoryStatus(message); - } } async function queueProjectPolicyConfirmationIfNeeded( @@ -2460,7 +1127,6 @@ export function App({ if (policyView.policy.deniedCommands.includes(commandId)) { const message = `项目权限策略拒绝执行:${commandId}`; markProjectPolicyDenied(commandId, message); - setCommandLog((current) => [...current, `permission.deny ${commandId}`]); setMessages((current) => [ ...current, { role: 'assistant', text: message }, @@ -2496,7 +1162,6 @@ export function App({ } const message = `项目权限策略拒绝执行:${commandId}`; markProjectPolicyDenied(commandId, message); - setCommandLog((current) => [...current, `permission.deny ${commandId}`]); setMessages((current) => [ ...current, { role: 'assistant', text: message }, @@ -2532,10 +1197,6 @@ export function App({ const action = pendingUiConfirmationActionRef.current; pendingUiConfirmationActionRef.current = null; setPendingUiConfirmation(null); - setCommandLog((current) => [ - ...current, - `permission.confirm ${pending.commandId}`, - ]); appendLocalPermissionLog( permissionProjectPath, 'permission.confirm', @@ -2564,26 +1225,13 @@ export function App({ } } if (pending.commandId === 'conversation.read') { - if (pending.detail.includes('Agent 对话')) { - setAgentConversationStatus((current) => - current === '等待确认' ? '已取消读取 Agent 对话' : current, - ); - setAgentMemoryStatus((current) => - current === '等待确认' ? '已取消读取 Agent 对话' : current, - ); - } else { + if (!pending.detail.includes('Agent 对话')) { setWorkspaceStatus((current) => current === '等待确认' ? '已取消读取项目对话' : current, ); } } if (pending.commandId === 'memory.read') { - setMemoryStatus((current) => - current === '等待确认' ? '已取消读取项目记忆' : current, - ); - setAgentMemoryStatus((current) => - current === '等待确认' ? '已取消读取 Agent 私有记忆' : current, - ); if (!pending.detail.includes('Agent 私有记忆')) { setMessages((current) => [ ...current, @@ -2591,30 +1239,6 @@ export function App({ ]); } } - if (pending.commandId === 'memory.write') { - setMemoryStatus((current) => - current === '等待确认' ? '已取消保存项目记忆' : current, - ); - setAgentMemoryStatus((current) => - current === '等待确认' ? '已取消保存 Agent 私有记忆' : current, - ); - } - if ( - pending.commandId === 'file.list' || - pending.commandId === 'file.read' || - pending.commandId === 'agent.trace_read' - ) { - setFileStatus((current) => - current === '等待确认' ? '已取消读取项目文件' : current, - ); - } - if (pending.commandId === 'agent.trace_read') { - setAgentRunStatus((current) => - current === '等待确认' || current === '等待确认读取 Agent trace' - ? '已取消读取 Agent trace' - : current, - ); - } if ( pending.commandId === 'project.status' || pending.commandId === 'task.list' @@ -2623,60 +1247,23 @@ export function App({ pending.commandId === 'task.list' ? '已取消读取任务拆分' : '已取消读取项目状态'; - setProjectStatus(status); setWorkspaceStatus(status); } if (pending.commandId === 'asset.list') { - setAssetStatus('已取消读取项目资产'); setWorkspaceStatus('已取消读取项目资产'); } - if (pending.commandId === 'asset.upload') { - setAssetStatus('已取消上传资产'); - } - if (pending.commandId === 'canvas.project_open') { - setAssetStatus('已取消打开画板'); - } - if (pending.commandId === 'canvas.project_sync') { - setAssetStatus('已取消同步画板项目'); - } - if (pending.commandId === 'canvas.asset_import') { - setAssetStatus('已取消导入画板资产'); - } - if (pending.commandId === 'canvas.asset_generate') { - setAssetStatus('已取消生成美术素材'); - } - if (pending.commandId === 'canvas.export_import') { - setAssetStatus('已取消导入画板导出包'); - } if ( pending.commandId === 'preview.start' || pending.commandId === 'preview.open' || pending.commandId === 'preview.stop' || pending.commandId === 'preview.status' ) { - setPreviewStatus('已取消预览操作'); setWorkspaceStatus('已取消预览操作'); setMessages((current) => [ ...current, { role: 'assistant', text: '已取消预览操作' }, ]); } - if ( - pending.commandId === 'agent.run_status' || - pending.commandId === 'agent.audit' - ) { - setAgentRunStatus( - pending.commandId === 'agent.audit' - ? '已取消 Agent 审计' - : '已取消读取 Agent run 状态', - ); - } - if ( - pending.commandId === 'agent.resume' && - pending.detail.includes('未完成的 Agent Runtime 任务') - ) { - setAgentRunStatus('已取消恢复 Agent Runtime 任务'); - } if ( pending.commandId === 'project.index' || pending.commandId === 'project.diff' || @@ -2688,37 +1275,13 @@ export function App({ : pending.commandId === 'project.diff' ? '已取消对比项目 checkpoint' : '已取消导出本地试玩包'; - setFileStatus(status); setWorkspaceStatus(status); } if (pending.commandId === 'project.create') { - setProjectStatus('已取消'); setWorkspaceStatus('已取消'); } - if (pending.commandId === 'file.write') { - setFileStatus('已取消保存项目文件'); - } - if (pending.commandId === 'file.delete') { - setFileStatus('已取消删除项目文件'); - } - if (pending.commandId === 'memory.write') { - setMemoryStatus('已取消写入项目记忆'); - } - if (pending.commandId === 'memory.delete') { - setMemoryStatus('已取消删除项目记忆'); - } - if (pending.commandId === 'asset.register') { - setAssetStatus('已取消登记资产'); - } - if (pending.commandId === 'command.run_limited') { - setLimitedCommandStatus('已取消运行'); - } pendingUiConfirmationActionRef.current = null; setPendingUiConfirmation(null); - setCommandLog((current) => [ - ...current, - `permission.cancel ${pending.commandId}`, - ]); appendLocalPermissionLog( pending.projectPath ?? resolveUiPermissionLogProjectPath(pending.commandId), @@ -2727,27 +1290,11 @@ export function App({ ); } - function handleRuntimeConfigOpen() { - setRuntimeConfigOpen(true); - } - - function queueRunLocalShortcut() { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'game.run_local' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备运行当前本地游戏。' }, - ]); - } - queueRunLocalShortcutRef.current = queueRunLocalShortcut; executeRunLocalRef.current = executeRunLocal; useEffect(() => { const nextProjectPath = localProject?.projectPath; if ( - !projectSupervisorOnly || !playRequest || !nextProjectPath || playRequest.projectPath !== nextProjectPath @@ -2761,238 +1308,31 @@ export function App({ handledPlayRequestRef.current = requestKey; onPlayRequestHandled?.(playRequest.requestId); void executeRunLocalRef.current(true); - }, [ - localProject?.projectPath, - onPlayRequestHandled, - playRequest, - projectSupervisorOnly, - ]); + }, [localProject?.projectPath, onPlayRequestHandled, playRequest]); - function queueStaticSmokeShortcut() { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ - id: 'command.run_limited', - commandId: 'game.static_smoke', - }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备运行静态入口自检。' }, - ]); + /** + * 切换项目作用域时把这条链路自己的聊天状态清干净。 + * + * 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。 + */ + function resetChatState() { + setChatFilesImporting(false); + setChatFileImportNotice(''); + setProjectChatError(''); + setDesignAgentTransientReplyTarget(''); + designAgentPendingViewRef.current = null; + setDesignAgentReasoning(''); + setDesignAgentActive(planningStartMode); + designAgentActiveRef.current = planningStartMode; + designAgentLaneRef.current = planningStartMode; + designAgentTurnRef.current = null; + designAgentReasoningTurnRef.current = null; + setDesignAgentView(null); } - function queueProjectCheckpointShortcut() { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'project.checkpoint' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备保存当前项目快照。' }, - ]); - } - - function queueProjectExportPackageShortcut() { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'project.export_package' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备导出本地试玩包。' }, - ]); - } - - function queuePreviewStartShortcut() { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'preview.start' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备启动本地预览。' }, - ]); - } - - function queuePreviewOpenShortcut() { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'preview.open' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备打开当前本地预览。' }, - ]); - } - - async function handleOpenLauncherWindow() { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请回到首页的项目组切换项目。' }, - ]); - } - - async function handleRevealCurrentProjectDirectory() { - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内打开项目目录。' }, - ]); - return; - } - try { - await invoke('open_local_project_directory', { - projectPath: nextProjectPath, - }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '已打开项目目录。' }, - ]); - } catch (error) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: error instanceof Error ? error.message : String(error), - }, - ]); - } - } - - function requireChatProjectForUserAction() { - const nextProjectPath = resolveChatProjectPath(localProject); - if (nextProjectPath) { - return nextProjectPath; - } - setMemoryStatus('请先初始化本地项目'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); - return null; - } - - async function handleProjectInit(event: FormEvent) { - event.preventDefault(); - requestCommandConfirmation( - 'project.create', - `创建 ${projectPath}`, - () => void executeProjectCreate(projectPath, false), - ); - } - - async function resumeProjectSupervisorRuntimeTasksIfNeeded( - invoke: TauriInvoke, - nextProjectPath: string, - ) { - if ( - projectSupervisorRuntimeResumeProjectPathRef.current === nextProjectPath - ) { - return ''; - } - try { - await invoke( - 'resume_game_creator_agent_runtime_tasks', - { projectPath: nextProjectPath }, - ); - projectSupervisorRuntimeResumeProjectPathRef.current = nextProjectPath; - agentRuntimeResumeProjectPathRef.current = nextProjectPath; - return ''; - } catch (error) { - if (isMissingAgentRuntimeResumeCommandError(error)) { - projectSupervisorRuntimeResumeProjectPathRef.current = nextProjectPath; - agentRuntimeResumeProjectPathRef.current = nextProjectPath; - return ''; - } - const message = error instanceof Error ? error.message : String(error); - if ( - message.includes('项目权限策略要求用户确认:agent.resume') || - message.includes('项目权限策略拒绝执行:agent.resume') - ) { - return ''; - } - if (isRuntimeConfigMissingError(message)) { - requestRuntimeConfigOpen(); - } - const runtimeError = `项目总控 Agent 恢复失败:${message}`; - setProjectSupervisorRuntimeError(runtimeError); - return runtimeError; - } - } - - async function readProjectSupervisorActiveSession( - invoke: TauriInvoke, - nextProjectPath: string, - ) { - return readProjectSupervisorActiveSessionId(invoke, nextProjectPath); - } - - async function refreshProjectSupervisorConversation( - invoke: TauriInvoke, - nextProjectPath: string, - sessionId: string, - ) { - const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; - projectSupervisorHistoryLoadVersionRef.current = loadVersion; - const [projectConversation, supervisorConversation] = await Promise.all([ - invoke('read_local_conversation', { - projectPath: nextProjectPath, - agentId: null, - }), - invoke('read_local_conversation', { - projectPath: nextProjectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - sessionId, - }), - ]); - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath || - projectSupervisorSessionIdRef.current !== sessionId - ) { - return; - } - const conversationMessages = mergeProjectSupervisorConversation( - projectConversation.messages, - supervisorConversation.messages, - ); - const transientResponse = projectSupervisorResponseStreamRef.current; - if ( - conversationContainsProjectSupervisorResponseStream( - supervisorConversation.messages, - transientResponse, - ) - ) { - projectSupervisorResponseStreamRef.current = null; - setProjectSupervisorResponseStream(null); - } - setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - setMessages((current) => { - // 不能整体替换:乐观插入、尚未落盘的用户消息会被冲掉(初始需求看不到就是这个原因)。 - const nextMessages = mergeLoadedConversationWithPendingRuntimeMessages( - conversationMessages, - current, - ); - savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = nextMessages.length; - latestMessagesRef.current = nextMessages; - return nextMessages; - }); - setProjectSupervisorRuntimeError(''); - } - projectSupervisorRefreshConversationRef.current = - refreshProjectSupervisorConversation; - async function loadProjectConversation( nextProjectPath: string, skipPolicyConfirm = false, - mode: 'initial' | 'replace' = 'initial', ) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -3009,7 +1349,7 @@ export function App({ 'conversation.read', nextProjectPath, `读取 ${nextProjectPath} 的项目对话历史`, - () => void loadProjectConversation(nextProjectPath, true, mode), + () => void loadProjectConversation(nextProjectPath, true), ); setWorkspaceStatus('等待确认'); return; @@ -3018,265 +1358,61 @@ export function App({ return; } } - const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; - projectSupervisorHistoryLoadVersionRef.current = loadVersion; try { - if (planningStartMode) { - try { - const design = await hydrateDesignAgentSession(nextProjectPath); - if (design) { - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath - ) { - return; - } - setWorkspaceStatus((workspaceStatus) => - workspaceStatus === '等待确认' - ? `已打开:${nextProjectPath}` - : workspaceStatus, - ); - return; - } - designAgentActiveRef.current = true; - setDesignAgentActive(true); - setDesignAgentView(null); - projectSupervisorSessionIdRef.current = null; - setProjectSupervisorSessionId(null); - updateProjectSupervisorRuntime(null); - setMessages(createDefaultChatMessages()); - savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = 0; - latestMessagesRef.current = []; - setWorkspaceStatus((workspaceStatus) => - workspaceStatus === '等待确认' - ? `已打开:${nextProjectPath}` - : workspaceStatus, - ); - return; - } catch (error) { - setProjectSupervisorRuntimeError( - `策划会话无法恢复:${error instanceof Error ? error.message : String(error)}`, - ); + const design = await hydrateDesignAgentSession(nextProjectPath); + if (design) { + if (localProjectPathRef.current !== nextProjectPath) { return; } - } - const resumeError = directCodexProductRuntime - ? '' - : await resumeProjectSupervisorRuntimeTasksIfNeeded( - invoke, - nextProjectPath, - ); - if (directCodexProductRuntime) { - projectSupervisorRuntimeResumeProjectPathRef.current = nextProjectPath; - agentRuntimeResumeProjectPathRef.current = nextProjectPath; - } - // Direct Codex owns its app-server conversation identity. The project - // workbench only hydrates the project conversation and must not inspect - // or create a legacy Supervisor Session while opening the project. - const sessionId = directCodexProductRuntime - ? null - : await readProjectSupervisorActiveSession(invoke, nextProjectPath); - let runtimeError = ''; - let loadedDirectHistoryHasMore = false; - let loadedDirectHistoryFirstItemId: string | null = null; - // 首屏连拉到的条目先暂存,待下方 staleness 守卫通过后再并入聊天 reducer: - // 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。 - let loadedDirectHistoryItems: DirectThreadItem[] = []; - // 首屏切片的新端边界只认订阅回执里的 `lastCompletedItemId`(含该条):比它更新的条目 - // 只能来自运行态事件。打开项目时订阅 effect 还没跑,这里就先把闸门开好,订阅侧会复用 - // 同一道闸门并 settle 它。同一道闸门只锚定一次:同一订阅下再读一次(例如重开同一个项目) - // 没有新回执可等,退回"按当前文件尾取尾屏",与 `/history` 手动重读一致。 - let directHistoryThroughItemId: string | null = null; - if (directCodexProductRuntime && mode !== 'replace') { - const anchorGate = directHistoryAnchorGateToWaitFor( - directHistoryAnchorGateRef.current, - nextProjectPath, - ); - if (anchorGate) { - directHistoryAnchorGateRef.current = anchorGate; - directHistoryThroughItemId = await anchorGate.anchor; - anchorGate.consumed = true; - } - } - const projectConversation = directCodexProductRuntime - ? (() => { - // 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染 - // 的条目",所以基线取空,免得拿上一份对话的残留状态去比。首屏失败仍按原有语义 - // 抛出,交给调用方的 catch 处理。 - return readDirectHistoryPages({ - existingEntries: [], - beforeItemId: null, - readSlice: (beforeItemId) => - invoke( - 'read_direct_project_history_slice', - beforeItemId - ? { - projectPath: nextProjectPath, - beforeItemId, - limit: CONVERSATION_INITIAL_VISIBLE_COUNT, - } - : { - projectPath: nextProjectPath, - ...(directHistoryThroughItemId - ? { throughItemId: directHistoryThroughItemId } - : {}), - limit: CONVERSATION_INITIAL_VISIBLE_COUNT, - }, - ), - }).then((pages) => { - if (pages.error) { - throw pages.error; - } - loadedDirectHistoryHasMore = pages.hasMore; - loadedDirectHistoryFirstItemId = pages.firstItemId; - loadedDirectHistoryItems = pages.items; - return { - path: nextProjectPath, - agentId: null, - messages: [], - } satisfies LocalConversationResult; - }); - })() - : invoke('read_local_conversation', { - projectPath: nextProjectPath, - agentId: null, - }); - const resolvedProjectConversation = await projectConversation; - // 首屏历史直接进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 - let supervisorConversation: LocalConversationResult | null = null; - let runtime: AgentRuntimeState | null = null; - let runtimeResponseStream: AgentRuntimeResponseStream | null = null; - if (sessionId) { - supervisorConversation = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - sessionId, - }, - ); - } - if (sessionId) { - try { - const runtimeResult = await invoke( - 'read_game_creator_agent_runtime', - { - projectPath: nextProjectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - sessionId, - }, - ); - runtime = agentRuntimeStateFromResult(runtimeResult); - runtimeResponseStream = runtimeResult.responseStream ?? null; - } catch (error) { - runtimeError = error instanceof Error ? error.message : String(error); - } - } - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath - ) { - return; - } - const conversationMessages = mergeProjectSupervisorConversation( - resolvedProjectConversation.messages, - supervisorConversation?.messages ?? [], - ); - if ( - conversationContainsProjectSupervisorResponseStream( - supervisorConversation?.messages ?? [], - runtimeResponseStream, - ) - ) { - runtimeResponseStream = null; - } - projectSupervisorSessionIdRef.current = sessionId; - setProjectSupervisorSessionId(sessionId); - updateProjectSupervisorRuntime(runtime); - if (runtime) { - updateProjectSupervisorResponseStream(runtimeResponseStream, runtime); - } else { - projectSupervisorResponseStreamRef.current = null; - setProjectSupervisorResponseStream(null); - } - setProjectSupervisorRuntimeError(runtimeError || resumeError); - if (directCodexProductRuntime) { - setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId; - if (loadedDirectHistoryItems.length > 0) { - const items = loadedDirectHistoryItems; - // 首屏历史进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 - setDirectThreadChat((state) => mergeDirectHistoryItems(state, items)); - } - } - setMessages((current) => { - // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 - const nextConversationMessages = - mergeLoadedConversationWithPendingRuntimeMessages( - conversationMessages, - current, - ); - // 空对话(没有默认问候之后的新常态)同样应当接受回读结果。 - const isEmptyConversation = current.length === 0; - const hasOnlyDefaultGreeting = - current.length === 1 && - current[0]?.role === 'assistant' && - current[0]?.text === '想做什么游戏?'; - const hasOnlyOpenStatus = - current.length === 2 && - current[0]?.role === 'assistant' && - current[0]?.text === '想做什么游戏?' && - current[1]?.role === 'assistant' && - current[1]?.text === `已设置本地项目:${nextProjectPath}`; - if ( - mode !== 'replace' && - !isEmptyConversation && - !hasOnlyDefaultGreeting && - !hasOnlyOpenStatus - ) { - return current; - } - setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = nextConversationMessages.length; - latestMessagesRef.current = nextConversationMessages; - setWorkspaceStatus((workspaceStatus) => { - if (mode === 'replace') { - return `已读取项目对话历史:${conversationMessages.length} 条`; - } - return workspaceStatus === '等待确认' + setWorkspaceStatus((workspaceStatus) => + workspaceStatus === '等待确认' ? `已打开:${nextProjectPath}` - : workspaceStatus; - }); - return nextConversationMessages; - }); - } catch (error) { - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath - ) { + : workspaceStatus, + ); return; } - const message = error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { - requestRuntimeConfigOpen(); + } catch (error) { + if (planningStartMode) { + setProjectChatError( + `策划会话无法恢复:${error instanceof Error ? error.message : String(error)}`, + ); } - setProjectSupervisorRuntimeError(message); + } + if (planningStartMode) { + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + designAgentActiveRef.current = true; + setDesignAgentActive(true); + designAgentLaneRef.current = true; + setDesignAgentView(null); + setMessages(createDefaultChatMessages()); + savedConversationProjectPathRef.current = nextProjectPath; + savedConversationCountRef.current = 0; + latestMessagesRef.current = []; setWorkspaceStatus((workspaceStatus) => workspaceStatus === '等待确认' - ? `项目对话读取失败:${message}` + ? `已打开:${nextProjectPath}` : workspaceStatus, ); - // Keep the default greeting when history is missing or blocked. + return; } + // DirectProject 的项目对话由聊天容器自己的订阅与切片读取拥有:工作台壳不读项目 + // 对话文件,也不把历史混进壳自己的 `messages`。 + setProjectChatError(''); + setWorkspaceStatus((workspaceStatus) => + workspaceStatus === '等待确认' + ? `已打开:${nextProjectPath}` + : workspaceStatus, + ); } async function openWorkspace( nextProjectPath: string, announceToChat: boolean, mode: 'create' | 'open' = 'create', - projectKind: LocalProjectKind = 'web', + // 工程类型已不再由壳层决定插件可用性(插件宿主自己判),保留入参只为调用契约。 + _projectKind: LocalProjectKind = 'web', ) { const trimmedProjectPath = nextProjectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { @@ -3291,7 +1427,6 @@ export function App({ const invoke = resolveTauriInvoke(); if (!invoke) { setWorkspaceStatus('需要在 Tauri App 内运行'); - setProjectStatus('需要在 Tauri App 内运行'); if (announceToChat) { setMessages((current) => [ ...current, @@ -3303,26 +1438,9 @@ export function App({ const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; - resetProjectSupervisorState(); - resetDirectThreadChat(); + resetChatState(); updateClientPreview(null); - setPreviewStatus('未启动'); setWorkspaceStatus('正在打开'); - setProjectStatus('正在初始化'); - setSelectedAgent(null); - setAgentConversationInput(''); - setAgentConversationMessages([]); - setAgentConversationRuntime(null); - setAgentConversationSessionId(null); - setAgentConversationRunSubmitMode('steer'); - setAgentConversationRuntimeError(''); - setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - setAgentConversationStatus('未选择 agent'); - setAgentConversationSaving(false); - setAgentMemoryStatus('未读取'); - setAgentMemoryContent(''); - agentConversationSavingRef.current = false; - agentConversationLoadVersionRef.current += 1; try { const result = mode === 'open' @@ -3350,7 +1468,6 @@ export function App({ ) { const message = '本地项目路径无效'; setWorkspaceStatus(message); - setProjectStatus(message); if (announceToChat) { setMessages((current) => [ ...current, @@ -3370,18 +1487,8 @@ export function App({ } localProjectPathRef.current = openedProject.projectPath; setProjectPath(openedProject.projectPath); - setWorkspaceProjectKind(projectKind); setLocalProject(openedProject); - if (supervisorChatOnly) { - setChatInput(readSupervisorChatDraft(openedProject.projectPath)); - } - setChatReferences([]); - setChatContent([]); setManifest(openedProject.manifest); - setProjectFiles([]); - setProjectCheckpoints([]); - setAgentRunHistory([]); - setAgentRunHistoryFiles([]); setAgentRuntimeById({}); setMessages(conversationMessages); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); @@ -3389,7 +1496,6 @@ export function App({ savedConversationCountRef.current = conversationMessages.length; writeRecentWorkspace(openedProject.projectPath); setWorkspaceStatus(`已打开:${openedProject.projectPath}`); - setProjectStatus(mode === 'open' ? '已打开' : '已初始化'); appendLocalPermissionLog( openedProject.projectPath, 'permission.confirm', @@ -3404,11 +1510,8 @@ export function App({ }, ]); } - // Direct Codex must not resume or inspect legacy Supervisor work. Its - // own project conversation is hydrated above; the old run trace remains - // available only in the explicit legacy diagnostic surface. void loadProjectConversation(openedProject.projectPath); - if (!directCodexProductRuntime) { + if (!directProjectMode) { void refreshAgentRunTrace(openedProject.projectPath); } } catch (error) { @@ -3417,7 +1520,6 @@ export function App({ } const message = error instanceof Error ? error.message : String(error); setWorkspaceStatus(message); - setProjectStatus(message); if (announceToChat) { setMessages((current) => [ ...current, @@ -3427,206 +1529,6 @@ export function App({ } } - async function openAgentConversation( - agent: AgentStatusCard, - skipConversationPolicyConfirm = false, - skipMemoryPolicyConfirm = false, - ) { - const loadVersion = agentConversationLoadVersionRef.current + 1; - agentConversationLoadVersionRef.current = loadVersion; - setSelectedAgent(agent); - setAgentConversationInput(''); - setAgentConversationMessages([]); - setAgentConversationRuntime(null); - setAgentConversationSessionId(null); - setAgentConversationRunSubmitMode('steer'); - setAgentConversationRuntimeError(''); - setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - setAgentMemoryContent(''); - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - if (!invoke || !nextProjectPath) { - setAgentConversationStatus('请先初始化本地项目'); - setAgentMemoryStatus('请先初始化本地项目'); - return; - } - try { - const status = await invoke( - 'check_game_creator_llm_config', - ); - if (agentConversationLoadVersionRef.current === loadVersion) { - setLlmConfigStatus(status); - } - } catch { - // Agent conversation history is still useful when config status is unavailable. - } - try { - if ( - !skipConversationPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'conversation.read', - nextProjectPath, - `读取 ${agent.title} Agent 对话`, - '准备读取 Agent 对话。', - () => - void openAgentConversation(agent, true, skipMemoryPolicyConfirm), - )) - ) { - setAgentConversationStatus('等待确认'); - setAgentMemoryStatus('等待确认'); - return; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAgentConversationStatus(message); - setAgentMemoryStatus(message); - return; - } - setAgentConversationStatus('正在读取'); - setAgentMemoryStatus('正在读取'); - try { - const result = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: agent.id, - }, - ); - if (agentConversationLoadVersionRef.current !== loadVersion) { - return; - } - setAgentConversationMessages(result.messages); - setAgentConversationSessionId(result.sessionId ?? null); - try { - const runtime = await invoke( - 'read_game_creator_agent_runtime', - { - projectPath: nextProjectPath, - agentId: agent.id, - ...(result.sessionId ? { sessionId: result.sessionId } : {}), - }, - ); - if (agentConversationLoadVersionRef.current === loadVersion) { - const nextRuntime = agentRuntimeStateFromResult(runtime); - setAgentConversationRuntime(nextRuntime); - rememberAgentRuntimeState(nextRuntime); - setAgentConversationRuntimeError(''); - } - } catch (error) { - if (agentConversationLoadVersionRef.current === loadVersion) { - setAgentConversationRuntime(null); - setAgentConversationRuntimeError( - error instanceof Error ? error.message : String(error), - ); - } - } - setAgentConversationStatus( - `已读取 ${result.messages.length} 条:${result.path}`, - ); - setCommandLog((current) => [...current, 'conversation.read']); - } catch (error) { - if (agentConversationLoadVersionRef.current !== loadVersion) { - return; - } - setAgentConversationMessages([]); - setAgentConversationStatus( - error instanceof Error ? error.message : String(error), - ); - } - try { - if ( - !skipMemoryPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'memory.read', - nextProjectPath, - `读取 ${agent.title} Agent 私有记忆`, - '准备读取 Agent 私有记忆。', - () => void openAgentConversation(agent, true, true), - )) - ) { - setAgentMemoryStatus('等待确认'); - return; - } - } catch (error) { - if (agentConversationLoadVersionRef.current !== loadVersion) { - return; - } - setAgentMemoryStatus( - error instanceof Error ? error.message : String(error), - ); - return; - } - try { - const result = await invoke( - 'read_local_agent_memory', - { - projectPath: nextProjectPath, - taskId: agent.id, - }, - ); - if (agentConversationLoadVersionRef.current !== loadVersion) { - return; - } - setAgentMemoryContent(result.content); - setAgentMemoryStatus( - result.exists ? `已读取:${result.path}` : '私有记忆为空', - ); - setCommandLog((current) => [...current, 'memory.agent.read']); - } catch (error) { - if (agentConversationLoadVersionRef.current !== loadVersion) { - return; - } - setAgentMemoryContent(''); - setAgentMemoryStatus( - error instanceof Error ? error.message : String(error), - ); - } - } - - function closeAgentConversation() { - agentConversationLoadVersionRef.current += 1; - agentConversationSavingRef.current = false; - agentConversationBackgroundBusyRef.current = false; - setSelectedAgent(null); - setAgentConversationInput(''); - setAgentConversationMessages([]); - setAgentConversationRuntime(null); - setAgentConversationSessionId(null); - setAgentConversationRunSubmitMode('steer'); - setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - setAgentConversationStatus('未选择 agent'); - setAgentConversationSaving(false); - setAgentConversationBackgroundBusy(false); - setAgentMemoryStatus('未读取'); - setAgentMemoryContent(''); - } - - function prepareSuggestedToolCommandDraft( - toolCall: GameCreationAgentToolCallTrace, - ) { - const commandDraft = commandDraftFromSuggestedToolCall(toolCall); - if (!commandDraft) { - return; - } - prepareChatCommandDraft(commandDraft); - closeAgentConversation(); - } - - function prepareChatCommandDraft(commandDraft: string) { - setChatInput(commandDraft); - setChatReferences([]); - setChatContent([]); - window.setTimeout(() => chatInputRef.current?.focus(), 0); - } - - function handleChatComposerChange(draft: ChatComposerDraft) { - setChatInput(draft.text); - setChatReferences(draft.references); - setChatContent(draft.content ?? []); - } - useEffect(() => { const handleResourceReferenceInsert = (event: Event) => { const detail = (event as CustomEvent) @@ -3635,2740 +1537,143 @@ export function App({ chatComposerRef.current?.insertReferences([detail.reference]); chatComposerRef.current?.focus(); }; - /** - * 画布拖拽的批量 @ 引用:与单条入口同一个消费点,只是一次把整批插进去。 - * - * 逐条派发单条事件也能跑,但每次都会重建一次草稿并重新聚焦,而且插入顺序只能靠事件顺序兜着; - * 批次为空时静默返回(不开一次空事务)。 - */ - const handleResourceReferenceInsertMany = (event: Event) => { - const detail = ( - event as CustomEvent - ).detail; - const references = detail?.references ?? []; - if (references.length === 0) return; - chatComposerRef.current?.insertReferences(references); - chatComposerRef.current?.focus(); - }; window.addEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); - window.addEventListener( - RESOURCE_REFERENCE_INSERT_MANY_EVENT, - handleResourceReferenceInsertMany, - ); - return () => { + return () => window.removeEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); - window.removeEventListener( - RESOURCE_REFERENCE_INSERT_MANY_EVENT, - handleResourceReferenceInsertMany, - ); - }; }, []); - function prepareProjectAssetRegisterDraft(localPath: string) { - prepareChatCommandDraft(projectFileActionDrafts(localPath).assetCommand); - } + /** + * DirectProject 聊天的首轮需求:入口 latch 里那份原文与附件,项目匹配后由聊天认领。 + * + * 认领记录与 Design / Planning 入口共用一份(按页面保存),因此同一条 + * 首轮需求不会被两个入口各发一次。 + */ + const initialDirectTurn: DirectProjectInitialTurn | null = (() => { + const latch = initialPlanningPromptLatchRef.current; + if (!latch.prompt || !latch.projectPath) return null; + return { + projectPath: latch.projectPath, + // 首轮需求按 Composer 的 canonical content 形状交给聊天:文本与附件引用内联在 + // 同一份 content 里,`@` 引用与附件的处理不需要两套入参。 + content: [ + { type: 'input_text', text: latch.prompt }, + ...directCodexAttachmentContentParts(latch.attachments), + ], + creationType: latch.creationType, + claimScope: latch.claimScope, + }; + })(); - function prepareAgentEvidenceReadDraft(localPath: string) { - if (!isSafeProjectRelativePath(localPath)) { - return; - } - prepareChatCommandDraft(projectFileActionDrafts(localPath).readCommand); - closeAgentConversation(); - } - - async function handlePickCanvasExportFileDraft() { - if (!requireChatProjectForUserAction()) { - return; - } + /** + * DirectProject 读自己项目对话的门:策略要求确认时入队确认,读取由回调继续。 + */ + async function ensureDirectHistoryReadAllowed(input: { + projectPath: string; + onConfirmed: () => void; + }) { const invoke = resolveTauriInvoke(); - if (!invoke) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内选择画板导出包。' }, - ]); - return; - } + if (!invoke) return false; try { - const selectedPath = await invoke('pick_local_file'); - if (!selectedPath) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '已取消选择画板导出包。' }, - ]); - return; - } - setCanvasExportPath(selectedPath); - prepareChatCommandDraft(`/import-canvas-export ${selectedPath} `); - setAssetStatus(`已选择画板导出包:${selectedPath}`); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `已选择画板导出包:${selectedPath}` }, - ]); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAssetStatus(message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - - async function saveAgentConversationMessage( - agent: AgentStatusCard, - content: string, - skipPolicyConfirm = false, - ) { - if (!agent || !content || agentConversationSavingRef.current) { - return; - } - if (agentRuntimeNeedsUserInput(agentConversationRuntime)) { - setAgentConversationStatus('请先回答 Agent 当前的澄清问题'); - return; - } - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - if (!invoke) { - setAgentConversationStatus('需要在 Tauri App 内运行'); - return; - } - if (!nextProjectPath) { - setAgentConversationStatus('请先初始化本地项目'); - return; - } - const llmWarning = formatAgentLlmConfigWarning(llmConfigStatus, agent); - if (llmWarning) { - setAgentConversationStatus(llmWarning); - return; - } - const saveVersion = agentConversationLoadVersionRef.current; - try { - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'conversation.write', - nextProjectPath, - `写入 ${agent.title} Agent 对话`, - '准备保存 Agent 对话。', - () => void saveAgentConversationMessage(agent, content, true), - )) - ) { - setAgentConversationStatus('等待确认'); - return; - } - } catch (error) { - setAgentConversationStatus( - error instanceof Error ? error.message : String(error), + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: input.projectPath }, ); - return; - } - agentConversationSavingRef.current = true; - setAgentConversationSaving(true); - setAgentConversationInput(''); - setAgentConversationStatus('正在保存用户消息'); - let savedUserResult: LocalConversationResult | null = null; - let stopStreamListen: (() => void) | null = null; - let streamListenDisposed = false; - let streamListenReady = false; - try { - savedUserResult = await invoke( - 'append_local_conversation_message', - { - projectPath: nextProjectPath, - agentId: agent.id, - message: { - role: 'user', - content, - agentId: null, - }, - }, + if (!policyView.policy.confirmCommands.includes('conversation.read')) { + return true; + } + requestProjectPolicyConfirmation( + 'conversation.read', + input.projectPath, + `读取 ${input.projectPath} 的项目对话历史`, + input.onConfirmed, ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - const savedUserMessages = savedUserResult.messages; - setAgentConversationMessages(savedUserMessages); - setAgentConversationStatus('正在连接 Agent LLM'); - const streamRunId = createAgentChatRunId('agent-conversation'); - if (canSubscribeTauriEvents()) { - try { - stopStreamListen = - await subscribeTauriEvent( - 'game-creator-role-agent-chat-stream', - (event) => { - const payload = event.payload; - if ( - payload.projectPath !== nextProjectPath || - payload.agentId !== agent.id || - payload.runId !== streamRunId || - agentConversationLoadVersionRef.current !== saveVersion - ) { - return; - } - if (payload.runtimeState) { - setAgentConversationRuntime((current) => { - const nextRuntime = normalizeAgentRuntimeState( - payload.runtimeState!, - current, - ); - rememberAgentRuntimeState(nextRuntime); - return nextRuntime; - }); - setAgentConversationRuntimeError(''); - } - if (payload.status === 'started') { - setAgentConversationStatus( - payload.runtimeSummary ?? 'Agent 已连接,正在等待回复', - ); - return; - } - if (payload.status === 'delta') { - const draftText = - payload.accumulatedText || payload.deltaText; - if (draftText) { - setAgentConversationMessages([ - ...savedUserMessages, - createLocalConversationDraftMessage(draftText), - ]); - } - setAgentConversationStatus( - payload.finishReason - ? `Agent 回复结束:${payload.finishReason}` - : '正在接收 Agent 回复', - ); - return; - } - if (payload.status === 'completed') { - setAgentConversationStatus( - payload.runtimeSummary ?? 'Agent 回复完成,正在保存', - ); - return; - } - if (payload.status === 'failed') { - setAgentConversationStatus( - payload.runtimeSummary ?? - 'Agent 流式回复失败,正在记录错误', - ); - } - }, - ); - streamListenReady = true; - if (streamListenDisposed) { - stopStreamListen(); - stopStreamListen = null; - } - } catch { - setAgentConversationStatus('实时状态不可用,正在使用普通回复模式'); - } - } - const reply = streamListenReady - ? await invoke( - 'chat_with_game_creator_role_agent_stream', - { - projectPath: nextProjectPath, - agentId: agent.id, - prompt: content, - runId: streamRunId, - }, - ) - : await invoke( - 'chat_with_game_creator_role_agent', - { - projectPath: nextProjectPath, - agentId: agent.id, - prompt: content, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - try { - const runtime = await invoke( - 'read_game_creator_agent_runtime', - { - projectPath: nextProjectPath, - agentId: agent.id, - }, - ); - if (agentConversationLoadVersionRef.current === saveVersion) { - const nextRuntime = agentRuntimeStateFromResult(runtime); - setAgentConversationRuntime(nextRuntime); - rememberAgentRuntimeState(nextRuntime); - setAgentConversationRuntimeError(''); - } - } catch (error) { - if (agentConversationLoadVersionRef.current === saveVersion) { - setAgentConversationRuntimeError( - error instanceof Error ? error.message : String(error), - ); - } - } - setAgentConversationStatus('正在保存 Agent 回复'); - setAgentConversationMessages([ - ...savedUserMessages, - createLocalConversationDraftMessage(reply.replyText), - ]); - const assistantResult = await invoke( - 'append_local_conversation_message', - { - projectPath: nextProjectPath, - agentId: agent.id, - message: { - role: 'assistant', - content: reply.replyText, - agentId: null, - }, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationMessages(assistantResult.messages); - setAgentConversationStatus( - `已保存 ${assistantResult.messages.length} 条:${assistantResult.path}`, - ); - setCommandLog((current) => [...current, 'conversation.write']); - } catch (error) { - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - void captureAgentRuntimeError(error, agent.id); - if (savedUserResult) { - const message = `已保存用户消息;Agent 回复失败:${ - error instanceof Error ? error.message : String(error) - }`; - try { - const errorResult = await invoke( - 'append_local_conversation_message', - { - projectPath: nextProjectPath, - agentId: agent.id, - message: { - role: 'assistant', - content: message, - agentId: null, - }, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationMessages(errorResult.messages); - } catch { - setAgentConversationMessages([ - ...savedUserResult.messages, - { - schemaVersion: 'game-creator-conversation.v1', - role: 'assistant', - content: message, - agentId: null, - updatedAt: Date.now(), - }, - ]); - } - setAgentConversationStatus(message); - } else { - setAgentConversationInput(content); - setAgentConversationStatus( - error instanceof Error ? error.message : String(error), - ); - } - } finally { - streamListenDisposed = true; - stopStreamListen?.(); - agentConversationSavingRef.current = false; - setAgentConversationSaving(false); - } - } - - async function startSelectedAgentBackgroundTask( - agent: AgentStatusCard, - content: string, - skipPolicyConfirm = false, - submitMode = agentConversationRunSubmitMode, - ) { - if (!agent || !content || agentConversationBackgroundBusyRef.current) { - return; - } - if (agentRuntimeNeedsUserInput(agentConversationRuntime)) { - setAgentConversationStatus('请先回答 Agent 当前的澄清问题'); - return; - } - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - if (!invoke) { - setAgentConversationStatus('需要在 Tauri App 内运行'); - return; - } - if (!nextProjectPath) { - setAgentConversationStatus('请先初始化本地项目'); - return; - } - const llmWarning = formatAgentLlmConfigWarning(llmConfigStatus, agent); - if (llmWarning) { - setAgentConversationStatus(llmWarning); - return; - } - const steerRuntime = - submitMode === 'steer' - ? matchingAgentRuntimeForSteer( - [agentConversationRuntime], - agent.id, - agentConversationSessionId, - ) - : null; - const saveVersion = agentConversationLoadVersionRef.current; - try { - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'conversation.write', - nextProjectPath, - steerRuntime - ? `向 ${agent.title} 当前 Run 追加指令` - : `启动 ${agent.title} 后台任务`, - steerRuntime - ? '准备向当前 Agent Run 追加指令。' - : '准备启动 Agent 后台任务。', - () => - void startSelectedAgentBackgroundTask( - agent, - content, - true, - submitMode, - ), - )) - ) { - setAgentConversationStatus('等待确认'); - return; - } - } catch (error) { - setAgentConversationStatus( - error instanceof Error ? error.message : String(error), - ); - return; - } - agentConversationBackgroundBusyRef.current = true; - setAgentConversationBackgroundBusy(true); - if (steerRuntime) { - setAgentConversationStatus( - `正在向当前 Run 追加指令:${steerRuntime.runId}`, - ); - } else { - setAgentConversationInput(''); - setAgentConversationStatus('正在启动 Agent 后台任务'); - } - try { - let runtime: AgentRuntimeResult; - let successStatus: string; - if (steerRuntime) { - const steer = await invoke( - 'steer_game_creator_agent_runtime_task', - { - projectPath: nextProjectPath, - agentId: agent.id, - sessionId: steerRuntime.sessionId, - runId: steerRuntime.runId, - steerId: createAgentChatRunId('agent-steer'), - instruction: content, - }, - ); - runtime = steer.runtime; - successStatus = agentRuntimeSteerStatus(steer); - } else { - runtime = await invoke( - 'start_game_creator_agent_runtime_task', - { - projectPath: nextProjectPath, - agentId: agent.id, - task: content, - runId: createAgentChatRunId('agent-background-task'), - ...(agentConversationSessionId - ? { sessionId: agentConversationSessionId } - : {}), - }, - ); - successStatus = agentRuntimeStartStatus(runtime); - } - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - const nextRuntime = agentRuntimeStateFromResult(runtime); - setAgentConversationInput(''); - setAgentConversationRunSubmitMode('steer'); - setAgentConversationSessionId(nextRuntime.sessionId); - setAgentConversationRuntime(nextRuntime); - rememberAgentRuntimeState(nextRuntime); - setAgentConversationRuntimeError(''); - try { - const conversation = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: agent.id, - sessionId: nextRuntime.sessionId, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationMessages(conversation.messages); - setAgentConversationSessionId( - conversation.sessionId ?? nextRuntime.sessionId, - ); - } catch (error) { - if (agentConversationLoadVersionRef.current === saveVersion) { - setAgentConversationRuntimeError( - `对话刷新失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - setAgentConversationStatus(successStatus); - setCommandLog((current) => [ - ...current, - steerRuntime ? 'agent.runtime.steer' : 'agent.runtime.background_task', - ]); - } catch (error) { - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationInput(content); - setAgentConversationStatus( - error instanceof Error ? error.message : String(error), - ); - } finally { - agentConversationBackgroundBusyRef.current = false; - setAgentConversationBackgroundBusy(false); - } - } - - async function cancelSelectedAgentRuntimeTask( - agent: AgentStatusCard, - runId: string, - ) { - if (!agent || !runId || agentConversationBackgroundBusyRef.current) { - return; - } - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - if (!invoke) { - setAgentConversationStatus('需要在 Tauri App 内运行'); - return; - } - if (!nextProjectPath) { - setAgentConversationStatus('请先初始化本地项目'); - return; - } - const saveVersion = agentConversationLoadVersionRef.current; - agentConversationBackgroundBusyRef.current = true; - setAgentConversationBackgroundBusy(true); - setAgentConversationStatus('正在取消 Agent 后台任务'); - try { - const runtime = await invoke( - 'cancel_game_creator_agent_runtime_task', - { - projectPath: nextProjectPath, - agentId: agent.id, - runId, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - const nextRuntime = agentRuntimeStateFromResult(runtime); - setAgentConversationRuntime(nextRuntime); - rememberAgentRuntimeState(nextRuntime); - setAgentConversationRuntimeError(''); - setAgentConversationStatus(agentRuntimeCancelStatus(nextRuntime, runId)); - setCommandLog((current) => [ - ...current, - 'agent.runtime.background_task.cancel', - ]); - } catch (error) { - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationStatus( - error instanceof Error ? error.message : String(error), - ); - } finally { - agentConversationBackgroundBusyRef.current = false; - setAgentConversationBackgroundBusy(false); - } - } - - async function retrySelectedAgentRuntimeTask( - agent: AgentStatusCard, - runId: string, - ) { - if (!agent || !runId || agentConversationBackgroundBusyRef.current) { - return; - } - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - if (!invoke) { - setAgentConversationStatus('需要在 Tauri App 内运行'); - return; - } - if (!nextProjectPath) { - setAgentConversationStatus('请先初始化本地项目'); - return; - } - const llmWarning = formatAgentLlmConfigWarning(llmConfigStatus, agent); - if (llmWarning) { - setAgentConversationStatus(llmWarning); - return; - } - const saveVersion = agentConversationLoadVersionRef.current; - agentConversationBackgroundBusyRef.current = true; - setAgentConversationBackgroundBusy(true); - setAgentConversationStatus('正在重试 Agent 后台任务'); - try { - const runtime = await invoke( - 'retry_game_creator_agent_runtime_task', - { - projectPath: nextProjectPath, - agentId: agent.id, - runId, - nextRunId: createAgentChatRunId('agent-background-retry'), - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - const nextRuntime = agentRuntimeStateFromResult(runtime); - setAgentConversationRuntime(nextRuntime); - rememberAgentRuntimeState(nextRuntime); - setAgentConversationRuntimeError(''); - const conversation = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: agent.id, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationMessages(conversation.messages); - setAgentConversationStatus(agentRuntimeStartStatus(runtime)); - setCommandLog((current) => [ - ...current, - 'agent.runtime.background_task.retry', - ]); - } catch (error) { - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationStatus( - error instanceof Error ? error.message : String(error), - ); - } finally { - agentConversationBackgroundBusyRef.current = false; - setAgentConversationBackgroundBusy(false); - } - } - - async function confirmSelectedAgentRuntimeTask( - agent: AgentStatusCard, - runId: string, - actionId: string, - ) { - if ( - !agent || - !runId || - !actionId || - agentConversationBackgroundBusyRef.current - ) { - return; - } - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - if (!invoke) { - setAgentConversationStatus('需要在 Tauri App 内运行'); - return; - } - if (!nextProjectPath) { - setAgentConversationStatus('请先初始化本地项目'); - return; - } - const llmWarning = formatAgentLlmConfigWarning(llmConfigStatus, agent); - if (llmWarning) { - setAgentConversationStatus(llmWarning); - return; - } - const saveVersion = agentConversationLoadVersionRef.current; - agentConversationBackgroundBusyRef.current = true; - setAgentConversationBackgroundBusy(true); - setAgentConversationStatus('正在确认并继续 Agent 后台任务'); - try { - const runtime = await invoke( - 'confirm_game_creator_agent_runtime_task', - { - projectPath: nextProjectPath, - agentId: agent.id, - runId, - actionId, - note: '开发者已确认待执行工具动作', - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - const nextRuntime = agentRuntimeStateFromResult(runtime); - setAgentConversationRuntime(nextRuntime); - rememberAgentRuntimeState(nextRuntime); - setAgentConversationRuntimeError(''); - const conversation = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: agent.id, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationMessages(conversation.messages); - setAgentConversationStatus(agentRuntimeStartStatus(runtime)); - setCommandLog((current) => [ - ...current, - 'agent.runtime.tool_confirmation.approved', - ]); - } catch (error) { - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationStatus( - error instanceof Error ? error.message : String(error), - ); - } finally { - agentConversationBackgroundBusyRef.current = false; - setAgentConversationBackgroundBusy(false); - } - } - - async function rejectSelectedAgentRuntimeTask( - agent: AgentStatusCard, - runId: string, - actionId: string, - ) { - if ( - !agent || - !runId || - !actionId || - agentConversationBackgroundBusyRef.current - ) { - return; - } - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - if (!invoke) { - setAgentConversationStatus('需要在 Tauri App 内运行'); - return; - } - if (!nextProjectPath) { - setAgentConversationStatus('请先初始化本地项目'); - return; - } - const llmWarning = formatAgentLlmConfigWarning(llmConfigStatus, agent); - if (llmWarning) { - setAgentConversationStatus(llmWarning); - return; - } - const saveVersion = agentConversationLoadVersionRef.current; - agentConversationBackgroundBusyRef.current = true; - setAgentConversationBackgroundBusy(true); - setAgentConversationStatus('正在拒绝工具动作并继续 Agent 后台任务'); - try { - const runtime = await invoke( - 'reject_game_creator_agent_runtime_task', - { - projectPath: nextProjectPath, - agentId: agent.id, - runId, - actionId, - note: '开发者拒绝待执行工具动作', - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - const nextRuntime = agentRuntimeStateFromResult(runtime); - setAgentConversationRuntime(nextRuntime); - rememberAgentRuntimeState(nextRuntime); - setAgentConversationRuntimeError(''); - const conversation = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: agent.id, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationMessages(conversation.messages); - setAgentConversationStatus(agentRuntimeStartStatus(runtime)); - setCommandLog((current) => [ - ...current, - 'agent.runtime.tool_confirmation.rejected', - ]); - } catch (error) { - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationStatus( - error instanceof Error ? error.message : String(error), - ); - } finally { - agentConversationBackgroundBusyRef.current = false; - setAgentConversationBackgroundBusy(false); - } - } - - async function answerSelectedAgentRuntimeUserInput( - agent: AgentStatusCard, - request: AgentRuntimeUserInputRequest, - responseId: string, - answers: Record, - ) { - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - const currentRequest = agentConversationRuntime?.userInputRequest; - if ( - !invoke || - !nextProjectPath || - !responseId || - agentConversationBackgroundBusyRef.current - ) { - return; - } - if ( - request.agentId !== agent.id || - request.sessionId !== agentConversationSessionId || - request.runId !== agentConversationRuntime?.runId || - currentRequest?.requestId !== request.requestId || - currentRequest?.actionId !== request.actionId - ) { - setAgentConversationRuntimeError('待回答请求已变更,请刷新 Runtime 状态'); - return; - } - const saveVersion = agentConversationLoadVersionRef.current; - agentConversationBackgroundBusyRef.current = true; - setAgentConversationBackgroundBusy(true); - setAgentConversationRuntimeError(''); - setAgentConversationStatus('正在提交回答并继续当前 Run'); - try { - const result = await invoke( - 'answer_game_creator_agent_runtime_user_input', - { - projectPath: nextProjectPath, - agentId: agent.id, - runId: request.runId, - actionId: request.actionId, - requestId: request.requestId, - responseId, - answers, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - const runtimeState = agentRuntimeStateFromResult( - result, - agentConversationRuntime, - ); - if ( - runtimeState.agentId !== agent.id || - runtimeState.sessionId !== request.sessionId || - runtimeState.runId !== request.runId - ) { - throw new Error('Agent 回答后 Runtime 身份不匹配'); - } - setAgentConversationRuntime(runtimeState); - rememberAgentRuntimeState(runtimeState); - setAgentConversationSessionId(runtimeState.sessionId); - const conversation = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: agent.id, - sessionId: runtimeState.sessionId, - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationMessages(conversation.messages); - setAgentConversationSessionId( - conversation.sessionId ?? runtimeState.sessionId, - ); - setAgentConversationRuntimeError(''); - setAgentConversationStatus(agentRuntimeConversationStatus(runtimeState)); - setCommandLog((current) => [ - ...current, - 'agent.runtime.user_input.answered', - ]); - } catch (error) { - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - const message = error instanceof Error ? error.message : String(error); - setAgentConversationRuntimeError(message); - setAgentConversationStatus(`回答提交失败:${message}`); - } finally { - agentConversationBackgroundBusyRef.current = false; - setAgentConversationBackgroundBusy(false); - } - } - - async function saveSelectedAgentPrivateMemory( - agent: AgentStatusCard, - content: string, - skipPolicyConfirm = false, - ) { - if (!agent || !content || agentConversationSavingRef.current) { - return; - } - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - if (!invoke) { - setAgentMemoryStatus('需要在 Tauri App 内运行'); - return; - } - if (!nextProjectPath) { - setAgentMemoryStatus('请先初始化本地项目'); - return; - } - const saveVersion = agentConversationLoadVersionRef.current; - try { - if (!skipPolicyConfirm) { - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - if (policyView.policy.deniedCommands.includes('memory.write')) { - const message = '项目权限策略拒绝执行:memory.write'; - markProjectPolicyDenied('memory.write', message); - setAgentMemoryStatus(message); - setCommandLog((current) => [ - ...current, - 'permission.deny memory.write', - ]); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - return; - } - if (policyView.policy.confirmCommands.includes('memory.write')) { - requestProjectPolicyConfirmation( - 'memory.write', - nextProjectPath, - `写入 ${agent.title} Agent 私有记忆`, - () => void saveSelectedAgentPrivateMemory(agent, content, true), - ); - setAgentMemoryStatus('等待确认'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备保存 Agent 私有记忆。' }, - ]); - return; - } - } - } catch (error) { - setAgentMemoryStatus( - error instanceof Error ? error.message : String(error), - ); - return; - } - agentConversationSavingRef.current = true; - setAgentConversationSaving(true); - setAgentConversationInput(''); - setAgentMemoryStatus('正在保存'); - try { - const result = await invoke( - 'write_local_agent_memory', - { - projectPath: nextProjectPath, - taskId: agent.id, - content: appendMemoryContent(agentMemoryContent, content), - }, - ); - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentMemoryContent(result.content); - setAgentMemoryStatus(`已追加私有记忆:${result.path}`); - setAgentConversationStatus(`已写入 ${agent.title} 私有记忆。`); - setCommandLog((current) => [...current, 'memory.agent.write']); - } catch (error) { - if (agentConversationLoadVersionRef.current !== saveVersion) { - return; - } - setAgentConversationInput(content); - setAgentMemoryStatus( - error instanceof Error ? error.message : String(error), - ); - } finally { - agentConversationSavingRef.current = false; - setAgentConversationSaving(false); - } - } - - function handleAgentConversationSubmit(event: FormEvent) { - event.preventDefault(); - const agent = selectedAgent; - const content = agentConversationInput.trim(); - if (agentRuntimeNeedsUserInput(agentConversationRuntime)) { - setAgentConversationStatus('请先回答 Agent 当前的澄清问题'); - return; - } - if (!agent || !content || agentConversationSavingRef.current) { - return; - } - void saveAgentConversationMessage(agent, content); - } - - function handleAgentPrivateMemorySubmit() { - const agent = selectedAgent; - const content = agentConversationInput.trim(); - if (!agent || !content || agentConversationSavingRef.current) { - return; - } - void saveSelectedAgentPrivateMemory(agent, content); - } - - function handleAgentBackgroundTaskSubmit() { - const agent = selectedAgent; - const content = agentConversationInput.trim(); - if (agentRuntimeNeedsUserInput(agentConversationRuntime)) { - setAgentConversationStatus('请先回答 Agent 当前的澄清问题'); - return; - } - if (!agent || !content || agentConversationBackgroundBusyRef.current) { - return; - } - void startSelectedAgentBackgroundTask(agent, content); - } - - function showChatHelp() { - setCommandLog((current) => [...current, 'help.show']); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `可用命令:\n${chatCommandHelp.join('\n')}`, - }, - ]); - } - - async function handleChatSubmit(event: FormEvent) { - event.preventDefault(); - const prompt = chatInput.trim(); - const references = chatReferences; - if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { - setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); - return; - } - if ((!prompt && references.length === 0) || chatAgentBusy) { - return; - } - - setChatInput(''); - setChatReferences([]); - setChatContent([]); - setMessages((current) => [ - ...current, - { - role: 'user', - text: prompt, - runtimeOwned: !prompt.startsWith('/'), - }, - ]); - if (prompt === '/help') { - showChatHelp(); - return; - } - - if (prompt === '/config') { - handleRuntimeConfigOpen(); - return; - } - - if (prompt === '/capabilities' || prompt === '/能力') { - void executeAgentCapabilitiesChat(); - return; - } - - if (prompt === '/audit' || prompt === '/审计') { - void executeAgentAuditChat(); - return; - } - - const missingArgumentMessage = missingChatCommandArgumentMessage(prompt); - if (missingArgumentMessage) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: missingArgumentMessage }, - ]); - return; - } - - if (prompt.startsWith('/project ')) { - const nextProjectPath = prompt.slice('/project '.length).trim(); - if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请提供本地项目绝对路径。' }, - ]); - return; - } - if (projectPathHasControlCharacter(nextProjectPath)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '本地项目路径不能包含控制字符。' }, - ]); - return; - } - queuePendingCommand({ - id: 'project.create', - projectPath: nextProjectPath, - }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备初始化本地项目:${nextProjectPath}` }, - ]); - return; - } - - if (prompt.startsWith('/generate ') || prompt.startsWith('/draft ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const generationPrompt = prompt - .slice( - prompt.startsWith('/generate ') - ? '/generate '.length - : '/draft '.length, - ) - .trim(); - if (!generationPrompt) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '格式:/generate 创作想法' }, - ]); - return; - } - queuePendingCommand({ - id: 'game.generate_draft', - prompt: generationPrompt, - }); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `准备生成本地游戏草案:${generationPrompt}`, - }, - ]); - return; - } - - if (prompt === '/status') { - void executeProjectStatus(true); - return; - } - - if ( - handleProjectSummaryChatCommand({ - agentRunHistory, - agentRunTrace, - agentStatusCards, - manifest, - prompt, - requireChatProjectForUserAction, - setMessages, - }) - ) { - return; - } - - if (prompt === '/index') { - void queueOrExecuteProjectIndex(); - return; - } - - if (prompt === '/checkpoint') { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'project.checkpoint' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备保存当前项目快照。' }, - ]); - return; - } - - if (prompt === '/checkpoints') { - void executeProjectCheckpoints(true); - return; - } - - if (prompt.startsWith('/diff ')) { - const checkpointId = prompt.slice('/diff '.length).trim(); - if (!checkpointId) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '格式:/diff checkpoint-id' }, - ]); - return; - } - if (!isSafeCheckpointId(checkpointId)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: 'checkpoint id 非法。' }, - ]); - return; - } - void executeProjectDiff(checkpointId, true); - return; - } - - if (prompt.startsWith('/restore ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const checkpointId = prompt.slice('/restore '.length).trim(); - if (!checkpointId) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '格式:/restore checkpoint-id' }, - ]); - return; - } - if (!isSafeCheckpointId(checkpointId)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: 'checkpoint id 非法。' }, - ]); - return; - } - queuePendingCommand({ id: 'project.restore', checkpointId }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备回滚到 checkpoint:${checkpointId}` }, - ]); - return; - } - - if (prompt === '/policy') { - void executeProjectPolicyRead(true); - return; - } - - if ( - prompt.startsWith('/policy-deny ') || - prompt.startsWith('/policy-allow ') || - prompt.startsWith('/policy-confirm ') || - prompt.startsWith('/policy-auto ') - ) { - if (!requireChatProjectForUserAction()) { - return; - } - const mode = prompt.startsWith('/policy-deny ') - ? 'deny' - : prompt.startsWith('/policy-allow ') - ? 'allow' - : prompt.startsWith('/policy-confirm ') - ? 'confirm' - : 'auto'; - const commandPrefix = - mode === 'deny' - ? '/policy-deny ' - : mode === 'allow' - ? '/policy-allow ' - : mode === 'confirm' - ? '/policy-confirm ' - : '/policy-auto '; - const commandId = prompt.slice(commandPrefix.length).trim(); - if (!commandId) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: - mode === 'deny' || mode === 'allow' - ? '格式:/policy-deny file.write' - : '格式:/policy-confirm project.index', - }, - ]); - return; - } - if (!isRegisteredGameCreationCommandId(commandId)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: `未知内置命令:${commandId}` }, - ]); - return; - } - if ( - (mode === 'confirm' || mode === 'auto') && - !isProjectPolicyConfirmableCommandId(commandId) - ) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: '当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', - }, - ]); - return; - } - void queueProjectPolicyMutation(commandId, mode); - return; - } - - if ( - prompt.startsWith('/agent-policy-deny ') || - prompt.startsWith('/agent-policy-allow ') || - prompt.startsWith('/agent-policy-confirm ') || - prompt.startsWith('/agent-policy-auto ') - ) { - if (!requireChatProjectForUserAction()) { - return; - } - const mode = prompt.startsWith('/agent-policy-deny ') - ? 'deny' - : prompt.startsWith('/agent-policy-allow ') - ? 'allow' - : prompt.startsWith('/agent-policy-confirm ') - ? 'confirm' - : 'auto'; - const commandPrefix = - mode === 'deny' - ? '/agent-policy-deny ' - : mode === 'allow' - ? '/agent-policy-allow ' - : mode === 'confirm' - ? '/agent-policy-confirm ' - : '/agent-policy-auto '; - const args = prompt.slice(commandPrefix.length).trim(); - const [agentId = '', commandId = ''] = args.split(/\s+/, 2); - if (!agentId || !commandId) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: - mode === 'deny' || mode === 'allow' - ? '格式:/agent-policy-deny design-director file.read' - : '格式:/agent-policy-confirm design-director memory.write', - }, - ]); - return; - } - if (!isRegisteredGameCreationCommandId(commandId)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: `未知内置命令:${commandId}` }, - ]); - return; - } - if ( - (mode === 'confirm' || mode === 'auto') && - !isProjectPolicyConfirmableCommandId(commandId) - ) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: '当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', - }, - ]); - return; - } - void queueAgentPolicyMutation(agentId, commandId, mode); - return; - } - - if (prompt === '/llm-status') { - void executeLlmConfigStatus(); - return; - } - - if (prompt === '/llm-routes') { - void executeLlmRouteSummary(); - return; - } - - if (prompt === '/open-project' || prompt === '/show-project') { - if (!requireChatProjectForUserAction()) { - return; - } - void handleRevealCurrentProjectDirectory(); - return; - } - - if (prompt === '/switch-project') { - void handleOpenLauncherWindow(); - return; - } - - if (prompt === '/files') { - void executeProjectFiles(true); - return; - } - - if (prompt === '/assets') { - void executeProjectAssets(true); - return; - } - - if (prompt === '/credits') { - if (!requireChatProjectForUserAction()) { - return; - } - const summary = summarizeProjectAssetCredits(manifest); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summary.text, - draftCommand: summary.draftCommand, - draftCommandLabel: summary.draftCommandLabel, - }, - ]); - return; - } - - if (prompt === '/art') { - if (!requireChatProjectForUserAction()) { - return; - } - const summary = summarizeProjectVisualAssets(manifest); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summary.text, - draftCommand: summary.draftCommand, - draftCommandLabel: summary.draftCommandLabel, - }, - ]); - return; - } - - if (prompt === '/audio') { - if (!requireChatProjectForUserAction()) { - return; - } - const summary = summarizeProjectAudioAssets(manifest); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summary.text, - draftCommand: summary.draftCommand, - draftCommandLabel: summary.draftCommandLabel, - }, - ]); - return; - } - - if (prompt === '/artifacts') { - if (!requireChatProjectForUserAction()) { - return; - } - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeCommonProjectArtifactReadDrafts(), - draftCommand: `/read ${commonProjectArtifactReadDrafts[0].path}`, - draftCommandLabel: commonProjectArtifactReadDrafts[0].label, - }, - ]); - return; - } - - if (prompt === '/run-artifacts') { - if (!requireChatProjectForUserAction()) { - return; - } - const artifacts = agentRunTrace - ? readableArtifactsFromAgentRunTrace(agentRunTrace) - : []; - const firstArtifact = artifacts[0] ?? null; - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeRunArtifactReadDrafts(agentRunTrace), - draftCommand: firstArtifact - ? `/read ${firstArtifact.path}` - : undefined, - draftCommandLabel: firstArtifact ? '读取首个 Run 产物' : undefined, - }, - ]); - return; - } - - if (prompt === '/passes') { - if (!requireChatProjectForUserAction()) { - return; - } - const artifacts = agentRunTrace - ? readablePassArtifactsFromAgentRunTrace(agentRunTrace) - : []; - const firstArtifact = artifacts[0] ?? null; - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeAgentPassArtifactReadDrafts(agentRunTrace), - draftCommand: firstArtifact - ? `/read ${firstArtifact.path}` - : undefined, - draftCommandLabel: firstArtifact ? '读取首个轮次产物' : undefined, - }, - ]); - return; - } - - if (prompt === '/runs') { - if (!requireChatProjectForUserAction()) { - return; - } - const firstHistory = agentRunHistory[0] ?? null; - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeAgentRunHistoryReadDrafts( - agentRunTrace, - agentRunHistory, - ), - draftCommand: firstHistory - ? `/read ${firstHistory.path}` - : agentRunTrace - ? '/trace' - : undefined, - draftCommandLabel: firstHistory - ? '读取首个历史 Run' - : agentRunTrace - ? '查看最近 Run' - : undefined, - }, - ]); - return; - } - - if (prompt === '/run-files') { - if (!requireChatProjectForUserAction()) { - return; - } - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeAgentRunSupportFileReadDrafts(), - draftCommand: `/read ${commonAgentRunSupportReadDrafts[0].path}`, - draftCommandLabel: commonAgentRunSupportReadDrafts[0].label, - }, - ]); - return; - } - - if (prompt === '/internals') { - if (!requireChatProjectForUserAction()) { - return; - } - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeProjectInternalReadDrafts(), - draftCommand: `/read ${commonProjectInternalReadDrafts[0].path}`, - draftCommandLabel: commonProjectInternalReadDrafts[0].label, - }, - ]); - return; - } - - if (prompt === '/logs') { - if (!requireChatProjectForUserAction()) { - return; - } - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeCommonProjectLogReadDrafts(), - draftCommand: `/read ${commonProjectLogReadDrafts[0].path}`, - draftCommandLabel: commonProjectLogReadDrafts[0].label, - }, - ]); - return; - } - - if (prompt.startsWith('/asset-register ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const [ - localPath, - kind = 'unknown', - mediaType = 'application/octet-stream', - ] = prompt.slice('/asset-register '.length).trim().split(/\s+/); - if (!localPath) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: '格式:/asset-register assets/hero.png [kind] [mediaType]', - }, - ]); - return; - } - if (!isSafeProjectRelativePath(localPath)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '资产路径必须是项目内相对路径。' }, - ]); - return; - } - queuePendingCommand({ - id: 'asset.register', - localPath, - kind: parseGameCreationAppAssetKind(kind, 'chat.asset-register.kind'), - mediaType, - }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备登记项目资产:${localPath}` }, - ]); - return; - } - - if (prompt.startsWith('/read ')) { - const relativePath = prompt.slice('/read '.length).trim(); - if (!relativePath) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '格式:/read game/index.html' }, - ]); - return; - } - if (!isSafeProjectRelativePath(relativePath)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '文件路径必须是项目内相对路径。' }, - ]); - return; - } - void executeProjectFileReadChat(relativePath); - return; - } - - if (prompt === '/tasks') { - void executeProjectTasks(true); - return; - } - - if (prompt === '/agents') { - if (!requireChatProjectForUserAction()) { - return; - } - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeAgentStatusCardsForChat( - agentStatusCards, - llmConfigStatus, - ), - }, - ]); - return; - } - - if (prompt === '/agent-conversations') { - if (!requireChatProjectForUserAction()) { - return; - } - const drafts = agentConversationReadDraftsFromManifest(manifest); - const firstDraft = drafts[0] ?? null; - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeAgentConversationReadDrafts(manifest), - draftCommand: firstDraft ? `/read ${firstDraft.path}` : undefined, - draftCommandLabel: firstDraft - ? `读取${firstDraft.task.title}对话` - : undefined, - }, - ]); - return; - } - - if (prompt === '/agent-memories') { - if (!requireChatProjectForUserAction()) { - return; - } - const drafts = agentMemoryReadDraftsFromManifest(manifest); - const firstDraft = drafts[0] ?? null; - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeAgentMemoryReadDrafts(manifest), - draftCommand: firstDraft ? `/read ${firstDraft.path}` : undefined, - draftCommandLabel: firstDraft - ? `读取${firstDraft.task.title}记忆` - : undefined, - }, - ]); - return; - } - - if (prompt === '/trace' || prompt === '/loop') { - void executeAgentTraceChat(); - return; - } - - if (prompt === '/agent-status') { - void executeAgentRunControl('status', undefined, true); - return; - } - - if (prompt === '/agent-kill') { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'agent.kill' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备标记最近 run 为 killed。' }, - ]); - return; - } - - if (prompt === '/agent-retry') { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'agent.retry' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备重新运行最近 run 的目标。' }, - ]); - return; - } - - if (prompt === '/agent-resume' || prompt.startsWith('/agent-resume ')) { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ - id: 'agent.resume', - detail: prompt.slice('/agent-resume'.length).trim(), - }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备恢复最近 run。' }, - ]); - return; - } - - if (prompt === '/history') { - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - void loadProjectConversation(nextProjectPath, false, 'replace'); - return; - } - - if (prompt === '/commands' || prompt === '/limited-commands') { - void executeLimitedCommandList(); - return; - } - - if (prompt === '/smoke') { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ - id: 'command.run_limited', - commandId: 'game.static_smoke', - }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备运行静态入口自检。' }, - ]); - return; - } - - if (prompt === '/run') { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'game.run_local' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备运行当前本地游戏。' }, - ]); - return; - } - - if (prompt === '/export') { - queueProjectExportPackageShortcut(); - return; - } - - if (prompt === '/exports') { - void executeProjectExportPackages(true); - return; - } - - if (prompt === '/preview') { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'preview.start' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备启动本地预览。' }, - ]); - return; - } - - if (prompt === '/open-preview') { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'preview.open' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备打开当前本地预览。' }, - ]); - return; - } - - if (prompt === '/preview-status') { - void executePreviewStatus(true); - return; - } - - if (prompt === '/preview-stop') { - if (!requireChatProjectForUserAction()) { - return; - } - void executePreviewStop(true); - return; - } - - if (prompt === '/memory' || prompt.startsWith('/memory ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const scope = parseOptionalMemoryScope(prompt.slice('/memory'.length)); - if (!scope) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: '格式:/memory [short|long|blackboard]', - }, - ]); - return; - } - void executeMemoryReadChat(scope); - return; - } - - if (prompt.startsWith('/remember ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const { scope, content } = parseRememberInput( - prompt.slice('/remember '.length), - ); - if (!content) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请提供要追加的记忆内容。' }, - ]); - return; - } - queuePendingCommand({ - id: 'memory.write', - scope, - content, - mode: 'append', - }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备追加${memoryScopeLabel(scope)}记忆。` }, - ]); - return; - } - - if (prompt.startsWith('/memory-set ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const { scope, content } = parseRememberInput( - prompt.slice('/memory-set '.length), - ); - if (!content) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请提供要保存的记忆内容。' }, - ]); - return; - } - queuePendingCommand({ - id: 'memory.write', - scope, - content, - mode: 'replace', - }); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `准备覆盖保存${memoryScopeLabel(scope)}记忆。`, - }, - ]); - return; - } - - if (prompt === '/forget-memory' || prompt.startsWith('/forget-memory ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const scope = parseOptionalMemoryScope( - prompt.slice('/forget-memory'.length), - ); - if (!scope) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: '格式:/forget-memory [short|long|blackboard]', - }, - ]); - return; - } - queuePendingCommand({ id: 'memory.delete', scope }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备删除${memoryScopeLabel(scope)}记忆。` }, - ]); - return; - } - - if (prompt.startsWith('/canvas ')) { - const canvasProjectId = prompt.slice('/canvas '.length).trim(); - if (!canvasProjectId) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请提供画板项目 ID。' }, - ]); - return; - } - if (!isSafeCanvasProjectId(canvasProjectId)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '画板项目 ID 不能包含控制字符。' }, - ]); - return; - } - queuePendingCommand({ id: 'canvas.project_open', canvasProjectId }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备打开画板项目:${canvasProjectId}` }, - ]); - return; - } - - if (prompt.startsWith('/sync-canvas-project ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const canvasProjectId = prompt - .slice('/sync-canvas-project '.length) - .trim(); - if (!canvasProjectId) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请提供画板项目 ID。' }, - ]); - return; - } - if (!isSafeCanvasProjectId(canvasProjectId)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '画板项目 ID 不能包含控制字符。' }, - ]); - return; - } - queuePendingCommand({ id: 'canvas.project_sync', canvasProjectId }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备同步画板项目资源:${canvasProjectId}` }, - ]); - return; - } - - if (prompt.startsWith('/generate-art ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const generationPrompt = prompt.slice('/generate-art '.length).trim(); - if (!generationPrompt) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请提供美术生成提示词。' }, - ]); - return; - } - queuePendingCommand({ - id: 'canvas.asset_generate', - prompt: generationPrompt, - }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备生成首版美术素材。' }, - ]); - return; - } - - if (prompt.startsWith('/import-canvas-asset ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const [localPath, canvasProjectId, canvasAssetId, kind, mediaType] = - prompt.slice('/import-canvas-asset '.length).trim().split(/\s+/); - if (!localPath || !canvasProjectId || !canvasAssetId) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: '格式:/import-canvas-asset assets/hero.png 画板项目ID 资源ID|object:资产对象ID', - }, - ]); - return; - } - if (!isSafeCanvasProjectId(canvasProjectId)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '画板项目 ID 不能包含控制字符。' }, - ]); - return; - } - if (!isSafeProjectRelativePath(localPath)) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: '画板资产路径必须是项目内相对路径。', - }, - ]); - return; - } - const canvasAssetObjectId = canvasAssetId.startsWith('object:') - ? canvasAssetId.slice('object:'.length).trim() - : ''; - if (canvasAssetId.startsWith('object:') && !canvasAssetObjectId) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: '请提供 object: 后面的资产对象 ID。', - }, - ]); - return; - } - queuePendingCommand({ - id: 'canvas.asset_import', - localPath, - canvasProjectId, - canvasAssetId: canvasAssetObjectId ? '' : canvasAssetId, - canvasAssetObjectId, - kind: parseGameCreationAppAssetKind( - kind ?? 'unknown', - 'chat.canvas-asset-import.kind', - ), - mediaType: mediaType ?? 'application/octet-stream', - }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备导入画板资产:${localPath}` }, - ]); - return; - } - - if (prompt.startsWith('/import-canvas-export ')) { - if (!requireChatProjectForUserAction()) { - return; - } - const [exportPath, canvasProjectId] = prompt - .slice('/import-canvas-export '.length) - .trim() - .split(/\s+/); - if (!exportPath || !canvasProjectId) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: '格式:/import-canvas-export /绝对/画板素材.zip 画板项目ID', - }, - ]); - return; - } - if (!isSafeCanvasProjectId(canvasProjectId)) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '画板项目 ID 不能包含控制字符。' }, - ]); - return; - } - if ( - !isAbsoluteProjectPath(exportPath) || - projectPathHasControlCharacter(exportPath) - ) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: '画板导出 ZIP 路径必须是绝对路径。', - }, - ]); - return; - } - queuePendingCommand({ - id: 'canvas.export_import', - exportPath, - canvasProjectId, - }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备导入画板导出包:${exportPath}` }, - ]); - return; - } - - if (prompt.startsWith('/')) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `未知命令:${prompt}。输入 /help 查看可用命令。`, - }, - ]); - return; - } - - const clientTurnId = createDirectCodexConversationTurnId(); - const userItem = chatComposerDraftToDirectCodexUserItem( - { text: prompt, references, content: chatContent }, - directCodexConversationMessageId(clientTurnId, 'user'), - ); - void executeChatAgentReply({ prompt, references, userItem, clientTurnId }); - } - - async function executeLlmConfigStatus() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setLlmConfigStatus(null); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - - try { - const status = await invoke( - 'check_game_creator_llm_config', - ); - setLlmConfigStatus(status); - setCommandLog((current) => [...current, 'llm.config_check']); - const agentLines = (status.agents ?? []).map(formatLlmAgentStatusLine); - const summary = status.configured - ? `${formatLlmRouteEndpoint(status)}。` - : `官方智能服务未就绪;请登录或重新登录后重试。${formatCodexRuntimeCapabilities(status)},账号状态 ${ - status.accountCredentialState === 'login_required' - ? '需要登录' - : '暂不可用' - }。`; - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: [summary, ...agentLines].join('\n'), - }, - ]); - } catch (error) { - setLlmConfigStatus(null); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: error instanceof Error ? error.message : String(error), - }, - ]); - } - } - - async function executeLlmRouteSummary() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setLlmConfigStatus(null); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - - try { - const status = await invoke( - 'check_game_creator_llm_config', - ); - setLlmConfigStatus(status); - setCommandLog((current) => [...current, 'llm.route_summary']); - const summary = summarizeAgentLlmRoutes(status); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summary.text, - draftCommand: summary.draftCommand, - draftCommandLabel: summary.draftCommandLabel, - }, - ]); - } catch (error) { - setLlmConfigStatus(null); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: error instanceof Error ? error.message : String(error), - }, - ]); - } - } - - async function executeAgentCapabilitiesChat() { - const invoke = resolveTauriInvoke(); - let capabilities: readonly GameCreationAgentCapabilityDescriptor[] = - GAME_CREATION_AGENT_CAPABILITIES; - if (invoke) { - try { - const nativeCapabilities = await invoke< - GameCreationAgentCapabilityDescriptor[] - >('get_game_creation_agent_capabilities'); - if (nativeCapabilities.length > 0) { - capabilities = nativeCapabilities; - } - } catch { - capabilities = GAME_CREATION_AGENT_CAPABILITIES; - } - } - setCommandLog((current) => [...current, 'agent.capabilities']); - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeAgentCapabilities(capabilities) }, - ]); - } - - async function resolveProjectSupervisorSessionForSubmission( - invoke: TauriInvoke, - nextProjectPath: string, - ) { - const currentSessionId = projectSupervisorSessionIdRef.current; - if (currentSessionId) { - return currentSessionId; - } - const sessionId = await ensureProjectSupervisorActiveSessionId( - invoke, - nextProjectPath, - ); - if (!sessionId) { - throw new Error('项目总控 Agent active Session 不可用'); - } - if (localProjectPathRef.current !== nextProjectPath) { - return null; - } - projectSupervisorSessionIdRef.current = sessionId; - setProjectSupervisorSessionId(sessionId); - return sessionId; - } - - async function refreshDirectProjectManifest(nextProjectPath: string) { - try { - await refreshManifest(nextProjectPath); + setWorkspaceStatus('等待确认'); + return false; } catch { - // The direct turn result is still authoritative for the chat. A missing - // or legacy manifest must not hide the Codex reply. + return false; } } + /** + * DirectProject 写自己项目对话的门:策略要求确认时入队确认,确认后重跑同一轮。 + */ + async function ensureDirectTurnWriteAllowed(input: { + projectPath: string; + onConfirmed: () => void; + }) { + const invoke = resolveTauriInvoke(); + if (!invoke) return false; + if (projectConversationWriteConfirmedRef.current === input.projectPath) { + return true; + } + try { + const paused = await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'conversation.write', + input.projectPath, + '写入 DirectProject 对话历史', + 'DirectProject 对话写入需要确认。', + () => { + projectConversationWriteConfirmedRef.current = input.projectPath; + input.onConfirmed(); + }, + ); + if (paused) return false; + projectConversationWriteConfirmedRef.current = input.projectPath; + return true; + } catch (error) { + if (localProjectPathRef.current === input.projectPath) { + setProjectChatError( + `DirectProject 对话权限检查失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return false; + } + } + + /** + * 工作台壳要把一句结果说给用户在项目对话里听。 + * + * DirectProject 的会话由聊天容器持有,壳只把这句话交给聊天的本地消息流; + * 立项策划路径仍写壳自己的 `messages`。 + */ + function announceProjectChatMessage(text: string) { + if (directProjectMode) { + directProjectChatRef.current?.announce(text); + return; + } + setMessages((current) => [...current, { role: 'assistant', text }]); + } + async function executeChatAgentReply({ prompt, clientTurnId: directConversationTurnId, - creationType, - attachments, - directPolicyChecked = false, - references, - userItem, }: ExecuteChatAgentReplyInput) { - if (designAgentActiveRef.current) { - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setProjectSupervisorRuntimeError('请先初始化本地项目'); - return; - } - if (designAgentActiveRef.current) { - await executeDesignAgentTurn( - nextProjectPath, - { type: 'message', text: prompt }, - directConversationTurnId ?? createAgentChatRunId('design-agent-turn'), - ); - return; - } - return; - } - // Product default: send the conversation directly to Codex app-server. - // The legacy Supervisor/harness path remains below for rollback and tests. - if (directCodexProductRuntime) { - const directInvoke = resolveTauriInvoke(); - // Capture the project snapshot before any asynchronous policy/session work. - // `resolveChatProjectPath` only returns a path and TypeScript cannot infer - // that the source project is still non-null after an await; keeping the - // immutable snapshot also prevents a project switch from changing the - // projectId used by this turn halfway through submission. - const directProject = localProject; - const directProjectPath = resolveChatProjectPath(directProject); - const directProjectId = directProject?.manifest.projectId; - if (directProjectPath && directProjectId && directInvoke) { - const clientTurnId = - directConversationTurnId ?? createDirectCodexConversationTurnId(); - const effectiveUserItem = - userItem ?? - chatComposerDraftToDirectCodexUserItem( - { text: prompt, references: references ?? [], content: [] }, - directCodexConversationMessageId(clientTurnId, 'user'), - ); - if ( - !directPolicyChecked && - projectConversationWriteConfirmedRef.current !== directProjectPath - ) { - // 首轮的入参整体留一份给「确认后重跑」用,不在回调里重列字段。 - const policyRetryInput = directCodexPolicyRetryInput({ - prompt, - clientTurnId, - creationType, - attachments, - references, - userItem: effectiveUserItem, - }); - try { - const policyPaused = await queueProjectPolicyConfirmationIfNeeded( - directInvoke, - 'conversation.write', - directProjectPath, - '写入 DirectProject 对话历史', - 'DirectProject 对话写入需要确认。', - () => { - projectConversationWriteConfirmedRef.current = - directProjectPath; - void executeChatAgentReply(policyRetryInput); - }, - ); - if (policyPaused) { - return; - } - projectConversationWriteConfirmedRef.current = directProjectPath; - } catch (error) { - if (localProjectPathRef.current === directProjectPath) { - setProjectSupervisorRuntimeError( - `DirectProject 对话权限检查失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - return; - } - } - const directUserMessageId = directCodexConversationMessageId( - clientTurnId, - 'user', - ); - const directAssistantMessageId = directCodexConversationMessageId( - clientTurnId, - 'assistant', - ); - const appendDirectUserMessageIfMissing = ( - current: ChatMessage[], - ): ChatMessage[] => { - if ( - current.some((message) => message.messageId === directUserMessageId) - ) { - return current; - } - let optimisticIndex = -1; - for (let index = current.length - 1; index >= 0; index -= 1) { - const message = current[index]; - if ( - message?.role === 'user' && - message.text === prompt && - !message.messageId - ) { - optimisticIndex = index; - break; - } - } - if (optimisticIndex >= 0) { - return current.map((message, index) => - index === optimisticIndex - ? { - ...message, - runtimeOwned: true, - messageId: directUserMessageId, - updatedAt: Date.now(), - } - : message, - ); - } - return [ - ...current, - { - role: 'user' as const, - text: prompt, - runtimeOwned: true, - messageId: directUserMessageId, - updatedAt: Date.now(), - }, - ]; - }; - const appendDirectAssistantMessage = ( - current: ChatMessage[], - text: string, - failed = false, - ): ChatMessage[] => { - const messageId = failed - ? `direct-codex:${clientTurnId}:failure` - : directAssistantMessageId; - const nextMessage: ChatMessage = { - role: 'assistant', - text, - runtimeOwned: true, - messageId, - updatedAt: Date.now(), - }; - const withUser = appendDirectUserMessageIfMissing(current); - const existingIndex = withUser.findIndex( - (message) => message.messageId === messageId, - ); - if (existingIndex < 0) { - return [...withUser, nextMessage]; - } - return withUser.map((message, index) => - index === existingIndex ? nextMessage : message, - ); - }; - setChatAgentBusy(true); - setProjectSupervisorRuntimeError(''); - try { - const directTurnInput: { - projectPath: string; - prompt: string; - clientTurnId: string; - creationType?: HomeCreationType; - attachments?: DirectCodexTurnAttachment[]; - userItem: ReturnType; - } = { - projectPath: directProjectPath, - prompt, - clientTurnId, - userItem: effectiveUserItem, - }; - if (creationType) { - directTurnInput.creationType = creationType; - } - if (attachments?.length) { - directTurnInput.attachments = attachments; - } - directTurnInput.userItem = effectiveUserItem; - // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条, - // 因此这里只等回合跑完,不接返回值。 - await withDirectCodexSessionRefresh(() => { - return directInvoke( - 'chat_with_game_creator_direct_codex', - directTurnInput, - ); - }); - // Rust already persisted the complete raw response items. Invalidate - // any history snapshot captured before the turn completed. - if (localProjectPathRef.current === directProjectPath) { - projectSupervisorHistoryLoadVersionRef.current += 1; - } - if (localProjectPathRef.current === directProjectPath) { - await refreshDirectProjectManifest(directProjectPath); - } - } catch (error) { - if ( - isDirectCodexTurnAlreadyRunningError(error) || - isDirectCodexAnotherTurnRunningError(error) - ) { - if (localProjectPathRef.current === directProjectPath) { - setProjectSupervisorRuntimeError( - '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', - ); - } - return; - } - if (localProjectPathRef.current !== directProjectPath) { - return; - } - if (isDirectCodexTurnInterruptedError(error)) { - // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 - setProjectSupervisorRuntimeError(''); - setChatComposerNotice('已终止本次回合'); - setMessages((current) => - appendDirectAssistantMessage(current, '已终止本次回合。', true), - ); - return; - } - void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID); - const message = - error instanceof Error ? error.message : String(error); - let persistedDetail = ''; - const detailRef = message.match( - /详情:(\.agent\/runtime\/errors\/[^\s;]+)/, - )?.[1]; - if (detailRef && directInvoke) { - try { - persistedDetail = await directInvoke( - 'read_agent_runtime_error_detail', - { - projectPath: directProjectPath, - detailRef, - }, - ); - } catch { - persistedDetail = ''; - } - } - const visibleMessage = projectRuntimeVisibleError( - persistedDetail ? `${message}\n\n${persistedDetail}` : message, - '陶泥儿智能创作', - true, - ); - projectSupervisorHistoryLoadVersionRef.current += 1; - if (localProjectPathRef.current === directProjectPath) { - setProjectSupervisorRuntimeError(visibleMessage); - setMessages((current) => - appendDirectAssistantMessage(current, visibleMessage, true), - ); - } - } finally { - try { - if (localProjectPathRef.current === directProjectPath) { - await refreshManifest(directProjectPath); - } - } finally { - if (localProjectPathRef.current === directProjectPath) { - setChatAgentBusy(false); - setDirectCodexTurnCancelling(false); - chatAgentBusyRef.current = false; - // 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。 - dispatchNextQueuedChatTurn(); - } - } - } - return; - } - const message = directInvoke - ? '当前项目尚未准备好,无法启动智能创作。' - : '需要在 Tauri App 内运行,无法启动智能创作。'; - setProjectSupervisorRuntimeError(message); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: message, - runtimeOwned: true, - ...(directConversationTurnId - ? { - messageId: directCodexConversationMessageId( - directConversationTurnId, - 'assistant', - ), - } - : {}), - updatedAt: Date.now(), - }, - ]); - return; - } - if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { - setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); - return; - } - const nextProjectPath = requireChatProjectForUserAction(); + const nextProjectPath = resolveChatProjectPath(localProject); if (!nextProjectPath) { + setProjectChatError('请先初始化本地项目'); return; } - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - - setChatAgentBusy(true); - setProjectSupervisorRuntimeError(''); - try { - const sessionId = await resolveProjectSupervisorSessionForSubmission( - invoke, - nextProjectPath, - ); - if (!sessionId || localProjectPathRef.current !== nextProjectPath) { - return; - } - const runtimeAtSubmission = projectSupervisorRuntimeRef.current; - const submissionRoute = resolveProjectSupervisorRuntimeSubmission({ - workspaceProjectKind, - orchestrationMode, - supervisorChatOnly, - }); - const submission = await submitProjectSupervisorRuntimeTask({ - invoke, - projectPath: nextProjectPath, - sessionId, - prompt, - runtime: runtimeAtSubmission, - runProfile: submissionRoute.runProfile, - source: submissionRoute.source, - }); - const runtimeResult = submission.runtimeResult; - const acceptedRunId = submission.acceptedRunId.trim(); - if (!acceptedRunId) { - throw new Error('项目总控 Agent 请求未进入后台队列'); - } - setCommandLog((current) => [ - ...current, - `agent.runtime.${submission.mode} project-supervisor`, - ]); - const runtime = agentRuntimeStateFromResult( - runtimeResult, - projectSupervisorRuntimeRef.current, - ); - if ( - localProjectPathRef.current !== nextProjectPath || - projectSupervisorSessionIdRef.current !== sessionId - ) { - return; - } - if ( - runtime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || - runtime.sessionId !== sessionId - ) { - throw new Error('项目总控 Agent Runtime Session 身份不匹配'); - } - const acceptedRuntimeReady = runtime.runId === acceptedRunId; - if (!acceptedRuntimeReady) { - projectSupervisorExpectedRunIdRef.current = acceptedRunId; - setProjectSupervisorExpectedRunId(acceptedRunId); - } - if (acceptedRuntimeReady) { - projectSupervisorExpectedRunIdRef.current = null; - setProjectSupervisorExpectedRunId(null); - updateProjectSupervisorRuntime(runtime); - updateProjectSupervisorResponseStream( - runtimeResult.responseStream, - runtime, - ); - } - setProjectSupervisorRuntimeError(''); - const refreshConversation = - projectSupervisorRefreshConversationRef.current; - if (refreshConversation) { - void refreshConversation(invoke, nextProjectPath, sessionId).catch( - (error) => { - if ( - localProjectPathRef.current === nextProjectPath && - projectSupervisorSessionIdRef.current === sessionId - ) { - setProjectSupervisorRuntimeError( - `项目总控 Agent 对话刷新失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }, - ); - } - if (acceptedRuntimeReady) { - syncTerminalProjectSupervisorConversation( - invoke, - nextProjectPath, - runtime, - ); - } - } catch (error) { - if (localProjectPathRef.current !== nextProjectPath) { - return; - } - void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID); - const message = error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { - requestRuntimeConfigOpen(); - } - setProjectSupervisorRuntimeError(message); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: message, - runtimeOwned: true, - updatedAt: Date.now(), - }, - ]); - } finally { - setChatAgentBusy(false); - } + await executeDesignAgentTurn( + nextProjectPath, + { type: 'message', text: prompt }, + directConversationTurnId ?? createAgentChatRunId('design-agent-turn'), + ); } executeChatAgentReplyRef.current = executeChatAgentReply; useEffect(() => { - const latch = initialSupervisorMessageLatchRef.current; - if ( - (!directCodexProductRuntime && !planningStartMode) || - !latch.prompt || - !localProject - ) { + const latch = initialPlanningPromptLatchRef.current; + if (!planningStartMode || !latch.prompt || !localProject) { return; } if (localProject.projectPath !== latch.projectPath) { @@ -6379,898 +1684,36 @@ export function App({ } if ( chatAgentBusy || - !claimInitialSupervisorMessageForPage(latch.projectPath, latch.claimScope) + !claimInitialTurnForPage(latch.projectPath, latch.claimScope) ) { return; } - supervisorChatShouldFollowLatestRef.current = true; - const directConversationTurnId = directCodexProductRuntime - ? createDirectCodexConversationTurnId() - : undefined; + planningChatShouldFollowLatestRef.current = true; setMessages((current) => [ ...current, { role: 'user', text: latch.prompt, runtimeOwned: true, - ...(directConversationTurnId - ? { - messageId: directCodexConversationMessageId( - directConversationTurnId, - 'user', - ), - } - : {}), updatedAt: Date.now(), }, ]); - void executeChatAgentReplyRef.current({ - prompt: latch.prompt, - clientTurnId: directConversationTurnId, - creationType: latch.creationType, - attachments: latch.attachments, - }); + // DirectProject 的首轮需求由聊天容器自己认领并发送(见 `DirectProjectChat`)。 + void executeChatAgentReplyRef.current({ prompt: latch.prompt }); + // 初始消息 latch 只由入口状态驱动;controller 内部函数保持稳定语义。 + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chatAgentBusy, - directCodexProductRuntime, initialCreationType, - initialSupervisorMessage, + initialPlanningPrompt, localProject, planningStartMode, ]); - async function handleProjectSupervisorToolAction( - decision: 'confirm' | 'reject', - ) { - const nextProjectPath = resolveChatProjectPath(localProject); - const runtime = projectSupervisorRuntimeRef.current; - const pendingAction = runtime?.pendingToolAction; - const sessionId = projectSupervisorSessionIdRef.current; - const invoke = resolveTauriInvoke(); - if ( - !invoke || - !nextProjectPath || - !runtime || - !pendingAction?.actionId || - !sessionId || - chatAgentBusy - ) { - return; - } - setChatAgentBusy(true); - setProjectSupervisorRuntimeError(''); - try { - const command = - decision === 'confirm' - ? 'confirm_game_creator_agent_runtime_task' - : 'reject_game_creator_agent_runtime_task'; - const result = await invoke(command, { - projectPath: nextProjectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - runId: runtime.runId, - actionId: pendingAction.actionId, - note: - decision === 'confirm' - ? '用户已确认待执行工具动作' - : '用户已拒绝待执行工具动作', - }); - const nextRuntime = agentRuntimeStateFromResult( - result, - projectSupervisorRuntimeRef.current, - ); - if ( - localProjectPathRef.current !== nextProjectPath || - projectSupervisorSessionIdRef.current !== sessionId || - nextRuntime.sessionId !== sessionId || - nextRuntime.runId !== runtime.runId - ) { - return; - } - updateProjectSupervisorRuntime(nextRuntime); - updateProjectSupervisorResponseStream(result.responseStream, nextRuntime); - setCommandLog((current) => [ - ...current, - `agent.runtime.${decision} project-supervisor`, - ]); - syncTerminalProjectSupervisorConversation( - invoke, - nextProjectPath, - nextRuntime, - ); - } catch (error) { - if ( - localProjectPathRef.current !== nextProjectPath || - projectSupervisorSessionIdRef.current !== sessionId - ) { - return; - } - const message = error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { - requestRuntimeConfigOpen(); - } - setProjectSupervisorRuntimeError(message); - } finally { - setChatAgentBusy(false); - } - } - - async function handleProjectProfessionalAgentToolAction( - runtime: AgentRuntimeState, - decision: 'confirm' | 'reject', - ) { - const nextProjectPath = resolveChatProjectPath(localProject); - const currentRuntime = agentRuntimeById[runtime.agentId]; - const pendingAction = currentRuntime?.pendingToolAction; - const supervisorRunId = projectSupervisorRuntimeRef.current?.runId; - const invoke = resolveTauriInvoke(); - if ( - !invoke || - !nextProjectPath || - !currentRuntime || - !pendingAction?.actionId || - chatAgentBusy - ) { - throw new Error('专业 Agent 待确认动作已变化,请等待状态刷新'); - } - if ( - currentRuntime.runId !== runtime.runId || - pendingAction.actionId !== runtime.pendingToolAction?.actionId || - currentRuntime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || - currentRuntime.parentRunId !== supervisorRunId - ) { - throw new Error('专业 Agent Runtime 身份已变化,请等待状态刷新'); - } - setChatAgentBusy(true); - try { - const command = - decision === 'confirm' - ? 'confirm_game_creator_agent_runtime_task' - : 'reject_game_creator_agent_runtime_task'; - const result = await invoke(command, { - projectPath: nextProjectPath, - agentId: currentRuntime.agentId, - runId: currentRuntime.runId, - actionId: pendingAction.actionId, - note: - decision === 'confirm' - ? '用户已确认专业 Agent 待执行工具动作' - : '用户已拒绝专业 Agent 待执行工具动作', - }); - if ( - localProjectPathRef.current !== nextProjectPath || - projectSupervisorRuntimeRef.current?.runId !== supervisorRunId - ) { - return; - } - const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime); - if ( - nextRuntime.agentId !== currentRuntime.agentId || - nextRuntime.runId !== currentRuntime.runId - ) { - throw new Error('专业 Agent 操作后 Runtime 身份不匹配'); - } - rememberAgentRuntimeState(nextRuntime); - setCommandLog((current) => [ - ...current, - `agent.runtime.${decision} ${currentRuntime.agentId}`, - ]); - } catch (error) { - throw new Error( - `专业 Agent 操作失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } finally { - setChatAgentBusy(false); - } - } - - async function handleProjectProfessionalAgentRetry( - runtime: AgentRuntimeState, - ): Promise { - const nextProjectPath = resolveChatProjectPath(localProject); - const currentRuntime = agentRuntimeById[runtime.agentId]; - const supervisorRunId = projectSupervisorRuntimeRef.current?.runId; - const invoke = resolveTauriInvoke(); - if (!invoke || !nextProjectPath || !currentRuntime || chatAgentBusy) { - throw new Error('专业 Agent 状态已变化,请等待刷新后重试'); - } - const supervisorRuntime = projectSupervisorRuntimeRef.current; - if (!supervisorRuntime || isAgentRuntimeTerminalState(supervisorRuntime)) { - throw new Error('项目总控已停止,请先重试项目总控'); - } - if ( - currentRuntime.runId !== runtime.runId || - currentRuntime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || - currentRuntime.parentRunId !== supervisorRunId || - !( - currentRuntime.status === 'failed' || currentRuntime.phase === 'failed' - ) || - currentRuntime.pendingToolAction - ) { - throw new Error('专业 Agent 当前状态不允许直接重试'); - } - if ( - projectSupervisorPendingRepairMatchesProfessional( - projectSupervisorRuntimeRef.current?.pendingToolAction, - currentRuntime, - ) - ) { - await handleProjectSupervisorToolAction('confirm'); - return `已确认项目总控安排${projectProfessionalAgentLabel( - currentRuntime.agentId, - )}返工,正在同步新一轮状态`; - } - setChatAgentBusy(true); - try { - const result = await invoke( - 'confirm_retry_game_creator_agent_runtime_task', - { - projectPath: nextProjectPath, - agentId: currentRuntime.agentId, - runId: currentRuntime.runId, - nextRunId: createAgentChatRunId('project-professional-retry'), - }, - ); - if ( - localProjectPathRef.current !== nextProjectPath || - projectSupervisorRuntimeRef.current?.runId !== supervisorRunId - ) { - return '项目已切换,未把旧项目的重试状态合并到当前界面'; - } - const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime); - if ( - nextRuntime.agentId !== currentRuntime.agentId || - nextRuntime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || - nextRuntime.parentRunId !== supervisorRunId - ) { - throw new Error('专业 Agent 重试后的 Runtime 身份不匹配'); - } - rememberAgentRuntimeState(nextRuntime); - setCommandLog((current) => [ - ...current, - `agent.runtime.retry ${currentRuntime.agentId}`, - ]); - return '重试请求已受理,正在同步新一轮状态'; - } catch (error) { - throw new Error( - `专业 Agent 重试失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } finally { - setChatAgentBusy(false); - } - } - - async function handleProjectSupervisorRetry( - runtime: AgentRuntimeState, - ): Promise { - const nextProjectPath = resolveChatProjectPath(localProject); - const currentRuntime = projectSupervisorRuntimeRef.current; - const invoke = resolveTauriInvoke(); - if (!invoke || !nextProjectPath || !currentRuntime || chatAgentBusy) { - throw new Error('项目总控状态已变化,请等待刷新后重试'); - } - const needsReconciliation = - currentRuntime.status === 'needs-reconciliation' || - currentRuntime.phase === 'needs-reconciliation'; - if (needsReconciliation) { - if ( - currentRuntime.runId !== runtime.runId || - currentRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID - ) { - throw new Error('项目总控待核对任务已变化,请等待刷新'); - } - const previousRunId = currentRuntime.runId; - setChatAgentBusy(true); - setProjectSupervisorRuntimeError(''); - try { - const result = await invoke( - 'cancel_game_creator_agent_runtime_task', - { - projectPath: nextProjectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - runId: previousRunId, - }, - ); - if ( - localProjectPathRef.current !== nextProjectPath || - projectSupervisorRuntimeRef.current?.runId !== previousRunId - ) { - return '项目已切换,未把旧项目的取消状态合并到当前界面'; - } - const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime); - if ( - nextRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || - nextRuntime.runId !== previousRunId || - nextRuntime.sessionId !== currentRuntime.sessionId - ) { - throw new Error('项目总控取消后的 Runtime 身份不匹配'); - } - updateProjectSupervisorRuntime(nextRuntime); - updateProjectSupervisorResponseStream( - result.responseStream, - nextRuntime, - ); - setCommandLog((current) => [ - ...current, - 'agent.runtime.cancel project-supervisor reconciliation', - ]); - const queuePending = nextRuntime.taskQueue?.pending ?? 0; - if ( - nextRuntime.status === 'cancelled' || - nextRuntime.phase === 'cancelled' - ) { - return queuePending > 0 - ? `旧任务已结束,队列中还有 ${queuePending} 个待处理任务,队列将继续处理` - : '旧任务已结束,当前队列为空,可重新启动项目总控'; - } - return '已提交结束旧任务请求,正在同步取消状态'; - } catch (error) { - throw new Error( - `项目总控旧任务结束失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } finally { - setChatAgentBusy(false); - } - } - const cancelledWithEmptyQueue = - (currentRuntime.status === 'cancelled' || - currentRuntime.phase === 'cancelled') && - (currentRuntime.taskQueue?.pending ?? 0) === 0; - if ( - currentRuntime.runId !== runtime.runId || - currentRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || - !( - currentRuntime.status === 'failed' || - currentRuntime.phase === 'failed' || - cancelledWithEmptyQueue - ) || - currentRuntime.pendingToolAction - ) { - throw new Error('项目总控当前状态不允许重试'); - } - const previousRunId = currentRuntime.runId; - const nextRunId = createAgentChatRunId('project-supervisor-retry'); - setChatAgentBusy(true); - setProjectSupervisorRuntimeError(''); - try { - const result = await invoke( - 'confirm_retry_game_creator_agent_runtime_task', - { - projectPath: nextProjectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - runId: previousRunId, - nextRunId, - }, - ); - if ( - localProjectPathRef.current !== nextProjectPath || - projectSupervisorRuntimeRef.current?.runId !== previousRunId - ) { - return '项目已切换,未把旧项目的重试状态合并到当前界面'; - } - const inferredRunId = agentRuntimeStartedRunId(result, nextRunId); - const acceptedRunId = - result.acceptedRunId?.trim() || - (inferredRunId === nextRunId ? nextRunId : ''); - if (!acceptedRunId) { - throw new Error('项目总控重试请求未进入后台队列'); - } - projectSupervisorExpectedRunIdRef.current = acceptedRunId; - setProjectSupervisorExpectedRunId(acceptedRunId); - const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime); - if (nextRuntime.runId === acceptedRunId) { - if ( - nextRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || - nextRuntime.sessionId !== currentRuntime.sessionId - ) { - throw new Error('项目总控重试后的 Runtime 身份不匹配'); - } - updateProjectSupervisorRuntime(nextRuntime); - updateProjectSupervisorResponseStream( - result.responseStream, - nextRuntime, - ); - projectSupervisorExpectedRunIdRef.current = null; - setProjectSupervisorExpectedRunId(null); - } - setCommandLog((current) => [ - ...current, - 'agent.runtime.retry project-supervisor', - ]); - return nextRuntime.runId === acceptedRunId - ? '已在当前项目重新启动项目总控' - : '重试已受理,正在同步新一轮项目总控状态'; - } catch (error) { - throw new Error( - `项目总控重试失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } finally { - setChatAgentBusy(false); - } - } - - async function handleProjectSupervisorUserInput( - request: AgentRuntimeUserInputRequest, - responseId: string, - answers: Record, - ) { - const nextProjectPath = resolveChatProjectPath(localProject); - const runtime = projectSupervisorRuntimeRef.current; - const sessionId = projectSupervisorSessionIdRef.current; - const currentRequest = runtime?.userInputRequest; - const invoke = resolveTauriInvoke(); - if ( - !invoke || - !nextProjectPath || - !runtime || - !sessionId || - !responseId || - chatAgentBusy - ) { - return; - } - if ( - request.agentId !== PROJECT_SUPERVISOR_AGENT_ID || - request.sessionId !== sessionId || - request.runId !== runtime.runId || - currentRequest?.requestId !== request.requestId || - currentRequest?.actionId !== request.actionId - ) { - setProjectSupervisorRuntimeError('待回答请求已变更,请刷新 Runtime 状态'); - return; - } - setChatAgentBusy(true); - setProjectSupervisorRuntimeError(''); - try { - const result = await invoke( - 'answer_game_creator_agent_runtime_user_input', - { - projectPath: nextProjectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - runId: request.runId, - actionId: request.actionId, - requestId: request.requestId, - responseId, - answers, - }, - ); - const nextRuntime = agentRuntimeStateFromResult( - result, - projectSupervisorRuntimeRef.current, - ); - if ( - localProjectPathRef.current !== nextProjectPath || - projectSupervisorSessionIdRef.current !== sessionId - ) { - return; - } - if ( - nextRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || - nextRuntime.sessionId !== sessionId || - nextRuntime.runId !== request.runId - ) { - throw new Error('项目总控 Agent 回答后 Runtime 身份不匹配'); - } - updateProjectSupervisorRuntime(nextRuntime); - updateProjectSupervisorResponseStream(result.responseStream, nextRuntime); - const refreshConversation = - projectSupervisorRefreshConversationRef.current; - if (refreshConversation) { - await refreshConversation(invoke, nextProjectPath, sessionId); - } - setCommandLog((current) => [ - ...current, - 'agent.runtime.user_input.answered project-supervisor', - ]); - setProjectSupervisorRuntimeError(''); - syncTerminalProjectSupervisorConversation( - invoke, - nextProjectPath, - nextRuntime, - ); - } catch (error) { - if ( - localProjectPathRef.current !== nextProjectPath || - projectSupervisorSessionIdRef.current !== sessionId - ) { - return; - } - const message = error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { - requestRuntimeConfigOpen(); - } - setProjectSupervisorRuntimeError(`回答提交失败:${message}`); - } finally { - setChatAgentBusy(false); - } - } - - async function executeGameDraft(prompt: string) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - - try { - setCommandLog((current) => [...current, 'game.generate_draft']); - setProjectStatus('正在调用 LLM'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: gameDraftStartedMessage() }, - ]); - const result = await invoke( - 'generate_local_game_draft', - { projectPath: nextProjectPath, prompt }, - ); - const generatedProjectPath = result.projectPath.trim(); - if ( - !generatedProjectPath || - !isAbsoluteProjectPath(generatedProjectPath) || - projectPathHasControlCharacter(generatedProjectPath) - ) { - const message = '生成结果项目路径无效'; - setProjectStatus(message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - return; - } - setLocalProject({ - projectPath: generatedProjectPath, - manifestPath: `${generatedProjectPath}/.agent/manifest.json`, - manifest: result.manifest, - }); - setManifest(result.manifest); - setProjectStatus('已生成可试玩原型'); - setCommandLog((current) => [ - ...current, - 'memory.write', - 'file.write game/index.html', - ]); - const completionSummary = - await refreshAgentRunTrace(generatedProjectPath); - - try { - const previewResult = await invoke( - 'start_local_game_preview', - { projectPath: generatedProjectPath }, - ); - updateClientPreview(previewResult); - setPreviewStatus(`运行中:127.0.0.1:${previewResult.port}`); - setCommandLog((current) => [...current, 'preview.start']); - void refreshManifest(generatedProjectPath); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: [ - `已保存并在客户端运行视图启动预览:${previewResult.url}`, - completionSummary?.text, - ] - .filter(Boolean) - .join('\n\n'), - draftCommand: completionSummary?.draftCommand, - }, - ]); - } catch (previewError) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: [ - `已保存草案:${result.designPath}。预览启动失败:${ - previewError instanceof Error - ? previewError.message - : String(previewError) - }`, - completionSummary?.text, - ] - .filter(Boolean) - .join('\n\n'), - draftCommand: completionSummary?.draftCommand, - }, - ]); - } - } catch (error) { - void refreshAgentRunTrace(nextProjectPath); - const message = error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { - requestRuntimeConfigOpen(); - } - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: message, - }, - ]); - } - } - - function markPendingCommandCanceled(command: PendingCommand) { - if (command.id === 'project.create') { - setWorkspaceStatus('已取消'); - setProjectStatus('已取消'); - return; - } - if (command.id === 'game.generate_draft') { - setProjectStatus('已取消生成游戏草案'); - return; - } - if ( - command.id === 'game.run_local' || - command.id === 'command.run_limited' - ) { - setLimitedCommandStatus('已取消运行'); - return; - } - if (command.id === 'project.checkpoint') { - setFileStatus('已取消保存 checkpoint'); - return; - } - if (command.id === 'project.export_package') { - setFileStatus('已取消导出本地试玩包'); - return; - } - if (command.id === 'project.index') { - setFileStatus('已取消索引项目'); - setWorkspaceStatus('已取消索引项目'); - return; - } - if (command.id === 'project.restore') { - setFileStatus('已取消回滚项目'); - return; - } - if (command.id === 'project.policy_write') { - setProjectStatus('已取消修改权限策略'); - return; - } - if (command.id === 'preview.start' || command.id === 'preview.open') { - setPreviewStatus('已取消预览操作'); - setWorkspaceStatus('已取消预览操作'); - return; - } - if ( - command.id === 'agent.kill' || - command.id === 'agent.retry' || - command.id === 'agent.resume' - ) { - setAgentRunStatus('已取消 Agent run 操作'); - return; - } - if (command.id === 'memory.write') { - setMemoryStatus('已取消写入项目记忆'); - return; - } - if (command.id === 'memory.delete') { - setMemoryStatus('已取消删除项目记忆'); - return; - } - if (command.id === 'asset.upload') { - setAssetStatus('已取消上传资产'); - return; - } - if (command.id === 'asset.register') { - setAssetStatus('已取消登记资产'); - return; - } - if (command.id === 'canvas.project_open') { - setAssetStatus('已取消打开画板'); - return; - } - if (command.id === 'canvas.project_sync') { - setAssetStatus('已取消同步画板项目'); - return; - } - if (command.id === 'canvas.asset_import') { - setAssetStatus('已取消导入画板资产'); - return; - } - if (command.id === 'canvas.asset_generate') { - setAssetStatus('已取消生成美术素材'); - return; - } - if (command.id === 'canvas.export_import') { - setAssetStatus('已取消导入画板导出包'); - } - } - - function handlePendingCommandCancel() { - const command = pendingCommand; - setPendingCommand(null); - if (command) { - markPendingCommandCanceled(command); - appendLocalPermissionLog( - resolvePermissionLogProjectPath(command), - 'permission.cancel', - command.id, - ); - setCommandLog((current) => [ - ...current, - `permission.cancel ${command.id}`, - ]); - } - setMessages((current) => [ - ...current, - { role: 'assistant', text: '已取消。' }, - ]); - } - - async function handlePendingCommandConfirm() { - const command = pendingCommand; - if (!command) { - return; - } - if ( - needsInitializedChatProject(command.id) && - !resolveChatProjectPath(localProject) - ) { - setPendingCommand(null); - appendLocalPermissionLog( - resolvePermissionLogProjectPath(command), - 'permission.cancel', - command.id, - ); - setCommandLog((current) => [ - ...current, - `permission.cancel ${command.id} missing-project`, - ]); - requireChatProjectForUserAction(); - return; - } - if ( - await denyPendingCommandIfNeeded( - command.id, - resolvePermissionLogProjectPath(command), - ) - ) { - setPendingCommand(null); - return; - } - setPendingCommand(null); - appendLocalPermissionLog( - resolvePermissionLogProjectPath(command), - 'permission.confirm', - command.id, - ); - setCommandLog((current) => [ - ...current, - `permission.confirm ${command.id}`, - ]); - if (command.id === 'asset.upload') { - void executeAssetUpload(command.file); - } else if (command.id === 'asset.register') { - void executeAssetRegister( - resolveChatProjectPath(localProject) ?? '', - command.localPath, - command.kind, - command.mediaType, - 'generated', - '', - '', - '', - true, - ); - } else if (command.id === 'game.run_local') { - void executeRunLocal(true); - } else if (command.id === 'command.run_limited') { - void executeLimitedCommand(command.commandId, true); - } else if (command.id === 'project.create') { - void executeProjectCreate(command.projectPath, true); - } else if (command.id === 'project.checkpoint') { - void executeProjectCheckpoint(true); - } else if (command.id === 'project.export_package') { - void executeProjectExportPackage(true); - } else if (command.id === 'project.index') { - void executeProjectIndex(true); - } else if (command.id === 'project.restore') { - void executeProjectRestore(command.checkpointId, true); - } else if (command.id === 'project.policy_write') { - void executeProjectPolicyWrite(command.policy, true); - } else if (command.id === 'preview.start') { - void executePreviewStart(true); - } else if (command.id === 'preview.open') { - void executePreviewOpen(true); - } else if ( - command.id === 'agent.kill' || - command.id === 'agent.retry' || - command.id === 'agent.resume' - ) { - void executeAgentRunControl( - command.id.slice('agent.'.length), - command.detail, - true, - ); - } else if (command.id === 'memory.write') { - void executeMemoryWrite( - command.scope, - command.content, - command.mode ?? 'append', - ); - } else if (command.id === 'memory.delete') { - void executeMemoryDeleteChat(command.scope); - } else if (command.id === 'canvas.project_open') { - void executeCanvasProjectOpen(command.canvasProjectId, true); - } else if (command.id === 'canvas.project_sync') { - void executeCanvasProjectSync(command.canvasProjectId, true); - } else if (command.id === 'canvas.asset_import') { - void executeCanvasAssetImport( - null, - command.localPath, - command.kind, - command.mediaType, - command.canvasProjectId, - command.canvasAssetId, - command.canvasAssetObjectId ?? '', - true, - ); - } else if (command.id === 'canvas.asset_generate') { - void executeCanvasAssetGenerate(command.prompt, true); - } else if (command.id === 'canvas.export_import') { - void executeCanvasExportImport( - command.exportPath, - command.canvasProjectId, - true, - ); - } else if (command.id === 'game.generate_draft') { - void executeGameDraft(command.prompt); - } - } - - async function executeProjectCreate( - nextProjectPath: string, - announceToChat: boolean, - ) { - const trimmedProjectPath = nextProjectPath.trim(); - const invoke = resolveTauriInvoke(); - if ( - invoke && - trimmedProjectPath && - isAbsoluteProjectPath(trimmedProjectPath) && - !projectPathHasControlCharacter(trimmedProjectPath) - ) { - try { - const nonEmpty = await invoke( - 'is_local_project_directory_non_empty', - { projectPath: trimmedProjectPath }, - ); - if (nonEmpty) { - setPendingNonEmptyProjectCreate({ - projectPath: trimmedProjectPath, - announceToChat, - }); - setWorkspaceStatus('目标文件夹不是空的'); - setProjectStatus('等待确认'); - return; - } - } catch { - // ponytail: stale test doubles and older shells may miss this helper; init still validates. - } - } - await openWorkspace(nextProjectPath, announceToChat); - } - function cancelProjectCreateInNonEmptyFolder() { const pendingCreate = pendingNonEmptyProjectCreate; setPendingNonEmptyProjectCreate(null); setWorkspaceStatus('已取消'); - setProjectStatus('已取消'); if (pendingCreate?.announceToChat) { setMessages((current) => [ ...current, @@ -7282,3311 +1725,100 @@ export function App({ } } - function confirmProjectCreateInNonEmptyFolder() { - const pendingCreate = pendingNonEmptyProjectCreate; - if (!pendingCreate) { - return; - } - setPendingNonEmptyProjectCreate(null); - void openWorkspace(pendingCreate.projectPath, pendingCreate.announceToChat); - } - - async function executeProjectStatus( - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setProjectStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setProjectStatus('请先初始化本地项目'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); - } - return; - } - + /** + * 预览已经在跑时先切视图,不重启预览服务。 + * + * `activate_local_game_preview` 是 Rust 侧的「这条预览还活着、且属于这个项目」闸门: + * 它只核对内存 registry 里的状态并回传可用的 loopback 地址,前端据此进客户端运行视图。 + * 同一个动作过去由工作台壳的 `/preview open` 聊天命令承担,那条命令链随 Supervisor + * 前端链路一起退役,行为改由「运行」入口承接,结果经 DirectProject 聊天的 `announce` + * 交给聊天自己的消息流。返回 `null` 表示没有可复用的活体预览,调用方照旧重启预览。 + */ + async function activateRunningPreview( + invoke: TauriInvoke, + nextProjectPath: string, + ): Promise { try { - if ( - announceToChat && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'project.status', - nextProjectPath, - `读取 ${nextProjectPath} 的项目状态`, - '准备读取项目状态。', - () => void executeProjectStatus(true, true), - )) - ) { - return; - } - const nextManifest = await invoke( - 'get_local_game_manifest', - { projectPath: nextProjectPath, commandId: 'project.status' }, - ); - setManifest(nextManifest); - setProjectStatus('已读取状态'); - setCommandLog((current) => [...current, 'project.status']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeProjectStatus(nextManifest, nextProjectPath), - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setProjectStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeProjectIndex(announceToChat: boolean) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setLimitedCommandStatus('请先初始化本地项目'); - return; - } - - try { - const result = await invoke( - 'build_local_project_index', - { projectPath: nextProjectPath }, - ); - setFileStatus(`已索引 ${result.fileCount} 个文件`); - setCommandLog((current) => [...current, 'project.index']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeProjectIndex(result) }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setFileStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function queueOrExecuteProjectIndex() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - void executeProjectIndex(true); - return; - } - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - - try { - const result = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - if (result.policy.confirmCommands.includes('project.index')) { - queuePendingCommand({ id: 'project.index' }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '准备刷新本地项目索引。' }, - ]); - return; - } - void executeProjectIndex(true); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - - async function executeProjectCheckpoint(announceToChat: boolean) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : projectPath; - if (!nextProjectPath) { - return; - } - - try { - const result = await invoke( - 'create_local_project_checkpoint', - { projectPath: nextProjectPath }, - ); - setFileStatus(`已保存 checkpoint:${result.checkpointId}`); - setCommandLog((current) => [...current, 'project.checkpoint']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeProjectCheckpoint(result) }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setFileStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeProjectExportPackage(announceToChat: boolean) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : projectPath; - if (!nextProjectPath) { - return; - } - - try { - const result = await invoke( - 'export_local_project_package', - { projectPath: nextProjectPath }, - ); - setFileStatus(`已导出本地试玩包:${result.packageRelativePath}`); - setCommandLog((current) => [...current, 'project.export_package']); - void refreshManifest(nextProjectPath); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeProjectExportPackage(result), - draftCommand: '/open-project', - draftCommandLabel: '显示目录', - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setFileStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeProjectExportPackages( - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : resolveChatProjectPath(localProject); - if (!nextProjectPath) { - return; - } - - try { - if ( - announceToChat && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'project.export_list', - nextProjectPath, - `列出 ${nextProjectPath} 的本地试玩包`, - '准备列出本地试玩包。', - () => void executeProjectExportPackages(true, true), - )) - ) { - return; - } - const result = await invoke( - 'list_local_project_export_packages', - { projectPath: nextProjectPath }, - ); - setFileStatus(`已列出 ${result.packages.length} 个本地试玩包`); - setCommandLog((current) => [...current, 'project.export_list']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeProjectExportPackages(result), - draftCommand: - result.packages.length > 0 ? '/open-project' : '/export', - draftCommandLabel: - result.packages.length > 0 ? '显示目录' : '导出试玩包', - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setFileStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeProjectCheckpoints( - announceToChat: boolean, - skipListPolicyConfirm = false, - skipReadPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : resolveChatProjectPath(localProject); - if (!nextProjectPath) { - return; - } - - try { - if ( - announceToChat && - !skipListPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'file.list', - nextProjectPath, - `列出 ${nextProjectPath} 的 checkpoint`, - '准备列出项目 checkpoint。', - () => void executeProjectCheckpoints(true, true, false), - )) - ) { - return; - } - const result = await invoke( - 'list_local_project_files', - { projectPath: nextProjectPath }, - ); - const manifestFiles = sortCheckpointManifestFiles(result.files); - const visibleFiles = manifestFiles.slice(0, 5); - if ( - announceToChat && - visibleFiles.length > 0 && - !skipReadPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'file.read', - nextProjectPath, - `读取 ${nextProjectPath} 的 checkpoint manifest`, - '准备读取 checkpoint manifest。', - () => void executeProjectCheckpoints(true, true, true), - )) - ) { - return; - } - const checkpoints: LocalProjectCheckpointSummary[] = []; - for (const file of visibleFiles) { - const manifest = await invoke( - 'read_local_project_file', - { - projectPath: nextProjectPath, - relativePath: file.path, - commandId: 'file.read', - }, - ); - checkpoints.push(checkpointSummaryFromManifest(file, manifest.content)); - } - setProjectFiles(result.files); - setProjectCheckpoints(checkpoints); - setFileStatus(`已列出 ${manifestFiles.length} 个 checkpoint`); - setCommandLog((current) => [ - ...current, - 'file.list', - ...(visibleFiles.length > 0 ? ['file.read checkpoint manifests'] : []), - ]); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeProjectCheckpoints( - checkpoints, - manifestFiles.length - visibleFiles.length, - ), - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setFileStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeProjectDiff( - checkpointId: string, - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : projectPath; - if (!nextProjectPath) { - return; - } - - try { - if ( - announceToChat && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'project.diff', - nextProjectPath, - `对比 ${nextProjectPath} 的 checkpoint:${checkpointId}`, - '准备对比项目 checkpoint。', - () => void executeProjectDiff(checkpointId, true, true), - )) - ) { - return; - } - const result = await invoke( - 'diff_local_project_checkpoint', - { projectPath: nextProjectPath, checkpointId }, - ); - setFileStatus(`已对比 checkpoint:${result.checkpointId}`); - setCommandLog((current) => [...current, 'project.diff']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeProjectDiff(result) }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setFileStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeProjectRestore( - checkpointId: string, - announceToChat: boolean, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : projectPath; - if (!nextProjectPath) { - return; - } - - try { - const result = await invoke( - 'restore_local_project_checkpoint', - { projectPath: nextProjectPath, checkpointId }, - ); - setFileStatus( - `已回滚 ${result.restoredCount} 个文件,删除 ${result.deletedCount} 个新增文件`, - ); - setCommandLog((current) => [...current, 'project.restore']); - void refreshManifest(nextProjectPath); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `已回滚 ${result.restoredCount} 个文件到 ${nextProjectPath},删除 ${result.deletedCount} 个新增文件:${result.checkpointId}`, - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setFileStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeProjectPolicyRead(announceToChat: boolean) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setProjectStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : projectPath; - if (!nextProjectPath) { - return; - } - - try { - const result = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - setProjectStatus('已读取权限策略'); - setCommandLog((current) => [...current, 'project.policy_read']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeProjectPolicy(result) }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setProjectStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function queueProjectPolicyMutation( - commandId: string, - mode: 'deny' | 'allow' | 'confirm' | 'auto', - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - - try { - const result = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - const alreadyDenied = result.policy.deniedCommands.includes(commandId); - const alreadyConfirmed = - result.policy.confirmCommands.includes(commandId); - if (mode === 'deny' && alreadyDenied) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: `命令已在拒绝列表中:${commandId}` }, - ]); - return; - } - if (mode === 'allow' && !alreadyDenied) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: `命令不在拒绝列表中:${commandId}` }, - ]); - return; - } - if (mode === 'confirm' && alreadyConfirmed) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: `命令已在确认列表中:${commandId}` }, - ]); - return; - } - if (mode === 'auto' && !alreadyConfirmed) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: `命令不在确认列表中:${commandId}` }, - ]); - return; - } - const deniedCommands = - mode === 'deny' - ? [...new Set([...result.policy.deniedCommands, commandId])] - : mode === 'allow' - ? result.policy.deniedCommands.filter( - (value) => value !== commandId, - ) - : mode === 'confirm' - ? result.policy.deniedCommands.filter( - (value) => value !== commandId, - ) - : result.policy.deniedCommands; - const confirmCommands = - mode === 'confirm' - ? [...new Set([...result.policy.confirmCommands, commandId])] - : mode === 'deny' - ? result.policy.confirmCommands.filter( - (value) => value !== commandId, - ) - : mode === 'auto' - ? result.policy.confirmCommands.filter( - (value) => value !== commandId, - ) - : result.policy.confirmCommands; - queuePendingCommand({ - id: 'project.policy_write', - policy: { ...result.policy, deniedCommands, confirmCommands }, - }); - const action = - mode === 'deny' - ? '拒绝' - : mode === 'allow' - ? '允许' - : mode === 'confirm' - ? '确认' - : '自动执行'; - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `准备${action}命令:${commandId}`, - }, - ]); - } catch (error) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: error instanceof Error ? error.message : String(error), - }, - ]); - } - } - - async function queueAgentPolicyMutation( - agentId: string, - commandId: string, - mode: 'deny' | 'allow' | 'confirm' | 'auto', - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - - try { - const result = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - const agentPolicies = { ...(result.policy.agentPolicies ?? {}) }; - const agentPolicy = agentPolicies[agentId] ?? { - deniedCommands: [], - confirmCommands: [], - }; - const alreadyDenied = agentPolicy.deniedCommands.includes(commandId); - const alreadyConfirmed = agentPolicy.confirmCommands.includes(commandId); - if (mode === 'deny' && alreadyDenied) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `Agent ${agentId} 的命令已在拒绝列表中:${commandId}`, - }, - ]); - return; - } - if (mode === 'allow' && !alreadyDenied) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `Agent ${agentId} 的命令不在拒绝列表中:${commandId}`, - }, - ]); - return; - } - if (mode === 'confirm' && alreadyConfirmed) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `Agent ${agentId} 的命令已在确认列表中:${commandId}`, - }, - ]); - return; - } - if (mode === 'auto' && !alreadyConfirmed) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `Agent ${agentId} 的命令不在确认列表中:${commandId}`, - }, - ]); - return; - } - const deniedCommands = - mode === 'deny' - ? [...new Set([...agentPolicy.deniedCommands, commandId])] - : mode === 'allow' - ? agentPolicy.deniedCommands.filter((value) => value !== commandId) - : mode === 'confirm' - ? agentPolicy.deniedCommands.filter( - (value) => value !== commandId, - ) - : agentPolicy.deniedCommands; - const confirmCommands = - mode === 'confirm' - ? [...new Set([...agentPolicy.confirmCommands, commandId])] - : mode === 'deny' - ? agentPolicy.confirmCommands.filter((value) => value !== commandId) - : mode === 'auto' - ? agentPolicy.confirmCommands.filter( - (value) => value !== commandId, - ) - : agentPolicy.confirmCommands; - agentPolicies[agentId] = { - deniedCommands, - confirmCommands, - }; - queuePendingCommand({ - id: 'project.policy_write', - policy: { - ...result.policy, - agentPolicies, - }, - }); - const action = - mode === 'deny' - ? '拒绝' - : mode === 'allow' - ? '允许' - : mode === 'confirm' - ? '确认' - : '自动执行'; - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `准备${action} Agent ${agentId} 命令:${commandId}`, - }, - ]); - } catch (error) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: error instanceof Error ? error.message : String(error), - }, - ]); - } - } - - async function executeProjectPolicyWrite( - policy: ProjectPermissionPolicy, - announceToChat: boolean, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setProjectStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : projectPath; - if (!nextProjectPath) { - return; - } - - try { - const result = await invoke( - 'write_project_permission_policy', - { projectPath: nextProjectPath, policy }, - ); - setProjectStatus('已写入权限策略'); - setCommandLog((current) => [...current, 'project.policy_write']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeProjectPolicy(result) }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setProjectStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeProjectFiles( - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setFileStatus('请先初始化本地项目'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); - } - return; - } - - try { - if ( - announceToChat && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'file.list', - nextProjectPath, - `列出 ${nextProjectPath} 的项目文件`, - '准备列出项目文件。', - () => void executeProjectFiles(true, true), - )) - ) { - return; - } - const result = await invoke( - 'list_local_project_files', - { projectPath: nextProjectPath }, - ); - setProjectFiles(result.files); - setFileStatus(`已列出 ${result.files.length} 项`); - setCommandLog((current) => [...current, 'file.list']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeProjectFiles(result.files) }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setFileStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeProjectAssets( - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAssetStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setAssetStatus('请先初始化本地项目'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); - } - return; - } - - try { - if ( - announceToChat && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'asset.list', - nextProjectPath, - `列出 ${nextProjectPath} 的项目资产`, - '准备列出项目资产。', - () => void executeProjectAssets(true, true), - )) - ) { - return; - } - const nextManifest = await invoke( - 'get_local_game_manifest', - { projectPath: nextProjectPath, commandId: 'asset.list' }, - ); - setManifest(nextManifest); - setAssetStatus(`已列出 ${nextManifest.assets.length} 个资产`); - setCommandLog((current) => [...current, 'asset.list']); - if (announceToChat) { - const firstReadableAsset = firstReadableProjectAssetPath(nextManifest); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeProjectAssets(nextManifest), - draftCommand: firstReadableAsset - ? `/read ${firstReadableAsset}` - : undefined, - draftCommandLabel: firstReadableAsset ? '读取首个资产' : undefined, - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAssetStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeProjectFileReadChat( - relativePath: string, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setFileStatus('请先初始化本地项目'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); - return; - } - - try { - const commandId = isAgentTraceFilePath(relativePath) - ? 'agent.trace_read' - : 'file.read'; - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - commandId, - nextProjectPath, - `读取 ${nextProjectPath} 的 ${relativePath}`, - '准备读取项目文件。', - () => void executeProjectFileReadChat(relativePath, true), - )) - ) { - return; - } - const result = await invoke( - 'read_local_project_file', - { projectPath: nextProjectPath, relativePath, commandId }, - ); - setFilePath(result.path); - setFileDraft(result.content); - setFileStatus(`已读取:${result.path}`); - setCommandLog((current) => [...current, 'file.read']); - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeProjectFileContent(result) }, - ]); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setFileStatus(message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - - async function executeProjectTasks( - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setProjectStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setProjectStatus('请先初始化本地项目'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); - } - return; - } - - try { - if ( - announceToChat && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'task.list', - nextProjectPath, - `读取 ${nextProjectPath} 的任务拆分`, - '准备读取任务拆分。', - () => void executeProjectTasks(true, true), - )) - ) { - return; - } - const nextManifest = await invoke( - 'get_local_game_manifest', - { projectPath: nextProjectPath, commandId: 'task.list' }, - ); - setManifest(nextManifest); - setProjectStatus('已读取任务拆分'); - setCommandLog((current) => [...current, 'task.list']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeProjectTasks(nextManifest) }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setProjectStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeAgentTraceChat(skipPolicyConfirm = false) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAgentRunStatus('需要在 Tauri App 内运行'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setAgentRunStatus('请先初始化本地项目'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); - return; - } - - try { - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'agent.trace_read', - nextProjectPath, - `读取 ${nextProjectPath} 的最近 Agent run trace`, - '准备读取 Agent run trace。', - () => void executeAgentTraceChat(true), - )) - ) { - return; - } - const result = await invoke( - 'read_local_project_file', - { - projectPath: nextProjectPath, - relativePath: '.agent/run.latest.json', - commandId: 'agent.trace_read', - }, - ); - const trace = parseAgentRunTrace(result.content); - setAgentRunTrace(trace); - setAgentRunStatus(formatAgentRunStatus(trace)); - setCommandLog((current) => [ - ...current, - 'agent.trace_read', - 'file.read .agent/run.latest.json', - ]); - const traceSummary = summarizeAgentRunCompletionForChat(trace); - setMessages((current) => [ - ...current, - { role: 'assistant', ...traceSummary }, - ]); - } catch (error) { - const rawMessage = error instanceof Error ? error.message : String(error); - const message = isMissingAgentRunTraceError(rawMessage) - ? '暂无最近 Agent trace。先生成一次游戏草案后再查看。' - : rawMessage; - setAgentRunStatus(message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - - async function executeAgentRunControl( - action: string, - detail: string | undefined, - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAgentRunStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - const commandId = - action === 'status' - ? 'agent.run_status' - : (`agent.${action}` as GameCreationAppCommandDescriptor['id']); - - try { - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - commandId, - nextProjectPath, - action === 'status' - ? `查看 ${nextProjectPath} 的最近 Agent run 状态` - : `执行 ${nextProjectPath} 的 ${commandId}`, - action === 'status' - ? '准备查看 Agent run 状态。' - : '准备执行 Agent run 操作。', - () => - void executeAgentRunControl(action, detail, announceToChat, true), - )) - ) { - return; - } - const result = await invoke('control_agent_run', { - projectPath: nextProjectPath, - action, - detail, - }); - setAgentRunStatus( - `${result.status} · ${result.lifecycleStatus} · ${result.nextStep}`, - ); - setCommandLog((current) => [ - ...current, - commandId, - 'file.write .agent/activity.jsonl', - 'file.write .agent/output.jsonl', - 'file.write .agent/context.bundle.json', - ]); - if (announceToChat && action === 'status') { - appendLocalPermissionLog( - nextProjectPath, - 'command.auto', - 'agent.run_status', - ); - } - let traceReadQueued = false; - if (action === 'status') { - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - if (policyView.policy.confirmCommands.includes('agent.trace_read')) { - requestProjectPolicyConfirmation( - 'agent.trace_read', - nextProjectPath, - `读取 ${nextProjectPath} 的最近 Agent run trace`, - () => void refreshAgentRunTrace(nextProjectPath), - ); - traceReadQueued = true; - } - } - if (!traceReadQueued) { - await refreshAgentRunTrace(nextProjectPath); - } - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: [ - result.message, - `run:${result.runId}`, - `状态:${result.status} / ${result.lifecycleStatus}`, - `下一步:${result.nextStep}`, - `事件:${result.activityPath}`, - `输出:${result.outputPath}`, - `上下文包:${result.contextBundlePath}`, - ].join('\n'), - draftCommand: '/read .agent/output.jsonl', - draftCommandLabel: '读取 Run 输出', - }, - ]); - } - } catch (error) { - const rawMessage = error instanceof Error ? error.message : String(error); - const message = formatAgentRunControlError(action, rawMessage); - setAgentRunStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - function queueAgentRunControlFromPanel(action: 'kill' | 'retry' | 'resume') { - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: `agent.${action}` }); - setAgentRunStatus('等待确认'); - } - - async function executeAgentAuditChat( - confirmedPolicyCommands: GameCreationAppCommandDescriptor['id'][] = [], - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAgentRunStatus('需要在 Tauri App 内运行'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setAgentRunStatus('请先初始化本地项目'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); - return; - } - - try { - if ( - !confirmedPolicyCommands.includes('agent.audit') && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'agent.audit', - nextProjectPath, - `审计 ${nextProjectPath} 的 Agent 能力证据`, - '准备审计 Agent 能力证据。', - () => void executeAgentAuditChat(['agent.audit']), - )) - ) { - return; - } - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - const auditReadCommands: Array = [ - 'project.status', - 'file.list', - 'file.read', - 'agent.trace_read', - ]; - const confirmCommandId = auditReadCommands.find( - (commandId) => - policyView.policy.confirmCommands.includes(commandId) && - !confirmedPolicyCommands.includes(commandId), - ); - if (confirmCommandId) { - requestProjectPolicyConfirmation( - confirmCommandId, - nextProjectPath, - `审计 ${nextProjectPath} 需要读取 ${confirmCommandId}`, - () => - void executeAgentAuditChat([ - ...confirmedPolicyCommands, - confirmCommandId, - ]), - ); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备确认审计读取:${confirmCommandId}` }, - ]); - return; - } - const nextManifest = await invoke( - 'get_local_game_manifest', - { projectPath: nextProjectPath, commandId: 'agent.audit' }, - ); - const fileResult = await invoke( - 'list_local_project_files', - { projectPath: nextProjectPath }, - ); - let commandLogContent = ''; - try { - const commandLogResult = await invoke( - 'read_local_project_file', - { - projectPath: nextProjectPath, - relativePath: '.agent/logs/command.log', - commandId: 'file.read', - }, - ); - commandLogContent = commandLogResult.content; - } catch { - commandLogContent = ''; - } - let trace: GameCreationAgentRunTrace | null = null; - try { - const traceResult = await invoke( - 'read_local_project_file', - { - projectPath: nextProjectPath, - relativePath: '.agent/run.latest.json', - commandId: 'agent.trace_read', - }, - ); - trace = parseAgentRunTrace(traceResult.content); - } catch { - trace = null; - } - - setManifest(nextManifest); - setProjectFiles(fileResult.files); - setAgentRunTrace(trace); - setAgentRunStatus( - trace ? formatAgentRunStatus(trace) : '还没有最近一次 Agent run', - ); - setCommandLog((current) => [ - ...current, - 'agent.audit', - 'project.status', - 'file.list', - ...(commandLogContent ? ['file.read .agent/logs/command.log'] : []), - ...(trace ? ['agent.trace_read'] : []), - ]); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: summarizeAgentAudit( - nextManifest, - nextProjectPath, - fileResult.files, - trace, - commandLogContent, - ), - }, - ]); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAgentRunStatus(message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - - async function handlePreviewStart() { - const nextProjectPath = resolveChatProjectPath(localProject) ?? projectPath; - requestCommandConfirmation( - 'preview.start', - `启动 ${nextProjectPath}/game/ 并载入客户端运行视图`, - () => void executePreviewStart(false), - ); - } - - async function executePreviewStart( - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setPreviewStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setPreviewStatus('请先初始化本地项目'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); - } - return; - } - - setPreviewStatus('正在启动'); - try { - if ( - announceToChat && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'preview.start', - nextProjectPath, - `启动 ${nextProjectPath}/game/ 并载入客户端运行视图`, - '准备启动本地预览。', - () => void executePreviewStart(true, true), - )) - ) { - return; - } - const result = await invoke( - 'start_local_game_preview', - { projectPath: nextProjectPath }, - ); - updateClientPreview(result); - setPreviewStatus(`运行中:127.0.0.1:${result.port}`); - void refreshManifest(nextProjectPath); - setCommandLog((current) => [...current, 'preview.start']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `预览已在客户端运行视图启动:${result.url}`, - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setPreviewStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executePreviewOpen( - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setPreviewStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : resolveChatProjectPath(localProject); - if (announceToChat && !nextProjectPath) { - return; - } - - try { - if ( - announceToChat && - nextProjectPath && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'preview.open', - nextProjectPath, - `打开 ${nextProjectPath} 的本地预览`, - '准备打开当前本地预览。', - () => void executePreviewOpen(true, true), - )) - ) { - return; - } - const result = await invoke( + const status = await invoke( 'activate_local_game_preview', - nextProjectPath ? { projectPath: nextProjectPath } : undefined, + { projectPath: nextProjectPath }, ); if ( - result.status === 'running' && - result.url && - result.port && - result.root + status.status !== 'running' || + !status.url || + !status.port || + !status.root ) { - updateClientPreview({ - url: result.url, - port: result.port, - root: result.root, - }); - setPreviewStatus(`运行中:127.0.0.1:${result.port}`); + return null; } - setCommandLog((current) => [...current, 'preview.open']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: result.url - ? `已切换到客户端运行视图:${result.url}` - : '已切换到客户端运行视图。', - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setPreviewStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function handlePreviewStatus() { - void executePreviewStatus(false); - } - - async function executePreviewStatus( - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setPreviewStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : resolveChatProjectPath(localProject); - if (announceToChat && !nextProjectPath) { - return; - } - - try { - if ( - announceToChat && - nextProjectPath && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'preview.status', - nextProjectPath, - `查看 ${nextProjectPath} 的预览状态`, - '准备查看预览状态。', - () => void executePreviewStatus(true, true), - )) - ) { - return; - } - const result = await invoke( - 'get_local_game_preview_status', - nextProjectPath ? { projectPath: nextProjectPath } : undefined, - ); - if ( - result.status === 'running' && - result.url && - result.port && - result.root - ) { - updateClientPreview({ - url: result.url, - port: result.port, - root: result.root, - }); - setPreviewStatus(`运行中:127.0.0.1:${result.port}`); - } else { - updateClientPreview(null); - setPreviewStatus('未启动'); - } - setCommandLog((current) => [...current, 'preview.status']); - if (announceToChat && nextProjectPath) { - appendLocalPermissionLog( - nextProjectPath, - 'command.auto', - 'preview.status', - ); - } - if (announceToChat) { - const previewRunning = result.status === 'running' && result.url; - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: previewRunning ? `预览运行中:${result.url}` : '预览未启动。', - draftCommand: previewRunning ? '/open-preview' : '/preview', - draftCommandLabel: previewRunning - ? '填入打开预览命令' - : '填入启动预览命令', - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setPreviewStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function handlePreviewStop() { - void executePreviewStop(false); - } - - async function executePreviewStop( - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setPreviewStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setPreviewStatus('请先初始化本地项目'); - return; - } - - try { - if ( - announceToChat && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'preview.stop', - nextProjectPath, - `停止 ${nextProjectPath} 的本地预览`, - '准备停止本地预览。', - () => void executePreviewStop(true, true), - )) - ) { - return; - } - await invoke('stop_local_game_preview', { - projectPath: nextProjectPath, - }); - updateClientPreview(null); - setPreviewStatus('已停止'); - setCommandLog((current) => [...current, 'preview.stop']); - if (announceToChat) { - appendLocalPermissionLog( - nextProjectPath, - 'command.auto', - 'preview.stop', - ); - } - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '预览已停止。' }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setPreviewStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function handleMemoryRead(skipPolicyConfirm = false) { - const nextProjectPath = validateMemoryPanelProjectPath(); - if (!nextProjectPath) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMemoryStatus('需要在 Tauri App 内运行'); - return; - } - - setMemoryStatus('正在读取'); - try { - if (!skipPolicyConfirm) { - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - if (policyView.policy.confirmCommands.includes('memory.read')) { - requestProjectPolicyConfirmation( - 'memory.read', - nextProjectPath, - `读取 ${nextProjectPath}/${memoryScopePath(memoryScope)}`, - () => void handleMemoryRead(true), - ); - setMemoryStatus('等待确认'); - return; - } - } - const result = await invoke( - 'read_local_game_memory', - { projectPath: nextProjectPath, scope: memoryScope }, - ); - setMemoryDraft(result.content); - setMemoryStatus( - result.exists ? `已读取:${result.path}` : '记忆文件不存在', - ); - setCommandLog((current) => [...current, 'memory.read']); - } catch (error) { - setMemoryStatus(error instanceof Error ? error.message : String(error)); - } - } - - function validateMemoryPanelProjectPath() { - const nextProjectPath = projectPath.trim(); - if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { - setMemoryStatus('请提供本地项目绝对路径。'); + return { url: status.url, port: status.port, root: status.root }; + } catch { + // 没在跑、不属于这个项目,或权限位要求确认:都不是错误,回落到重新启动预览。 return null; } - if (projectPathHasControlCharacter(nextProjectPath)) { - setMemoryStatus('本地项目路径不能包含控制字符。'); - return null; - } - return nextProjectPath; - } - - async function handleMemoryWrite() { - const nextProjectPath = validateMemoryPanelProjectPath(); - if (!nextProjectPath) { - return; - } - requestCommandConfirmation( - 'memory.write', - `保存 ${nextProjectPath}/${memoryScopePath(memoryScope)}`, - () => void executeMemoryWritePanel(nextProjectPath), - ); - } - - async function executeMemoryWritePanel(nextProjectPath: string) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMemoryStatus('需要在 Tauri App 内运行'); - return; - } - - setMemoryStatus('正在保存'); - try { - const result = await invoke( - 'write_local_game_memory', - { - projectPath: nextProjectPath, - scope: memoryScope, - content: memoryDraft, - }, - ); - setMemoryStatus(`已保存:${result.path}`); - setCommandLog((current) => [...current, 'memory.write']); - } catch (error) { - setMemoryStatus(error instanceof Error ? error.message : String(error)); - } - } - - async function handleMemoryDelete() { - const nextProjectPath = validateMemoryPanelProjectPath(); - if (!nextProjectPath) { - return; - } - requestCommandConfirmation( - 'memory.delete', - `删除 ${nextProjectPath}/${memoryScopePath(memoryScope)}`, - () => void executeMemoryDeletePanel(nextProjectPath), - ); - } - - async function executeMemoryDeletePanel(nextProjectPath: string) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMemoryStatus('需要在 Tauri App 内运行'); - return; - } - - setMemoryStatus('正在删除'); - try { - const result = await invoke( - 'delete_local_game_memory', - { projectPath: nextProjectPath, scope: memoryScope }, - ); - setMemoryDraft(''); - setMemoryStatus(`已删除:${result.path}`); - setCommandLog((current) => [...current, 'memory.delete']); - } catch (error) { - setMemoryStatus(error instanceof Error ? error.message : String(error)); - } - } - - async function executeMemoryReadChat( - scope: MemoryScope, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMemoryStatus('需要在 Tauri App 内运行'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - - try { - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'memory.read', - nextProjectPath, - `读取 ${nextProjectPath}/${memoryScopePath(scope)}`, - '准备读取项目记忆。', - () => void executeMemoryReadChat(scope, true), - )) - ) { - return; - } - const result = await invoke( - 'read_local_game_memory', - { projectPath: nextProjectPath, scope }, - ); - setMemoryScope(scope); - setMemoryDraft(result.content); - setMemoryStatus( - result.exists ? `已读取:${result.path}` : '记忆文件不存在', - ); - setCommandLog((current) => [...current, 'memory.read']); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: result.exists - ? `${memoryScopeLabel(scope)}记忆:\n${result.content}` - : `${memoryScopeLabel(scope)}记忆为空。`, - }, - ]); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setMemoryStatus(message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - - async function executeMemoryWrite( - scope: MemoryScope, - content: string, - mode: MemoryWriteMode, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMemoryStatus('需要在 Tauri App 内运行'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - - try { - const currentMemory = - mode === 'append' - ? await invoke('read_local_game_memory', { - projectPath: nextProjectPath, - scope, - }) - : null; - const result = await invoke( - 'write_local_game_memory', - { - projectPath: nextProjectPath, - scope, - content: - mode === 'append' - ? appendMemoryContent(currentMemory?.content ?? '', content) - : content, - }, - ); - setMemoryScope(scope); - setMemoryDraft(result.content); - setMemoryStatus(`已保存:${result.path}`); - setCommandLog((current) => [ - ...current, - ...(mode === 'append' ? ['memory.read'] : []), - 'memory.write', - ]); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: - mode === 'append' - ? `已追加${memoryScopeLabel(scope)}记忆。` - : `已保存${memoryScopeLabel(scope)}记忆。`, - }, - ]); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setMemoryStatus(message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - - async function executeMemoryDeleteChat(scope: MemoryScope) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setMemoryStatus('需要在 Tauri App 内运行'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - - try { - const result = await invoke( - 'delete_local_game_memory', - { projectPath: nextProjectPath, scope }, - ); - setMemoryScope(scope); - setMemoryDraft(''); - setMemoryStatus(`已删除:${result.path}`); - setCommandLog((current) => [...current, 'memory.delete']); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `已删除${memoryScopeLabel(scope)}记忆。` }, - ]); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setMemoryStatus(message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - - async function handleLimitedCommandRun( - command: GameCreationAppLimitedRunCommandDescriptor, - ) { - const nextProjectPath = resolveChatProjectPath(localProject) ?? projectPath; - requestCommandConfirmation( - 'command.run_limited', - `运行 ${command.title} 于 ${nextProjectPath}`, - () => void executeLimitedCommand(command.id, false), - ); - } - - async function refreshLimitedLocalCommands() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setLimitedCommandStatus('需要在 Tauri App 内运行'); - return; - } - setLimitedCommandStatus('正在读取内置命令'); - try { - const commands = await invoke< - GameCreationAppLimitedRunCommandDescriptor[] - >('get_limited_local_commands'); - setLimitedLocalCommands( - commands.length > 0 - ? commands - : [...GAME_CREATION_APP_LIMITED_RUN_COMMANDS], - ); - setLimitedCommandStatus(`已读取 ${commands.length} 个内置命令`); - setCommandLog((current) => [...current, 'command.list_limited']); - } catch (error) { - setLimitedCommandStatus( - error instanceof Error ? error.message : String(error), - ); - } - } - - async function handleProjectLogRead( - relativePath: string, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setProjectLogStatus('需要在 Tauri App 内运行'); - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setProjectLogStatus('请先初始化本地项目'); - return; - } - setProjectLogStatus(`正在读取 ${relativePath}`); - try { - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'file.read', - nextProjectPath, - `读取 ${nextProjectPath}/${relativePath}`, - '准备读取项目日志。', - () => void handleProjectLogRead(relativePath, true), - )) - ) { - setProjectLogStatus('等待确认'); - return; - } - const result = await invoke( - 'read_local_project_file', - { - projectPath: nextProjectPath, - relativePath, - commandId: 'file.read', - }, - ); - setProjectLogContent(result.content); - setProjectLogStatus(`已读取:${result.path}`); - setCommandLog((current) => [...current, `file.read ${result.path}`]); - } catch (error) { - setProjectLogContent(''); - setProjectLogStatus( - error instanceof Error ? error.message : String(error), - ); - } - } - - async function executeLimitedCommandList() { - const invoke = resolveTauriInvoke(); - let commands: GameCreationAppLimitedRunCommandDescriptor[] = [ - ...GAME_CREATION_APP_LIMITED_RUN_COMMANDS, - ]; - if (invoke) { - try { - const nativeCommands = await invoke< - GameCreationAppLimitedRunCommandDescriptor[] - >('get_limited_local_commands'); - if (nativeCommands.length > 0) { - commands = nativeCommands; - } - } catch { - commands = [...GAME_CREATION_APP_LIMITED_RUN_COMMANDS]; - } - } - setLimitedLocalCommands(commands); - setLimitedCommandStatus(`已读取 ${commands.length} 个内置命令`); - setCommandLog((current) => [...current, 'command.list_limited']); - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeLimitedLocalCommands(commands) }, - ]); } async function executeRunLocal(announceToChat: boolean) { const invoke = resolveTauriInvoke(); if (!invoke) { - setLimitedCommandStatus('需要在 Tauri App 内运行'); if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); + announceProjectChatMessage('需要在 Tauri App 内运行。'); } return; } const nextProjectPath = resolveChatProjectPath(localProject); if (!nextProjectPath) { - setLimitedCommandStatus('请先初始化本地项目'); if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); + announceProjectChatMessage('请先用 /project 设置本地项目。'); } return; } - setLimitedCommandStatus('正在运行'); try { - const smoke = directCodexProductRuntime - ? null - : await invoke('run_limited_local_command', { - projectPath: nextProjectPath, - commandId: 'game.static_smoke', - }); + const activePreview = await activateRunningPreview( + invoke, + nextProjectPath, + ); + if (activePreview) { + updateClientPreview(activePreview); + if (announceToChat) { + announceProjectChatMessage( + `已切换到客户端运行视图:${activePreview.url}`, + ); + } + return; + } const previewResult = await invoke( 'start_local_game_preview', { projectPath: nextProjectPath }, ); updateClientPreview(previewResult); - setPreviewStatus(`运行中:127.0.0.1:${previewResult.port}`); - setLimitedCommandStatus(smoke?.output ?? '本地预览已启动'); - setCommandLog((current) => [ - ...current, - 'game.run_local', - ...(smoke ? [`command.run_limited ${smoke.commandId}`] : []), - 'preview.start', - ]); void refreshManifest(nextProjectPath); - if (!directCodexProductRuntime) { + if (!directProjectMode) { void refreshAgentRunTrace(nextProjectPath); } if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `运行通过,已载入客户端运行视图:${previewResult.url}`, - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setLimitedCommandStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function executeLimitedCommand( - commandId: string, - announceToChat: boolean, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setLimitedCommandStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setLimitedCommandStatus('请先初始化本地项目'); - return; - } - - setLimitedCommandStatus('正在运行'); - try { - const result = await invoke( - 'run_limited_local_command', - { projectPath: nextProjectPath, commandId }, - ); - const summary = `${result.output}\n日志:${result.logPath}`; - setLimitedCommandStatus(result.output); - setCommandLog((current) => [ - ...current, - `command.run_limited ${result.commandId}`, - ]); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: summary }, - ]); - } - void refreshManifest(nextProjectPath); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setLimitedCommandStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function handleFileList(skipPolicyConfirm = false) { - const nextProjectPath = validateFilePanelProjectPath(); - if (!nextProjectPath) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - return; - } - - setFileStatus('正在列出'); - try { - if (!skipPolicyConfirm) { - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, + announceProjectChatMessage( + `运行通过,已载入客户端运行视图:${previewResult.url}`, ); - if (policyView.policy.confirmCommands.includes('file.list')) { - requestProjectPolicyConfirmation( - 'file.list', - nextProjectPath, - `列出 ${nextProjectPath} 的项目文件`, - () => void handleFileList(true), - ); - setFileStatus('等待确认'); - return; - } - } - const result = await invoke( - 'list_local_project_files', - { projectPath: nextProjectPath }, - ); - setProjectFiles(result.files); - setFileStatus(`已列出 ${result.files.length} 项`); - setCommandLog((current) => [...current, 'file.list']); - } catch (error) { - setFileStatus(error instanceof Error ? error.message : String(error)); - } - } - - function validateFilePanelProjectPath() { - const nextProjectPath = projectPath.trim(); - if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { - setFileStatus('请提供本地项目绝对路径。'); - return null; - } - if (projectPathHasControlCharacter(nextProjectPath)) { - setFileStatus('本地项目路径不能包含控制字符。'); - return null; - } - return nextProjectPath; - } - - function validateFilePanelPath() { - if (!isSafeProjectRelativePath(filePath)) { - setFileStatus('文件路径必须是项目内相对路径。'); - return null; - } - return filePath.trim(); - } - - async function handleFileRead(skipPolicyConfirm = false) { - const nextProjectPath = validateFilePanelProjectPath(); - if (!nextProjectPath) { - return; - } - const relativePath = validateFilePanelPath(); - if (!relativePath) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - return; - } - - setFileStatus('正在读取'); - try { - const commandId = isAgentTraceFilePath(relativePath) - ? 'agent.trace_read' - : 'file.read'; - if (!skipPolicyConfirm) { - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - if (policyView.policy.confirmCommands.includes(commandId)) { - requestProjectPolicyConfirmation( - commandId, - nextProjectPath, - `读取 ${nextProjectPath} 的 ${relativePath}`, - () => void handleFileRead(true), - ); - setFileStatus('等待确认'); - return; - } - } - const result = await invoke( - 'read_local_project_file', - { - projectPath: nextProjectPath, - relativePath, - commandId, - }, - ); - setFilePath(result.path); - setFileDraft(result.content); - setFileStatus(`已读取:${result.path}`); - setCommandLog((current) => [...current, 'file.read']); - } catch (error) { - setFileStatus(error instanceof Error ? error.message : String(error)); - } - } - - async function handleFileWrite() { - const nextProjectPath = validateFilePanelProjectPath(); - if (!nextProjectPath) { - return; - } - const relativePath = validateFilePanelPath(); - if (!relativePath) { - return; - } - requestCommandConfirmation( - 'file.write', - `保存 ${nextProjectPath}/${relativePath}`, - () => void executeFileWrite(nextProjectPath, relativePath), - ); - } - - async function executeFileWrite( - nextProjectPath: string, - relativePath: string, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - return; - } - - setFileStatus('正在保存'); - try { - const result = await invoke( - 'write_local_project_file', - { projectPath: nextProjectPath, relativePath, content: fileDraft }, - ); - setFileStatus(`已保存:${result.path}`); - setCommandLog((current) => [...current, 'file.write']); - } catch (error) { - setFileStatus(error instanceof Error ? error.message : String(error)); - } - } - - async function handleFileDelete() { - const nextProjectPath = validateFilePanelProjectPath(); - if (!nextProjectPath) { - return; - } - const relativePath = validateFilePanelPath(); - if (!relativePath) { - return; - } - requestCommandConfirmation( - 'file.delete', - `删除 ${nextProjectPath}/${relativePath}`, - () => void executeFileDelete(nextProjectPath, relativePath), - ); - } - - async function executeFileDelete( - nextProjectPath: string, - relativePath: string, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setFileStatus('需要在 Tauri App 内运行'); - return; - } - - setFileStatus('正在删除'); - try { - const result = await invoke( - 'delete_local_project_file', - { projectPath: nextProjectPath, relativePath }, - ); - setFileDraft(''); - setFileStatus(result.deleted ? `已删除:${result.path}` : '文件不存在'); - setCommandLog((current) => [...current, 'file.delete']); - } catch (error) { - setFileStatus(error instanceof Error ? error.message : String(error)); - } - } - - async function handleAssetRegister() { - const nextProjectPath = projectPath.trim(); - const localPath = assetLocalPath.trim(); - const canvasProjectId = assetCanvasProjectId.trim(); - const resourceId = assetResourceId.trim(); - const nextAssetObjectId = assetObjectId.trim(); - if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { - setAssetStatus('请提供本地项目绝对路径。'); - return; - } - if (projectPathHasControlCharacter(nextProjectPath)) { - setAssetStatus('本地项目路径不能包含控制字符。'); - return; - } - if (!isSafeProjectRelativePath(localPath)) { - setAssetStatus('资产路径必须是项目内相对路径。'); - return; - } - if (assetSourceKind === 'canvas') { - if (!canvasProjectId) { - setAssetStatus('请提供画板项目 ID。'); - return; - } - if (!isSafeCanvasProjectId(canvasProjectId)) { - setAssetStatus('画板项目 ID 不能包含控制字符。'); - return; - } - if (!resourceId && !nextAssetObjectId) { - setAssetStatus('请提供资源 ID 或资产对象 ID。'); - return; - } - } - requestCommandConfirmation( - 'asset.register', - `登记 ${nextProjectPath}/${localPath}`, - () => - void executeAssetRegister( - nextProjectPath, - localPath, - parseGameCreationAppAssetKind(assetKind, 'ui.asset-register.kind'), - assetMediaType, - assetSourceKind, - canvasProjectId, - resourceId, - nextAssetObjectId, - ), - ); - } - - async function executeAssetRegister( - nextProjectPath: string, - localPath: string, - kind: GameCreationAppAssetKind, - mediaType: string, - sourceKind: string, - canvasProjectId: string, - resourceId: string, - nextAssetObjectId: string, - announceToChat = false, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAssetStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - - setAssetStatus('正在登记'); - try { - if ( - announceToChat && - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'asset.register', - nextProjectPath, - `登记 ${nextProjectPath}/${localPath}`, - '准备登记项目资产。', - () => - void executeAssetRegister( - nextProjectPath, - localPath, - kind, - mediaType, - sourceKind, - canvasProjectId, - resourceId, - nextAssetObjectId, - true, - true, - ), - )) - ) { - return; - } - const result = await invoke( - 'register_local_asset', - { - projectPath: nextProjectPath, - localPath, - kind, - mediaType, - sourceKind, - canvasProjectId, - resourceId, - assetObjectId: nextAssetObjectId, - taskId: '', - prompt: '', - model: '', - }, - ); - setUploadedAssets((current) => { - const filtered = current.filter((asset) => asset.id !== result.id); - return [...filtered, result]; - }); - setAssetStatus(`已登记:${result.localPath}`); - setCommandLog((current) => [...current, 'asset.register']); - void refreshManifest(nextProjectPath); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: `已登记资产:${result.localPath}` }, - ]); } } catch (error) { const message = error instanceof Error ? error.message : String(error); - setAssetStatus(message); if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); + announceProjectChatMessage(message); } } } - async function handleCanvasAssetImport() { - const nextProjectPath = projectPath.trim(); - const localPath = assetLocalPath.trim(); - const canvasProjectId = assetCanvasProjectId.trim(); - const resourceId = assetResourceId.trim(); - const nextAssetObjectId = assetObjectId.trim(); - if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { - setAssetStatus('请提供本地项目绝对路径。'); - return; - } - if (projectPathHasControlCharacter(nextProjectPath)) { - setAssetStatus('本地项目路径不能包含控制字符。'); - return; - } - if (!isSafeProjectRelativePath(localPath)) { - setAssetStatus('画板资产路径必须是项目内相对路径。'); - return; - } - if (!canvasProjectId) { - setAssetStatus('请提供画板项目 ID。'); - return; - } - if (!isSafeCanvasProjectId(canvasProjectId)) { - setAssetStatus('画板项目 ID 不能包含控制字符。'); - return; - } - if (!resourceId && !nextAssetObjectId) { - setAssetStatus('请提供资源 ID 或资产对象 ID。'); - return; - } - requestCommandConfirmation( - 'canvas.asset_import', - `导入 ${nextProjectPath}/${localPath}`, - () => - void executeCanvasAssetImport( - nextProjectPath, - localPath, - parseGameCreationAppAssetKind( - assetKind, - 'ui.canvas-asset-import.kind', - ), - assetMediaType, - canvasProjectId, - resourceId, - nextAssetObjectId, - false, - ), - ); - } - - async function executeCanvasAssetImport( - nextProjectPath: string | null, - localPath: string, - kind: GameCreationAppAssetKind, - mediaType: string, - canvasProjectId: string, - resourceId: string, - assetObjectId: string, - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAssetStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const targetProjectPath = announceToChat - ? requireChatProjectForUserAction() - : nextProjectPath; - if (!targetProjectPath) { - return; - } - - try { - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'canvas.asset_import', - targetProjectPath, - `导入 ${targetProjectPath}/${localPath} 的画板来源资产`, - '准备导入画板资产。', - () => - void executeCanvasAssetImport( - nextProjectPath, - localPath, - kind, - mediaType, - canvasProjectId, - resourceId, - assetObjectId, - announceToChat, - true, - ), - )) - ) { - setAssetStatus('等待确认导入画板资产'); - return; - } - setAssetStatus('正在导入画板资产'); - const result = await invoke( - 'import_canvas_asset', - { - projectPath: targetProjectPath, - localPath, - kind, - mediaType, - canvasProjectId, - resourceId, - assetObjectId, - taskId: '', - prompt: '', - model: '', - }, - ); - setUploadedAssets((current) => { - const filtered = current.filter((asset) => asset.id !== result.id); - return [...filtered, result]; - }); - const canvasSource = formatCanvasAssetSource({ - canvasProjectId, - canvasAssetId: resourceId, - canvasAssetObjectId: assetObjectId, - }); - setAssetStatus(`已导入画板资产 ${canvasSource}:${result.localPath}`); - setCommandLog((current) => [...current, 'canvas.asset_import']); - void refreshManifest(targetProjectPath); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `已导入画板资产 ${canvasSource}:${result.localPath}`, - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAssetStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - function handleCanvasAssetGenerate() { - const nextProjectPath = projectPath.trim(); - const prompt = assetGenerationPrompt.trim(); - if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { - setAssetStatus('请提供本地项目绝对路径。'); - return; - } - if (projectPathHasControlCharacter(nextProjectPath)) { - setAssetStatus('本地项目路径不能包含控制字符。'); - return; - } - if (!prompt) { - setAssetStatus('请提供美术生成提示词。'); - return; - } - requestCommandConfirmation( - 'canvas.asset_generate', - `生成 ${nextProjectPath} 的首版美术素材`, - () => void executeCanvasAssetGenerate(prompt, false), - ); - } - - async function executeCanvasAssetGenerate( - prompt: string, - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAssetStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : projectPath.trim(); - if (!nextProjectPath) { - return; - } - - try { - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'canvas.asset_generate', - nextProjectPath, - `生成 ${nextProjectPath} 的首版美术素材`, - '准备生成首版美术素材。', - () => void executeCanvasAssetGenerate(prompt, announceToChat, true), - )) - ) { - setAssetStatus('等待确认生成美术素材'); - return; - } - setAssetStatus('正在生成美术素材'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '正在生成首版美术素材。' }, - ]); - } - const result = await invoke( - 'generate_platform_art_asset', - { - projectPath: nextProjectPath, - prompt, - }, - ); - setUploadedAssets((current) => { - const filtered = current.filter((asset) => asset.id !== result.id); - return [...filtered, result]; - }); - setAssetStatus(`已生成美术素材:${result.localPath}`); - setCommandLog((current) => [...current, 'canvas.asset_generate']); - void refreshManifest(nextProjectPath); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: `已生成美术素材:${result.localPath}` }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAssetStatus(message); - if (isRuntimeConfigMissingError(message)) { - requestRuntimeConfigOpen(); - } - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - function handleCanvasProjectSync() { - const nextProjectPath = projectPath.trim(); - const canvasProjectId = assetCanvasProjectId.trim(); - if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { - setAssetStatus('请提供本地项目绝对路径。'); - return; - } - if (projectPathHasControlCharacter(nextProjectPath)) { - setAssetStatus('本地项目路径不能包含控制字符。'); - return; - } - if (!canvasProjectId) { - setAssetStatus('请提供画板项目 ID。'); - return; - } - if (!isSafeCanvasProjectId(canvasProjectId)) { - setAssetStatus('画板项目 ID 不能包含控制字符。'); - return; - } - requestCommandConfirmation( - 'canvas.project_sync', - `同步画板项目资源:${canvasProjectId}`, - () => void executeCanvasProjectSync(canvasProjectId, false), - ); - } - - async function executeCanvasProjectSync( - canvasProjectId: string, - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAssetStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : projectPath; - if (!nextProjectPath) { - return; - } - - try { - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'canvas.project_sync', - nextProjectPath, - `同步 ${nextProjectPath} 的画板项目资源:${canvasProjectId}`, - '准备同步画板项目资源。', - () => - void executeCanvasProjectSync( - canvasProjectId, - announceToChat, - true, - ), - )) - ) { - setAssetStatus('等待确认同步画板项目'); - return; - } - setAssetStatus('正在同步画板项目资源'); - const result = await invoke( - 'sync_canvas_project_assets', - { - projectPath: nextProjectPath, - canvasProjectId, - }, - ); - setUploadedAssets((current) => { - const byId = new Map(current.map((asset) => [asset.id, asset])); - for (const asset of result.assets) { - byId.set(asset.id, asset); - } - return [...byId.values()]; - }); - setAssetStatus( - `已同步画板项目:${result.importedCount} 个资产,${result.importRoot}`, - ); - setCommandLog((current) => [...current, 'canvas.project_sync']); - void refreshManifest(nextProjectPath); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `已同步 ${result.importedCount} 个画板资产自 ${result.canvasProjectId}:${result.importRoot}`, - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAssetStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - function handleCanvasExportImport() { - const nextProjectPath = projectPath.trim(); - const exportPath = canvasExportPath.trim(); - const canvasProjectId = assetCanvasProjectId.trim(); - if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { - setAssetStatus('请提供本地项目绝对路径。'); - return; - } - if (projectPathHasControlCharacter(nextProjectPath)) { - setAssetStatus('本地项目路径不能包含控制字符。'); - return; - } - if (!canvasProjectId) { - setAssetStatus('请提供画板项目 ID。'); - return; - } - if (!isSafeCanvasProjectId(canvasProjectId)) { - setAssetStatus('画板项目 ID 不能包含控制字符。'); - return; - } - if ( - !isAbsoluteProjectPath(exportPath) || - projectPathHasControlCharacter(exportPath) - ) { - setAssetStatus('画板导出 ZIP 路径必须是绝对路径。'); - return; - } - requestCommandConfirmation( - 'canvas.export_import', - `导入画板导出包:${exportPath}`, - () => void executeCanvasExportImport(exportPath, canvasProjectId, false), - ); - } - - async function executeCanvasExportImport( - exportPath: string, - canvasProjectId: string, - announceToChat: boolean, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAssetStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - const nextProjectPath = announceToChat - ? requireChatProjectForUserAction() - : projectPath; - if (!nextProjectPath) { - return; - } - - try { - if ( - !skipPolicyConfirm && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'canvas.export_import', - nextProjectPath, - `导入 ${nextProjectPath} 的画板导出包:${exportPath}`, - '准备导入画板导出包。', - () => - void executeCanvasExportImport( - exportPath, - canvasProjectId, - announceToChat, - true, - ), - )) - ) { - setAssetStatus('等待确认导入画板导出包'); - return; - } - setAssetStatus('正在导入画板导出包'); - const result = await invoke( - 'import_canvas_export', - { - projectPath: nextProjectPath, - exportPath, - canvasProjectId, - }, - ); - setUploadedAssets((current) => { - const byId = new Map(current.map((asset) => [asset.id, asset])); - for (const asset of result.assets) { - byId.set(asset.id, asset); - } - return [...byId.values()]; - }); - setAssetStatus( - `已导入画板导出包:${result.importedCount} 个资产,${result.importRoot}`, - ); - setCommandLog((current) => [...current, 'canvas.export_import']); - void refreshManifest(nextProjectPath); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `已导入 ${result.importedCount} 个画板资产自 ${canvasProjectId}:${result.importRoot}`, - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAssetStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function handleCanvasProjectOpen() { - const canvasProjectId = assetCanvasProjectId.trim(); - if (!canvasProjectId) { - setAssetStatus('请提供画板项目 ID。'); - return; - } - if (!isSafeCanvasProjectId(canvasProjectId)) { - setAssetStatus('画板项目 ID 不能包含控制字符。'); - return; - } - requestCommandConfirmation( - 'canvas.project_open', - `打开本机画板项目 ${canvasProjectId}`, - () => void executeCanvasProjectOpen(canvasProjectId, false), - ); - } - - async function executeCanvasProjectOpen( - canvasProjectId: string, - announceToChat: boolean, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAssetStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - } - return; - } - - setAssetStatus('正在打开画板'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: `正在打开画板:${canvasProjectId}` }, - ]); - } - try { - const result = await invoke( - 'open_canvas_project', - { - canvasProjectId, - editorBaseUrl, - }, - ); - setAssetStatus(`已打开画板:${result.url}`); - setCommandLog((current) => [...current, 'canvas.project_open']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `已打开画板:${result.url}`, - draftCommand: `/sync-canvas-project ${canvasProjectId}`, - draftCommandLabel: '同步此画板', - }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAssetStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - } - - async function handleAssetUpload(event: ChangeEvent) { - const file = event.currentTarget.files?.[0]; - event.currentTarget.value = ''; - if (!file) { - return; - } - if (!requireChatProjectForUserAction()) { - return; - } - - queuePendingCommand({ id: 'asset.upload', file }); - setMessages((current) => [ - ...current, - { role: 'user', text: `上传文件:${file.name}` }, - { role: 'assistant', text: `准备保存文件到本地项目:${file.name}` }, - ]); - } - - async function executeAssetUpload(file: File) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAssetStatus('需要在 Tauri App 内运行'); - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); - return; - } - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - - setAssetStatus('正在上传'); - try { - const bytes = Array.from(new Uint8Array(await file.arrayBuffer())); - const result = await invoke( - 'upload_local_asset', - { - projectPath: nextProjectPath, - fileName: file.name, - mediaType: file.type || 'application/octet-stream', - bytes, - }, - ); - setLocalProject({ - projectPath: nextProjectPath, - manifestPath: result.manifestPath, - manifest, - }); - setUploadedAssets((current) => [...current, result]); - setAssetStatus(`已上传:${result.localPath}`); - setCommandLog((current) => [ - ...current, - 'asset.upload', - 'asset.register', - ]); - void refreshManifest(nextProjectPath); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `已保存文件:${result.localPath}` }, - ]); - } catch (error) { - setAssetStatus(error instanceof Error ? error.message : String(error)); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: error instanceof Error ? error.message : String(error), - }, - ]); - } - } - async function loadAgentRunTraceFile( relativePath: string, nextProjectPath = resolveChatProjectPath(localProject) ?? '', ) { const invoke = resolveTauriInvoke(); if (!invoke) { - setAgentRunStatus('需要在 Tauri App 内运行'); return null; } if (!nextProjectPath) { - setAgentRunStatus('请先初始化本地项目'); return null; } @@ -10602,142 +1834,9 @@ export function App({ }, ); const trace = parseAgentRunTrace(result.content); - setAgentRunTrace(trace); - setAgentRunStatus(formatAgentRunStatus(trace)); - setCommandLog((current) => [...current, `file.read ${relativePath}`]); return summarizeAgentRunCompletionForChat(trace); - } catch (error) { - setAgentRunTrace(null); - setAgentRunStatus( - relativePath === '.agent/run.latest.json' && - isMissingProjectFileError(error) - ? '还没有最近一次 Agent run' - : error instanceof Error - ? error.message - : String(error), - ); - return null; - } - } - - async function handleAgentRunTraceFileOpen( - relativePath: string, - skipPolicyConfirm = false, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAgentRunStatus('需要在 Tauri App 内运行'); - return; - } - const nextProjectPath = resolveChatProjectPath(localProject) ?? ''; - if (!nextProjectPath) { - setAgentRunStatus('请先初始化本地项目'); - return; - } - - if (!skipPolicyConfirm) { - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - if (policyView.policy.confirmCommands.includes('agent.trace_read')) { - requestProjectPolicyConfirmation( - 'agent.trace_read', - nextProjectPath, - `读取 ${nextProjectPath} 的 ${relativePath}`, - () => void handleAgentRunTraceFileOpen(relativePath, true), - ); - setAgentRunStatus('等待确认读取 Agent trace'); - return; - } - } - - await loadAgentRunTraceFile(relativePath, nextProjectPath); - } - - async function readAgentRunHistoryItems( - invoke: TauriInvoke, - runFiles: LocalProjectFileEntry[], - nextProjectPath: string, - ) { - const history = await Promise.all( - runFiles.map(async (file) => { - try { - const traceResult = await invoke( - 'read_local_project_file', - { - projectPath: nextProjectPath, - relativePath: file.path, - commandId: 'agent.trace_read', - }, - ); - return { - path: file.path, - size: file.size, - trace: parseAgentRunTrace(traceResult.content), - } satisfies AgentRunHistoryItem; - } catch { - return null; - } - }), - ); - return history - .filter((item): item is AgentRunHistoryItem => item !== null) - .sort((left, right) => right.trace.updatedAt - left.trace.updatedAt); - } - - async function refreshAgentRunHistory( - nextProjectPath = resolveChatProjectPath(localProject) ?? '', - ) { - const invoke = resolveTauriInvoke(); - if (!invoke || !nextProjectPath) { - setAgentRunHistory([]); - setAgentRunHistoryFiles([]); - setAgentRunHistoryOverflowCount(0); - setAgentRunHistoryVisibleCount(AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT); - setAgentRunHistoryLoadingMore(false); - agentRunHistoryLoadingMoreRef.current = false; - return; - } - - try { - const result = await invoke( - 'list_local_project_files', - { projectPath: nextProjectPath }, - ); - const runFiles = result.files - .filter( - (file) => - file.kind === 'file' && - file.path.startsWith('.agent/runs/') && - file.path.endsWith('.json'), - ) - .sort( - (left, right) => - (right.modifiedAt ?? 0) - (left.modifiedAt ?? 0) || - right.path.localeCompare(left.path), - ); - const runFilesToLoad = runFiles.slice(0, AGENT_RUN_HISTORY_MAX_COUNT); - const firstPage = await readAgentRunHistoryItems( - invoke, - runFilesToLoad.slice(0, AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT), - nextProjectPath, - ); - setAgentRunHistory(firstPage); - setAgentRunHistoryFiles(runFilesToLoad); - setAgentRunHistoryOverflowCount( - Math.max(0, runFiles.length - runFilesToLoad.length), - ); - setAgentRunHistoryVisibleCount(AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT); - setAgentRunHistoryLoadingMore(false); - agentRunHistoryLoadingMoreRef.current = false; } catch { - setAgentRunHistory([]); - setAgentRunHistoryFiles([]); - setAgentRunHistoryOverflowCount(0); - setAgentRunHistoryVisibleCount(AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT); - setAgentRunHistoryLoadingMore(false); - agentRunHistoryLoadingMoreRef.current = false; + return null; } } @@ -10763,7 +1862,7 @@ export function App({ agentRuntimeResumeProjectPathRef.current = null; return; } - if (directCodexProductRuntime) { + if (directProjectMode) { agentRuntimeResumeProjectPathRef.current = nextProjectPath; setAgentRuntimeById({}); return; @@ -10815,30 +1914,20 @@ export function App({ agentRuntimeStateFromResult(runtimeResult), ); } - setAgentRunStatus('已确认恢复 Agent Runtime 任务'); }) - .catch((resumeError) => { + .catch(() => { if (localProjectPathRef.current !== nextProjectPath) { return; } agentRuntimeResumeProjectPathRef.current = null; - setAgentRunStatus( - `Agent Runtime 恢复失败:${ - resumeError instanceof Error - ? resumeError.message - : String(resumeError) - }`, - ); }); }, ); - setAgentRunStatus('等待确认恢复 Agent Runtime 任务'); } else if (message.includes('项目权限策略拒绝执行:agent.resume')) { agentRuntimeResumeProjectPathRef.current = nextProjectPath; markProjectPolicyDenied('agent.resume', message); } else { agentRuntimeResumeProjectPathRef.current = null; - setAgentRunStatus(`Agent Runtime 恢复失败:${message}`); } } } @@ -10853,32 +1942,6 @@ export function App({ for (const runtimeResult of runtimes) { nextRuntimes.push(agentRuntimeStateFromResult(runtimeResult)); } - const supervisorRuntimeIndex = nextRuntimes.findIndex( - (runtime) => runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID, - ); - const persistedSupervisorRuntime = - supervisorRuntimeIndex >= 0 - ? nextRuntimes[supervisorRuntimeIndex]! - : null; - if (projectSupervisorOnly && persistedSupervisorRuntime) { - const currentRuntime = projectSupervisorRuntimeRef.current; - if ( - !currentRuntime || - persistedSupervisorRuntime.updatedAt >= currentRuntime.updatedAt - ) { - if (persistedSupervisorRuntime.sessionId) { - projectSupervisorSessionIdRef.current = - persistedSupervisorRuntime.sessionId; - setProjectSupervisorSessionId(persistedSupervisorRuntime.sessionId); - } - updateProjectSupervisorRuntime(persistedSupervisorRuntime); - updateProjectSupervisorResponseStream( - runtimes[supervisorRuntimeIndex]?.responseStream, - persistedSupervisorRuntime, - ); - setProjectSupervisorRuntimeError(''); - } - } setAgentRuntimeById((current) => nextRuntimes.reduce( (next, runtime) => mergeAgentRuntimeStateIntoMap(next, runtime, true), @@ -10896,9 +1959,7 @@ export function App({ nextProjectPath = resolveChatProjectPath(localProject) ?? '', ) { if (!nextProjectPath) { - setAgentRunStatus('请先初始化本地项目'); await refreshAgentRuntimes(nextProjectPath); - await refreshAgentRunHistory(nextProjectPath); return null; } const summary = await loadAgentRunTraceFile( @@ -10906,108 +1967,9 @@ export function App({ nextProjectPath, ); await refreshAgentRuntimes(nextProjectPath); - await refreshAgentRunHistory(nextProjectPath); return summary; } - async function handleAgentRunTraceRefresh(skipPolicyConfirm = false) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAgentRunStatus('需要在 Tauri App 内运行'); - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setAgentRunStatus('请先初始化本地项目'); - return; - } - try { - if (!skipPolicyConfirm) { - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - if (policyView.policy.confirmCommands.includes('agent.trace_read')) { - requestProjectPolicyConfirmation( - 'agent.trace_read', - nextProjectPath, - `读取 ${nextProjectPath} 的最近 Agent run trace`, - () => void handleAgentRunTraceRefresh(true), - ); - setAgentRunStatus('等待确认'); - return; - } - } - await refreshAgentRunTrace(nextProjectPath); - } catch (error) { - setAgentRunStatus(error instanceof Error ? error.message : String(error)); - } - } - - async function scheduleReadyAgentTasks(skipPolicyConfirm = false) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAgentRunStatus('需要在 Tauri App 内运行'); - return; - } - const nextProjectPath = resolveChatProjectPath(localProject); - if (!nextProjectPath) { - setAgentRunStatus('请先初始化本地项目'); - return; - } - try { - if (!skipPolicyConfirm) { - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath: nextProjectPath }, - ); - if ( - policyView.policy.confirmCommands.includes('agent.schedule_ready') - ) { - requestProjectPolicyConfirmation( - 'agent.schedule_ready', - nextProjectPath, - `调度 ${nextProjectPath} 中已 Ready 的 Agent 任务`, - () => void scheduleReadyAgentTasks(true), - ); - setAgentRunStatus('等待确认调度 Ready 任务'); - return; - } - } - setAgentRunStatus('正在调度 Ready 任务'); - const scheduledRuntimes = await invoke( - 'schedule_game_creator_agent_ready_tasks', - { projectPath: nextProjectPath, limit: 16 }, - ); - for (const runtimeResult of scheduledRuntimes) { - rememberAgentRuntimeState(agentRuntimeStateFromResult(runtimeResult)); - } - await refreshManifest(nextProjectPath); - await refreshAgentRuntimes(nextProjectPath); - const message = - scheduledRuntimes.length > 0 - ? `已调度 ${scheduledRuntimes.length} 个 Ready 任务。` - : '没有可调度的 Ready 任务。'; - setAgentRunStatus(message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAgentRunStatus(message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } - } - - const agentStatusCards = deriveAgentStatusCards( - manifest, - agentRunTrace ?? agentRunHistory[0]?.trace ?? null, - agentRuntimeById, - ); const professionalResultCandidates = taskRowsFromManifest(manifest).map( (task) => ({ agentId: agentConversationId(task), @@ -11021,7 +1983,7 @@ export function App({ .join('|'); useEffect(() => { const nextProjectPath = localProject?.projectPath; - if (!projectSupervisorOnly || !nextProjectPath || !onManifestChange) { + if (!nextProjectPath || !onManifestChange) { return; } const invoke = resolveTauriInvoke(); @@ -11050,7 +2012,7 @@ export function App({ onManifestChange(nextProjectPath, currentManifest, { projectId: currentManifest.projectId, revision: after.revision, - source: 'supervisor', + source: 'chat', }); } return; @@ -11059,18 +2021,12 @@ export function App({ return () => { cancelled = true; }; - }, [ - localProject?.projectPath, - manifest, - onManifestChange, - projectSupervisorOnly, - ]); + }, [localProject?.projectPath, manifest, onManifestChange]); useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; if ( - !projectSupervisorOnly || - directCodexProductRuntime || + directProjectMode || !invoke || !nextProjectPath || professionalResultCandidates.length === 0 @@ -11152,55 +2108,27 @@ export function App({ // The semantic candidate key replaces the freshly allocated candidates array. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - directCodexProductRuntime, + directProjectMode, localProject?.projectPath, professionalResultCandidateKey, - projectSupervisorOnly, - projectSupervisorRuntime?.runId, ]); useEffect(() => { - if (!projectSupervisorOnly || !onAgentRuntimeSummariesChange) { + if (!onAgentRuntimeSummariesChange) { return; } + // 工作台壳不再持有任何根 Runtime:普通项目的运行态由 `DirectProjectChatView` + // 自己的订阅拥有,策划链路只投影设计 Agent 会话。这里保留回调契约,根 Runtime + // 传 null,运行态叠加交给各入口自己的事实源。 onAgentRuntimeSummariesChange( - projectAgentRuntimeSummaries( - manifest, - projectSupervisorRuntime, - agentRuntimeById, - ), + projectAgentRuntimeSummaries(manifest, null, agentRuntimeById), ); - }, [ - agentRuntimeById, - manifest, - onAgentRuntimeSummariesChange, - projectSupervisorOnly, - projectSupervisorRuntime, - ]); + }, [agentRuntimeById, manifest, onAgentRuntimeSummariesChange]); useEffect(() => { - if (!projectSupervisorOnly || !onAgentResultsChange) { + if (!onAgentResultsChange) { return; } onAgentResultsChange(Object.values(professionalAgentResultsById)); - }, [ - onAgentResultsChange, - professionalAgentResultsById, - projectSupervisorOnly, - ]); - const mainProjectSummary = localProject - ? summarizeMainProjectHeader(manifest, agentStatusCards) - : null; - const visibleAgentRunHistory = agentRunHistory.slice( - 0, - agentRunHistoryVisibleCount, - ); - const visibleMainProjectFiles = projectFiles - .filter((file) => file.kind === 'file' && !file.path.startsWith('.agent/')) - .slice(0, 8); - const visibleMainProjectAssets = manifest.assets - .filter( - (asset) => asset.localPath && !asset.localPath.startsWith('.agent/'), - ) - .slice(0, 8); + }, [onAgentResultsChange, professionalAgentResultsById]); const chatProjectAssets = manifest.assets.filter( (asset) => asset.localPath && !asset.localPath.startsWith('.agent/'), ); @@ -11209,70 +2137,6 @@ export function App({ // 传 `null` 表示回退到 manifest 中最新的版本。 const chatProjectVersions = manifest.versions ?? []; const chatActiveVersionId = activeVersionId; - const visibleMainProjectCheckpoints = projectCheckpoints.slice(0, 5); - const currentProjectTitle = localProject - ? manifest.name.trim() || projectNameFromPath(localProject.projectPath) - : seedManifest.name; - useEffect(() => { - if (!supervisorChatOnly) { - return; - } - setWindowTitle(localProject ? currentProjectTitle : undefined); - return () => setWindowTitle(undefined); - }, [currentProjectTitle, localProject, setWindowTitle, supervisorChatOnly]); - const agentRunHistoryOmittedCount = - Math.max(0, agentRunHistoryFiles.length - agentRunHistoryVisibleCount) + - agentRunHistoryOverflowCount; - const canShowMoreAgentRunHistory = - agentRunHistoryVisibleCount < agentRunHistoryFiles.length; - - async function showMoreAgentRunHistory() { - const invoke = resolveTauriInvoke(); - const nextProjectPath = resolveChatProjectPath(localProject); - if (!invoke || agentRunHistoryLoadingMoreRef.current) { - return; - } - if (!nextProjectPath) { - setAgentRunStatus('请先初始化本地项目'); - return; - } - const nextVisibleCount = Math.min( - agentRunHistoryVisibleCount + AGENT_RUN_HISTORY_VISIBLE_STEP, - agentRunHistoryFiles.length, - ); - const nextFiles = agentRunHistoryFiles.slice( - agentRunHistoryVisibleCount, - nextVisibleCount, - ); - agentRunHistoryLoadingMoreRef.current = true; - setAgentRunHistoryLoadingMore(true); - try { - const nextItems = await readAgentRunHistoryItems( - invoke, - nextFiles, - nextProjectPath, - ); - setAgentRunHistory((current) => - [...current, ...nextItems].sort( - (left, right) => right.trace.updatedAt - left.trace.updatedAt, - ), - ); - setAgentRunHistoryVisibleCount(nextVisibleCount); - } finally { - agentRunHistoryLoadingMoreRef.current = false; - setAgentRunHistoryLoadingMore(false); - } - } - - function handleAgentRunHistoryScroll(event: UIEvent) { - if (!canShowMoreAgentRunHistory) { - return; - } - const target = event.currentTarget; - if (target.scrollHeight - target.scrollTop - target.clientHeight <= 24) { - void showMoreAgentRunHistory(); - } - } const visibleMessages = latestVisibleItems( messages, @@ -11282,110 +2146,13 @@ export function App({ 0, messages.length - visibleMessages.length, ); - const hasEarlierConversationMessages = - hiddenConversationCount > 0 || - (directCodexProductRuntime && directHistoryHasMore); - const projectSupervisorTransientReply = - projectSupervisorResponseStream?.accumulatedText.trim() ?? ''; - const projectSupervisorNeedsUserInput = agentRuntimeNeedsUserInput( - projectSupervisorRuntime, - ); - const projectSupervisorHasConversationControls = Boolean( - projectSupervisorRuntime?.pendingToolAction || - projectSupervisorRuntime?.userInputRequest || - projectSupervisorNeedsUserInput, - ); - const visibleAgentConversationMessages = latestVisibleItems( - agentConversationMessages, - agentConversationVisibleCount, - ); - const hiddenAgentConversationCount = Math.max( - 0, - agentConversationMessages.length - visibleAgentConversationMessages.length, - ); - const selectedAgentLlmStatus = selectedAgent - ? formatAgentDialogLlmStatus(llmConfigStatus, selectedAgent) - : null; - const selectedAgentLlmWarning = selectedAgent - ? formatAgentLlmConfigWarning(llmConfigStatus, selectedAgent) - : null; - const selectedAgentNeedsUserInput = agentRuntimeNeedsUserInput( - agentConversationRuntime, - ); - const selectedAgentSteerRuntime = selectedAgent - ? matchingAgentRuntimeForSteer( - [agentConversationRuntime], - selectedAgent.id, - agentConversationSessionId, - ) - : null; - + const hasEarlierConversationMessages = hiddenConversationCount > 0; async function showEarlierConversationMessages() { - if (directCodexProductRuntime && directHistoryHasMore) { - const invoke = resolveTauriInvoke(); - const projectPath = localProject?.projectPath; - if (invoke && projectPath && !directHistoryLoadingRef.current) { - directHistoryLoadingRef.current = true; - try { - // 一次点击连拉,直到出现新回合或取不动为止:一屏全是工具卡片 / 思考文本时, - // 单发一页会让用户点了"显示更早"却看不到任何变化。 - const pages = await readDirectHistoryPages({ - existingEntries: selectDirectChatEntries(directThreadChat), - beforeItemId: directHistoryOldestItemIdRef.current, - readSlice: (beforeItemId) => - invoke( - 'read_direct_project_history_slice', - beforeItemId - ? { - projectPath, - beforeItemId, - limit: CONVERSATION_VISIBLE_STEP, - } - : { projectPath, limit: CONVERSATION_VISIBLE_STEP }, - ), - }); - if (localProjectPathRef.current !== projectPath) { - return; - } - // 连拉到的页一次性进聊天 reducer:与运行态同形、同身份,重复读取只补不重。 - setDirectThreadChat((state) => - mergeDirectHistoryItems(state, pages.items), - ); - // 首页或中途读取失败时保留旧值:失败页没有可靠的 hasMore,不能因为 - // 一次瞬时 IO 抖动把「显示更早」按钮永久收掉。 - if (!pages.error) { - setDirectHistoryHasMore(pages.hasMore); - } - directHistoryOldestItemIdRef.current = - pages.firstItemId ?? directHistoryOldestItemIdRef.current; - if (pages.error) { - // 已经取到的页照常进视图,失败只走下面同一个报错出口。 - throw pages.error; - } - } catch (error) { - setWorkspaceStatus( - `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, - ); - } finally { - directHistoryLoadingRef.current = false; - } - } - return; - } setConversationVisibleCount((current) => Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP), ); } - function showEarlierAgentConversationMessages() { - setAgentConversationVisibleCount((current) => - Math.min( - agentConversationMessages.length, - current + CONVERSATION_VISIBLE_STEP, - ), - ); - } - function handleConversationScroll(event: UIEvent) { if (!hasEarlierConversationMessages) { return; @@ -11395,489 +2162,119 @@ export function App({ } } - function handleSupervisorChatScroll(event: UIEvent) { + function handlePlanningChatScroll(event: UIEvent) { handleConversationScroll(event); const messageList = event.currentTarget; const distanceFromBottom = messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight; - supervisorChatShouldFollowLatestRef.current = + planningChatShouldFollowLatestRef.current = distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD; } - function handleAgentConversationScroll(event: UIEvent) { - if (hiddenAgentConversationCount === 0) { - return; - } - if (event.currentTarget.scrollTop <= 24) { - showEarlierAgentConversationMessages(); - } - } - - useEffect(() => { - if (!selectedAgent) { - return; - } - const refreshedAgent = agentStatusCards.find( - (agent) => agent.id === selectedAgent.id, - ); - if (!refreshedAgent) { - closeAgentConversation(); - return; - } - if (!sameAgentStatusCard(selectedAgent, refreshedAgent)) { - setSelectedAgent(refreshedAgent); - } - }, [agentStatusCards, selectedAgent]); - /** - * 发起一轮 direct-codex 对话回合:提交与队列出队共用同一条路径,避免两条入口的 - * 消息落盘/回合 id/附件参数走样。 + * 策划输入盒导入本地文件:文件直接写进策划工作区(`references/`),既不进游戏资源 + * 清单,也不生成回合附件;导入期间通知工作台壳暂缓切换游戏运行态。 */ - function startDirectCodexConversationTurn(input: { - prompt: string; - attachments?: DirectCodexTurnAttachment[]; - references?: ChatReference[]; - content?: DirectCodexUserContentPart[]; - }) { - const clientTurnId = createDirectCodexConversationTurnId(); - supervisorChatShouldFollowLatestRef.current = true; - setMessages((current) => [ - ...current, - { - role: 'user', - text: input.prompt, - runtimeOwned: true, - messageId: directCodexConversationMessageId(clientTurnId, 'user'), - updatedAt: Date.now(), - }, - ]); - void executeChatAgentReply({ - prompt: input.prompt, - clientTurnId, - attachments: input.attachments?.length ? input.attachments : undefined, - references: input.references, - userItem: chatComposerDraftToDirectCodexUserItem( - { - text: input.prompt, - references: input.references ?? [], - content: input.content ?? [], - }, - directCodexConversationMessageId(clientTurnId, 'user'), - ), - }); - } - - /** - * 输入盒上传本地文件:复用首页建项目那条 `upload_local_asset` 链路把文件写进项目, - * 再以**项目相对路径**生成回合附件(绝对路径会被 Rust 侧附件规则判为失败)。 - */ - async function handleChatComposerUploadFiles(files: readonly File[]) { + async function handleDesignComposerUploadFiles(files: readonly File[]) { const invoke = resolveTauriInvoke(); const nextProjectPath = resolveChatProjectPath(localProject); if (!invoke || !nextProjectPath) { - setChatAttachmentNotice('需要先打开本地项目,才能上传文件'); + setChatFileImportNotice('需要先打开本地项目,才能导入文件'); return; } - const remaining = MAX_CHAT_COMPOSER_ATTACHMENTS - chatAttachments.length; - const accepted = files.slice(0, Math.max(remaining, 0)); - if (accepted.length === 0) { - setChatAttachmentNotice( - `最多同时携带 ${MAX_CHAT_COMPOSER_ATTACHMENTS} 个附件,请先移除已有附件`, - ); + if (chatFilesImporting || files.length === 0) { return; } - setChatAttachmentNotice('正在上传文件'); + setChatFilesImporting(true); + onDesignFilesImportingChange?.(nextProjectPath, true); + setChatFileImportNotice('正在导入文件'); try { - const imported = await uploadLocalFilesAsAttachments( - invoke, - nextProjectPath, - accepted, - ); - const attachments = toDirectCodexTurnAttachments(imported); - if (localProjectPathRef.current !== nextProjectPath) { - return; - } - setChatAttachments((current) => - [...current, ...attachments].slice(0, MAX_CHAT_COMPOSER_ATTACHMENTS), - ); - const failed = attachments.filter( - (attachment) => attachment.status === 'failed', - ); - setChatAttachmentNotice( - failed.length > 0 - ? `${failed.length} 个文件未能上传:${failed[0]?.name ?? ''}` - : `已上传 ${attachments.length} 个文件,将在下次发送时作为本轮附件`, - ); - void refreshManifest(nextProjectPath); - } catch (error) { + const notice = await importDesignFiles(invoke, nextProjectPath, files); if (localProjectPathRef.current === nextProjectPath) { - setChatAttachmentNotice( - error instanceof Error ? error.message : String(error), - ); + setChatFileImportNotice(notice); } - } - } - - function removeChatComposerAttachment(index: number) { - setChatAttachments((current) => - current.filter((_, currentIndex) => currentIndex !== index), - ); - } - - /** 回合运行中再次发送:进本地 FIFO 队列;队列满时拒绝并保留草稿,不静默丢消息。 */ - function enqueueChatTurnForRunningTurn(input: { - prompt: string; - attachments: DirectCodexTurnAttachment[]; - references: ChatReference[]; - content: DirectCodexUserContentPart[]; - }): boolean { - if (isChatTurnQueueFull(chatTurnQueueRef.current)) { - setChatComposerNotice(chatQueueFullNotice()); - return false; - } - queuedChatTurnSequenceRef.current += 1; - const turn = createQueuedChatTurn({ - id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`, - prompt: input.prompt, - attachments: input.attachments, - references: input.references, - content: input.content, - createdAt: Date.now(), - }); - const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn); - chatTurnQueueRef.current = nextQueue; - setChatTurnQueue(nextQueue); - setChatComposerNotice('已加入发送队列,当前回合结束后自动发送'); - return true; - } - - function cancelQueuedChatTurn(id: string) { - const nextQueue = removeQueuedChatTurn(chatTurnQueueRef.current, id); - chatTurnQueueRef.current = nextQueue; - setChatTurnQueue(nextQueue); - if (nextQueue.length === 0) { - setChatComposerNotice(''); - } - } - - /** 队首出队并立即发出:只在 DirectProject 收到 `turn.completed` 后调用。 */ - function dispatchNextQueuedChatTurn() { - if (chatAgentBusyRef.current || directTurnRunningRef.current) { - return; - } - directTurnCompletionPendingRef.current = false; - const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current); - if (!next) { - return; - } - chatTurnQueueRef.current = rest; - setChatTurnQueue(rest); - if (rest.length === 0) { - setChatComposerNotice(''); - } - startDirectCodexConversationTurn({ - prompt: next.prompt, - attachments: next.attachments, - references: next.references, - content: next.content, - }); - } - - /** - * 回合事件可能先于本地 invoke 的 finally 到达,因此先记 pending,等本地忙碌态复位后再出队。 - */ - useEffect(() => { - if (!directCodexProductRuntime) { - directTurnCompletionPendingRef.current = false; - previousDirectTurnRunningRef.current = directTurnRunning; - return; - } - const wasRunning = previousDirectTurnRunningRef.current; - previousDirectTurnRunningRef.current = directTurnRunning; - if (wasRunning && !directTurnRunning) { - directTurnCompletionPendingRef.current = true; - } - if ( - directTurnCompletionPendingRef.current && - !chatAgentBusy && - !directTurnRunning - ) { - directTurnCompletionPendingRef.current = false; - dispatchNextQueuedChatTurn(); - } - // 队列出队函数读取本轮 render 的项目输入;这里只由三个生命周期信号触发。 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatAgentBusy, directCodexProductRuntime, directTurnRunning]); - - /** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */ - async function handleCancelDirectCodexTurn() { - if (directCodexTurnCancelling) { - return; - } - const invoke = resolveTauriInvoke(); - const directProjectPath = resolveChatProjectPath(localProject); - // 繁忙判据与「终止」按钮的可见条件一致:本地 invoke 正在跑,或订阅说还有一条回合没结束。 - // 只认 `turn.started` 推导出来的 `directTurnRunning` 会漏掉"刚提交、事件还没到"的窗口。 - if (!invoke || !directProjectPath || !supervisorChatBusy) { - setProjectSupervisorRuntimeError('当前没有正在运行的回合,无法终止。'); - return; - } - setDirectCodexTurnCancelling(true); - setChatComposerNotice('正在终止当前回合'); - try { - const result = await invoke( - 'cancel_direct_codex_turn', - { projectPath: directProjectPath }, - ); - const message = result?.message?.trim(); - if (result?.outcome === 'released') { - // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧强制释放了 - // 守卫并补了终态事件;这里按同一个收口函数同步把界面复位,不等 IPC 通知。 - // 时刻取宿主观测到的这一刻:终止返回就是这一轮的终态,原生随后补的事件若先到, - // 收口已经是冻结值,不会被抬高,也不会复活成"永远运行中"。 - // 但取消回包可能晚于新回合的开始:先核对身份(clientTurnId 对应的本轮用户条目), - // 只收口确实是这一轮的那一次,避免在新回合的回调里把旧轮的时间盖上来。 - const cancelledUserItemId = result.clientTurnId - ? directCodexConversationMessageId(result.clientTurnId, 'user') - : ''; - setDirectThreadChat((state) => - directThreadTurnMatchesUser(state, cancelledUserItemId) - ? finishDirectThreadTurn(state, Date.now()) - : state, - ); - setChatAgentBusy(false); - setProjectSupervisorRuntimeError(''); - setChatComposerNotice( - message ?? '已结束这一轮占用,可以直接重新发送消息', - ); - return; - } - if (message) { - setChatComposerNotice(message); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setProjectSupervisorRuntimeError(`终止失败:${message}`); - setChatComposerNotice(''); } finally { - setDirectCodexTurnCancelling(false); + setChatFilesImporting(false); + onDesignFilesImportingChange?.(nextProjectPath, false); } } - function handleProjectSupervisorOnlySubmit( - event: FormEvent, - ) { + function handlePlanningChatSubmit(event: FormEvent) { event.preventDefault(); - const prompt = chatInput.trim(); - const references = chatReferences; - const pendingAttachments = chatAttachments; - if ( - !directCodexProductRuntime && - supervisorChatOnly && - prompt.startsWith('/') - ) { - void handleChatSubmit(event); + const content = chatComposerRef.current?.getDraft().content ?? []; + const legacyContent = directCodexContentToLegacyContentDto( + content, + manifest.assets, + ); + const canonicalPrompt = legacyContent.text; + if (!hasMeaningfulDirectCodexContent(content)) { return; } - if ( - !directCodexProductRuntime && - agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current) - ) { - setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); + if (chatAgentBusy) { return; } - if ( - !prompt && - references.length === 0 && - chatContent.length === 0 && - pendingAttachments.length === 0 - ) { + if (!canonicalPrompt) { return; } - if (supervisorChatBusy) { - // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 - // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 - if (directCodexProductRuntime) { - const enqueued = enqueueChatTurnForRunningTurn({ - prompt, - attachments: pendingAttachments, - references, - content: chatContent, - }); - if (enqueued) { - setChatInput(''); - setChatContent([]); - setChatReferences([]); - setChatAttachments([]); - setChatAttachmentNotice(''); - } - } - return; - } - if (directCodexProductRuntime && prompt === '/history') { - const nextProjectPath = requireChatProjectForUserAction(); - if (!nextProjectPath) { - return; - } - setChatInput(''); - setChatReferences([]); - setChatContent([]); - void loadProjectConversation(nextProjectPath, false, 'replace'); - return; - } - if (supervisorChatOnly || directCodexProductRuntime || designAgentActive) { - supervisorChatShouldFollowLatestRef.current = true; - } - if (directCodexProductRuntime) { - // 待发附件随本轮提交一次性交给回合;提交后清空,避免同一批附件重复挂到下一轮。 - setChatInput(''); - setChatReferences([]); - setChatAttachments([]); - setChatContent([]); - setChatAttachmentNotice(''); - setChatComposerNotice(''); - startDirectCodexConversationTurn({ - prompt, - attachments: pendingAttachments, - references, - content: chatContent, - }); - return; - } - setChatInput(''); - setChatReferences([]); - setChatContent([]); + planningChatShouldFollowLatestRef.current = true; + const clientTurnId = createAgentChatRunId('design-agent-turn'); + chatComposerRef.current?.clear(); setMessages((current) => [ ...current, { role: 'user', - text: prompt, + text: canonicalPrompt, runtimeOwned: true, updatedAt: Date.now(), }, ]); - void executeChatAgentReply({ prompt, references }); + void executeChatAgentReply({ prompt: canonicalPrompt, clientTurnId }); } - const visibleProfessionalAgentCards = agentStatusCards.filter( - (agent) => - agent.id !== PROJECT_SUPERVISOR_AGENT_ID && - (agent.runtimeStatus !== null || agent.hasRecentEvidence), - ); const useDesignAgentSurface = Boolean(designAgentView) || designAgentActive; - if (projectSupervisorOnly && supervisorChatOnly) { - const supervisorProjectPath = - localProject?.projectPath || initialProjectPath || projectPath; + if (directProjectMode) { + // 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由 + // 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。 return ( - setRuntimeConfigOpen(false)} - onConfirmConfirmation={confirmUiCommand} - onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)} - onScroll={handleSupervisorChatScroll} - onShowEarlierMessages={showEarlierConversationMessages} - hasEarlierConversationMessages={hasEarlierConversationMessages} - onSubmit={handleProjectSupervisorOnlySubmit} - onToolAction={handleProjectSupervisorToolAction} - onUserInput={handleProjectSupervisorUserInput} - pendingConfirmation={pendingUiConfirmation} - pendingCommand={pendingCommand} - onCancelPendingCommand={handlePendingCommandCancel} - onConfirmPendingCommand={() => void handlePendingCommandConfirm()} - projectPath={supervisorProjectPath} - runtime={projectSupervisorRuntime} - runtimeConfigOpen={runtimeConfigOpen} - runtimeError={projectSupervisorRuntimeError} - transientReply={ - designAgentActive - ? designAgentTransientReply - : projectSupervisorTransientReply - } - hasConversationControls={projectSupervisorHasConversationControls} - hiddenConversationCount={hiddenConversationCount} - needsUserInput={projectSupervisorNeedsUserInput} - visibleMessages={visibleMessages} - workspaceStatus={workspaceStatus} - expectedRunId={projectSupervisorExpectedRunId} - versions={chatProjectVersions} + ); } - if (projectSupervisorOnly) { - return ( - + void handleCancelDirectCodexTurn()} - onRemoveAttachment={removeChatComposerAttachment} - onUploadFiles={(files) => void handleChatComposerUploadFiles(files)} - queuedTurns={chatTurnQueue} - turnCancelling={directCodexTurnCancelling} composerRef={chatComposerRef} chatProjectAssets={chatProjectAssets} - directCodex={directCodexProductRuntime} - directTurnRunning={directCodexProductRuntime && directTurnRunning} - directTurnStartedAt={directThreadChat.turnStartedAt} - directEntries={ - directCodexProductRuntime - ? selectDirectChatEntries(directThreadChat) - : [] - } hiddenConversationCount={hiddenConversationCount} hasEarlierConversationMessages={hasEarlierConversationMessages} - messagesRef={supervisorChatMessagesRef} - needsUserInput={ - directCodexProductRuntime ? false : projectSupervisorNeedsUserInput - } + messagesRef={planningChatMessagesRef} + needsUserInput={false} onCancelConfirmation={cancelUiCommandConfirmation} - onCancelPendingCommand={handlePendingCommandCancel} - onChatInputChange={handleChatComposerChange} onConfirmConfirmation={confirmUiCommand} - onConfirmPendingCommand={() => void handlePendingCommandConfirm()} - onScroll={handleSupervisorChatScroll} + onScroll={handlePlanningChatScroll} onShowEarlierMessages={showEarlierConversationMessages} - onSubmit={handleProjectSupervisorOnlySubmit} - pendingConfirmation={ - directCodexProductRuntime ? null : pendingUiConfirmation - } - pendingCommand={directCodexProductRuntime ? pendingCommand : null} + onSubmit={handlePlanningChatSubmit} + pendingConfirmation={pendingUiConfirmation} projectPath={localProject?.projectPath ?? projectPath} conversationMessages={messages} - transientReply={ - designAgentActive - ? designAgentTransientReply - : directCodexProductRuntime - ? '' - : projectSupervisorTransientReply - } + transientReply={designAgentTransientReply} showDesignReasoning={designAgentActive} designReasoning={designAgentReasoning} designReasoningEntries={ useDesignAgentSurface ? (designAgentView?.reasoningEntries ?? []) : [] } visibleMessages={visibleMessages} - visibleProfessionalAgentCards={visibleProfessionalAgentCards} - showProfessionalCollaboration={ - !directCodexProductRuntime && orchestrationMode === 'professional-dag' - } workspaceStatus={workspaceStatus} designView={useDesignAgentSurface ? designAgentView : null} onDesignApprove={ @@ -11896,7 +2293,6 @@ export function App({ designAgentReasoningTurnRef.current = { projectPath: nextProjectPath, clientTurnId, - text: '', }; designAgentPendingViewRef.current = null; setDesignAgentTransientReplyTarget(''); @@ -11935,7 +2331,7 @@ export function App({ ) { return; } - setProjectSupervisorRuntimeError(String(error)); + setProjectChatError(String(error)); }) .finally(() => { if ( @@ -11982,265 +2378,19 @@ export function App({ } : undefined } - runtime={projectSupervisorRuntime} - error={projectSupervisorRuntimeError} - runtimeByAgentId={agentRuntimeById} - controlBusy={ - directCodexProductRuntime ? supervisorChatBusy : chatAgentBusy - } - readOnly={directCodexProductRuntime} + error={projectChatError} + controlBusy={chatAgentBusy} + attachmentNotice={chatFileImportNotice} + importingFiles={chatFilesImporting} + onUploadFiles={(files) => void handleDesignComposerUploadFiles(files)} versions={chatProjectVersions} - professionalResultsByAgentId={professionalAgentResultsById} - onToolAction={handleProjectSupervisorToolAction} - onSupervisorRetry={handleProjectSupervisorRetry} - onProfessionalToolAction={handleProjectProfessionalAgentToolAction} - onProfessionalRetry={handleProjectProfessionalAgentRetry} - onUserInput={handleProjectSupervisorUserInput} /> - ); - } - - return ( -
- - {runtimeConfigOpen ? ( setRuntimeConfigOpen(false)} - onLog={(entry) => setCommandLog((current) => [...current, entry])} /> ) : null} - - {selectedAgent ? ( - - ) : null} - - {devMode ? ( -
- - -
- ) : null} -
+ ); } diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 62fbb6f0c..7cae7093c 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -9,15 +9,11 @@ export function createLocalProjectId(): string { return `local-project-${crypto.randomUUID()}`; } -export const AGENT_RUN_HISTORY_MAX_COUNT = 100; -export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; -export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20; export const CONVERSATION_VISIBLE_STEP = 20; /** 一次翻页操作最多连拉几页:见 ADR「分页锚点取原始条目 id」。 */ export const DIRECT_HISTORY_MAX_PAGES_PER_ACTION = 5; export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48; -export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor'; export const launcherNotifications: Array<{ label: string; detail: string; diff --git a/apps/ai-game-creator-shell/src/app/initialTurnClaims.ts b/apps/ai-game-creator-shell/src/app/initialTurnClaims.ts new file mode 100644 index 000000000..b5467f2bf --- /dev/null +++ b/apps/ai-game-creator-shell/src/app/initialTurnClaims.ts @@ -0,0 +1,22 @@ +/** + * 入口首轮需求的认领记录。 + * + * 首页/立项链路把同一条首轮需求带进工作台,DirectProject 与立项策划是同一页面上的 + * 不同入口;认领按页面保存并按「项目路径 + claimScope」去重,保证同一条需求只被一个 + * 入口发出。 + */ +const initialTurnClaimsByPage = new WeakMap>(); + +export function claimInitialTurnForPage(projectPath: string, scope = '') { + let claimedProjectPaths = initialTurnClaimsByPage.get(window); + if (!claimedProjectPaths) { + claimedProjectPaths = new Set(); + initialTurnClaimsByPage.set(window, claimedProjectPaths); + } + const claimKey = `${projectPath}\u0000${scope}`; + if (claimedProjectPaths.has(claimKey)) { + return false; + } + claimedProjectPaths.add(claimKey); + return true; +} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index d7d33af39..210cbd489 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -143,6 +143,8 @@ export type LauncherProjectContext = { startMode: ProjectStartMode | null; initialPrompt: string; attachments: LauncherImportedAttachment[]; + /** 仅用于工作区界面,不进入 Agent 消息。 */ + fileImportNotice?: string; recentRunStatus: string | null; recentRunStopReason: string | null; createdAt: number; @@ -1027,96 +1029,6 @@ export interface AgentStatusCard { export type AgentTaskGraphState = 'active' | 'carried' | 'ready'; -export type PendingCommand = - | { - id: 'game.generate_draft'; - prompt: string; - } - | { - id: 'game.run_local'; - } - | { - id: 'asset.upload'; - file: File; - } - | { - id: 'asset.register'; - localPath: string; - kind: GameCreationAppAssetKind; - mediaType: string; - } - | { - id: 'command.run_limited'; - commandId: string; - } - | { - id: 'project.create'; - projectPath: string; - } - | { - id: 'project.checkpoint'; - } - | { - id: 'project.export_package'; - } - | { - id: 'project.index'; - } - | { - id: 'project.restore'; - checkpointId: string; - } - | { - id: 'project.policy_write'; - policy: ProjectPermissionPolicy; - } - | { - id: 'preview.start'; - } - | { - id: 'preview.open'; - } - | { - id: 'agent.kill' | 'agent.retry' | 'agent.resume'; - detail?: string; - } - | { - id: 'memory.write'; - scope: MemoryScope; - content: string; - mode?: MemoryWriteMode; - } - | { - id: 'memory.delete'; - scope: MemoryScope; - } - | { - id: 'canvas.project_open'; - canvasProjectId: string; - } - | { - id: 'canvas.project_sync'; - canvasProjectId: string; - } - | { - id: 'canvas.asset_import'; - localPath: string; - canvasProjectId: string; - canvasAssetId: string; - canvasAssetObjectId?: string; - kind: GameCreationAppAssetKind; - mediaType: string; - } - | { - id: 'canvas.asset_generate'; - prompt: string; - } - | { - id: 'canvas.export_import'; - exportPath: string; - canvasProjectId: string; - }; - export interface PendingUiConfirmation { commandId: GameCreationAppCommandDescriptor['id']; detail: string; diff --git a/apps/ai-game-creator-shell/src/components/RichTextInput.tsx b/apps/ai-game-creator-shell/src/components/RichTextInput.tsx new file mode 100644 index 000000000..02c942e50 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/RichTextInput.tsx @@ -0,0 +1,102 @@ +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { + COMMAND_PRIORITY_HIGH, + type EditorState, + KEY_ENTER_COMMAND, + type Klass, + type LexicalNode, +} from 'lexical'; +import type { ReactElement, ReactNode, Ref } from 'react'; +import { useEffect } from 'react'; + +type RichTextInputProps = { + namespace: string; + nodes: Klass[]; + initialEditorState?: EditorState | null; + contentEditable?: ReactElement; + placeholder?: ReactElement; + containerClassName?: string; + containerRef?: Ref; + disabled?: boolean; + onChange?: (editorState: EditorState) => void; + onEnter?: () => void; + children?: ReactNode; +}; + +function SubmitOnEnter({ onEnter }: { onEnter?: () => void }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!onEnter) return undefined; + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event || event.shiftKey || event.isComposing) return false; + event.preventDefault(); + onEnter(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor, onEnter]); + + return null; +} + +function SetEditorEditable({ disabled }: { disabled: boolean }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + editor.setEditable(!disabled); + }, [disabled, editor]); + + return null; +} + +export default function RichTextInput({ + namespace, + nodes, + initialEditorState, + contentEditable = , + placeholder, + containerClassName, + containerRef, + disabled = false, + onChange, + onEnter, + children, +}: RichTextInputProps) { + return ( + { + throw error; + }, + }} + > +
+ + {children} + + + {onChange ? : null} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts index 268e61dbf..9f8ccaddf 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts @@ -1,2 +1 @@ export * from './model'; -export * from './panels'; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index c8baf7bb4..246b9ab14 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -2,132 +2,21 @@ import type { GameCreationAppManifest, GameCreationAppTaskState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { PROJECT_SUPERVISOR_AGENT_ID, seedManifest } from '../../app/constants'; +import { seedManifest } from '../../app/constants'; import type { - AgentConversationSessionListResult, - AgentConversationSessionRecord, - AgentGoalRecord, AgentRuntimeEventRecord, - AgentRuntimePendingToolActionSummary, AgentRuntimePlanStep, - AgentRuntimeResponseStream, AgentRuntimeResult, AgentRuntimeState, - AgentRuntimeSteerResult, AgentRuntimeTaskQueueSummary, AgentRuntimeTaskRecord, ChatMessage, - LocalConversationMessageRecord, - LocalProjectKind, - TauriInvoke, } from '../../app/types'; -const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX = 'runtime-public-status-'; -const AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX = 'runtime-task-'; -const AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX = 'agent-steer-'; -const AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN = /^[0-9a-f]{32}$/; - -export type ProjectSupervisorRuntimeSubmission = { - runProfile: 'standard' | 'autonomous-game-build'; - source: 'project-supervisor-gui'; -}; - -export function resolveProjectSupervisorRuntimeSubmission({ - workspaceProjectKind, - orchestrationMode, - supervisorChatOnly, -}: { - workspaceProjectKind: LocalProjectKind; - orchestrationMode: 'single-supervisor' | 'professional-dag'; - supervisorChatOnly: boolean; -}): ProjectSupervisorRuntimeSubmission { - if (workspaceProjectKind === 'godot' || supervisorChatOnly) { - return { - runProfile: 'standard', - source: 'project-supervisor-gui', - }; - } - if (orchestrationMode !== 'single-supervisor') { - return { - runProfile: 'autonomous-game-build', - source: 'project-supervisor-gui', - }; - } - return { - runProfile: 'standard', - source: 'project-supervisor-gui', - }; -} - -function agentRuntimeMessageCorrelationId( - messageId: string | null | undefined, -) { - const normalized = messageId?.trim() ?? ''; - const prefix = normalized.startsWith( - AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX, - ) - ? AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX - : normalized.startsWith(AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX) - ? AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX - : normalized.startsWith(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX) - ? AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX - : null; - if (!prefix) { - return null; - } - const correlationId = normalized.slice(prefix.length).split('-', 1)[0] ?? ''; - return AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN.test(correlationId) - ? correlationId - : null; -} - -export function createLocalConversationDraftMessage( - content: string, - updatedAt = Date.now(), -): LocalConversationMessageRecord { - return { - schemaVersion: 'game-creator-conversation.v1', - role: 'assistant', - content, - agentId: null, - updatedAt, - }; -} export function createAgentChatRunId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -export function parseAgentGoalItems(value: string) { - return value - .split(/\r?\n/) - .map((item) => item.trim()) - .filter(Boolean); -} - -export function formatAgentConversationSessionMeta( - session: AgentConversationSessionRecord, - sessions: AgentConversationSessionRecord[], - activeSessionId: string | null, -) { - const details = [String(session.messageCount)]; - if (session.sessionId === activeSessionId) { - details.push('活动'); - } - if (session.forkedFromSessionId) { - const source = sessions.find( - (candidate) => candidate.sessionId === session.forkedFromSessionId, - ); - const sourceLabel = source?.title ?? session.forkedFromSessionId; - details.push( - session.forkedMessageCount === null || - session.forkedMessageCount === undefined - ? `分支自 ${sourceLabel}` - : `分支自 ${sourceLabel}(${session.forkedMessageCount} 条)`, - ); - } - return details.join(' · '); -} - export function agentRuntimePlanStepsFromPlan( plan: string[], ): AgentRuntimePlanStep[] { @@ -390,30 +279,6 @@ export function normalizeAgentRuntimeState( }; } -export function mergeAgentGoalRecordFromRuntime( - goal: AgentGoalRecord | null, - runtime: AgentRuntimeState, -) { - if ( - !goal || - !runtime.goalId || - goal.goalId !== runtime.goalId || - goal.agentId !== runtime.agentId || - goal.sessionId !== runtime.sessionId - ) { - return goal; - } - return { - ...goal, - revision: runtime.goalRevision ?? goal.revision, - status: runtime.goalStatus ?? goal.status, - outcome: runtime.goalOutcome ?? goal.outcome, - constraints: runtime.goalConstraints ?? goal.constraints, - verification: runtime.goalVerification ?? goal.verification, - updatedAt: Math.max(goal.updatedAt, runtime.updatedAt), - }; -} - export function mergeAgentRuntimeStateIntoMap( current: Record, incoming: AgentRuntimeState, @@ -503,149 +368,11 @@ export function agentRuntimeStateFromResult( ); } -export function normalizeProjectSupervisorResponseStream( - stream: AgentRuntimeResponseStream | null | undefined, - runtime: AgentRuntimeState, -) { - if (!stream) { - return null; - } - const integerFields = [ - stream.appliedSteerCursor, - stream.responseRevision, - stream.sequence, - stream.startedAt, - stream.updatedAt, - ]; - if ( - stream.schemaVersion !== 'game-creator-runtime-response-stream.v1' || - stream.agentId !== PROJECT_SUPERVISOR_AGENT_ID || - stream.agentId !== runtime.agentId || - stream.taskId !== runtime.taskId || - stream.sessionId !== runtime.sessionId || - stream.runId !== runtime.runId || - stream.requestKind !== 'final-reply' || - !stream.requestSlot.trim() || - integerFields.some( - (value) => - typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0, - ) || - stream.startedAt <= 0 || - stream.updatedAt < stream.startedAt || - typeof stream.accumulatedText !== 'string' || - Array.from(stream.accumulatedText).length > 32_000 || - /<\/?think>/i.test(stream.accumulatedText) - ) { - return null; - } - if ( - typeof runtime.appliedSteerCursor === 'number' && - stream.appliedSteerCursor !== runtime.appliedSteerCursor - ) { - return null; - } - if ((runtime.queuedSteerCount ?? 0) > 0) { - return null; - } - if ( - typeof runtime.loopIteration === 'number' && - Number.isSafeInteger(runtime.loopIteration) && - stream.requestSlot !== - `final-reply-loop-${runtime.loopIteration}-revision-${stream.responseRevision}` - ) { - return null; - } - if ( - (stream.status === 'streaming' || stream.status === 'ready') && - (runtime.status !== 'running' || - !['response', 'finalizing'].includes(runtime.phase)) - ) { - return null; - } - if (stream.status === 'ready' && !stream.accumulatedText.trim()) { - return null; - } - return stream; -} - -export function sameProjectSupervisorResponseStream( - left: AgentRuntimeResponseStream, - right: AgentRuntimeResponseStream, -) { - return ( - left.agentId === right.agentId && - left.taskId === right.taskId && - left.sessionId === right.sessionId && - left.runId === right.runId && - left.requestKind === right.requestKind && - left.requestSlot === right.requestSlot && - left.appliedSteerCursor === right.appliedSteerCursor && - left.responseRevision === right.responseRevision - ); -} - /** * Durable identity for one final-reply response. The response text is * intentionally excluded so two turns with identical wording remain * distinct messages in the conversation. */ -export function projectSupervisorResponseStreamIdentity( - stream: Pick< - AgentRuntimeResponseStream, - 'runId' | 'requestSlot' | 'responseRevision' - >, -) { - return [stream.runId, stream.requestSlot, stream.responseRevision].join( - '\u001f', - ); -} - -export function mergeProjectSupervisorResponseStream( - current: AgentRuntimeResponseStream | null, - incoming: AgentRuntimeResponseStream | null | undefined, - runtime: AgentRuntimeState, -) { - const currentForRuntime = normalizeProjectSupervisorResponseStream( - current, - runtime, - ); - if (!incoming) { - return null; - } - const next = normalizeProjectSupervisorResponseStream(incoming, runtime); - if (!next) { - return currentForRuntime; - } - if (next.status !== 'streaming' && next.status !== 'ready') { - return null; - } - if ( - !currentForRuntime || - !sameProjectSupervisorResponseStream(currentForRuntime, next) - ) { - return next; - } - if (next.sequence <= currentForRuntime.sequence) { - return currentForRuntime; - } - return next; -} - -export function conversationContainsProjectSupervisorResponseStream( - messages: LocalConversationMessageRecord[], - stream: AgentRuntimeResponseStream | null | undefined, -) { - return Boolean( - stream?.accumulatedText.trim() && - messages.some( - (message) => - message.role === 'assistant' && - message.content === stream.accumulatedText && - message.updatedAt >= stream.startedAt, - ), - ); -} - export function agentRuntimeWaitingOnFromPhase(phase: string) { switch (phase) { case 'planning': @@ -708,36 +435,6 @@ export function agentRuntimeNextStepFromPhase(phase: string) { } } -export function agentRuntimeStartStatus(result: AgentRuntimeResult) { - const pendingTask = (result.recentTasks ?? []).find( - (task) => task.status === 'pending', - ); - return pendingTask - ? `已加入后台队列:${pendingTask.runId}` - : `已启动后台任务:${result.state.runId}`; -} - -export function agentRuntimeStartedRunId( - result: AgentRuntimeResult, - requestedRunId: string, -) { - if (result.acceptedRunId?.trim()) { - return result.acceptedRunId.trim(); - } - if ( - result.recentTasks?.some((task) => task.runId === requestedRunId) || - result.state.runId === requestedRunId - ) { - return requestedRunId; - } - return ( - result.taskQueue?.latestRunId ?? - result.state.taskQueue?.latestRunId ?? - result.state.runId ?? - requestedRunId - ); -} - export function isAgentRuntimeTerminalState(runtime: AgentRuntimeState) { return ( ['completed', 'failed', 'cancelled', 'needs-reconciliation'].includes( @@ -795,202 +492,6 @@ export function agentRuntimeNeedsUserInput( ); } -export const AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL = 'user.input_request'; - -export function agentRuntimePendingActionHasDedicatedCard( - action: AgentRuntimePendingToolActionSummary | null | undefined, -) { - return action?.tool === AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL; -} - -export function matchingAgentRuntimeForSteer( - runtimes: Array, - agentId: string, - sessionId: string | null, - requestedRunProfile: NonNullable< - AgentRuntimeState['runProfile'] - > = 'standard', - requestedSource?: string, -) { - if (!sessionId) { - return null; - } - return ( - runtimes.find( - (runtime) => - runtime?.agentId === agentId && - runtime.sessionId === sessionId && - (runtime.runProfile ?? 'standard') === requestedRunProfile && - (!requestedSource || runtime.source === requestedSource) && - isAgentRuntimeSteerableState(runtime), - ) ?? null - ); -} - -export function projectSupervisorActiveSessionIdFromList( - sessionList: AgentConversationSessionListResult, -) { - if ( - !sessionList || - sessionList.agentId !== PROJECT_SUPERVISOR_AGENT_ID || - !Array.isArray(sessionList.sessions) - ) { - throw new Error('项目总控 Agent 会话列表返回格式无效'); - } - const activeSession = sessionList.activeSessionId - ? sessionList.sessions.find( - (session) => - session.sessionId === sessionList.activeSessionId && - session.archivedAt === null, - ) - : null; - return ( - activeSession?.sessionId || - sessionList.sessions.find((session) => session.archivedAt === null) - ?.sessionId || - null - ); -} - -export async function readProjectSupervisorActiveSessionId( - invoke: TauriInvoke, - projectPath: string, -) { - try { - const sessionList = await invoke( - 'list_game_creator_agent_sessions', - { - projectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - }, - ); - return projectSupervisorActiveSessionIdFromList(sessionList); - } catch (error) { - if (isMissingAgentSessionCommandError(error)) { - return null; - } - throw error; - } -} - -export async function ensureProjectSupervisorActiveSessionId( - invoke: TauriInvoke, - projectPath: string, -) { - const existingSessionId = await readProjectSupervisorActiveSessionId( - invoke, - projectPath, - ); - if (existingSessionId) { - return existingSessionId; - } - const sessionList = await invoke( - 'create_game_creator_agent_session', - { - projectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - title: '项目总控', - }, - ); - const sessionId = projectSupervisorActiveSessionIdFromList(sessionList); - if (!sessionId) { - throw new Error('项目总控 Agent active Session 创建失败'); - } - return sessionId; -} - -export async function submitProjectSupervisorRuntimeTask({ - invoke, - projectPath, - sessionId, - prompt, - runtime, - runProfile, - source, -}: { - invoke: TauriInvoke; - projectPath: string; - sessionId: string; - prompt: string; - runtime: AgentRuntimeState | null; - runProfile: 'standard' | 'autonomous-game-build'; - source: ProjectSupervisorRuntimeSubmission['source']; -}) { - const steerRuntime = matchingAgentRuntimeForSteer( - [runtime], - PROJECT_SUPERVISOR_AGENT_ID, - sessionId, - runProfile, - source, - ); - if (steerRuntime) { - const steer = await invoke( - 'steer_game_creator_agent_runtime_task', - { - projectPath, - agentId: PROJECT_SUPERVISOR_AGENT_ID, - sessionId, - runId: steerRuntime.runId, - steerId: createAgentChatRunId('project-supervisor-steer'), - instruction: prompt, - runProfile, - source, - }, - ); - return { - mode: 'steer' as const, - runtimeResult: steer.runtime, - acceptedRunId: steerRuntime.runId, - }; - } - const requestedRunId = createAgentChatRunId('project-supervisor-task'); - const runtimeResult = await invoke( - 'start_game_creator_supervisor_runtime_task', - { - projectPath, - sessionId, - task: prompt, - runId: requestedRunId, - runProfile, - source, - }, - ); - return { - mode: 'start' as const, - runtimeResult, - acceptedRunId: agentRuntimeStartedRunId(runtimeResult, requestedRunId), - }; -} - -export function agentRuntimeSteerStatus(result: AgentRuntimeSteerResult) { - const runId = result.runtime.state.runId; - if (result.status === 'restarted') { - return `目标已变化,正在新 Run 重新理解并执行:${runId}`; - } - if (result.providerInterrupted) { - return `智能服务已根据追加指令调整,旧请求已安全中断:${runId}`; - } - if (result.interruptDecision === false) { - return `智能服务已回复且判定无需中断,当前 Run 继续:${runId}`; - } - if (result.interruptDecision === true) { - return `智能服务已判定需要改向;旧请求已结束或新规划已开始:${runId}`; - } - if (result.status === 'applied') { - return `追加指令已应用,当前 Run 正在继续:${runId}`; - } - return `追加指令已排队,等待当前 Run 应用:${runId}`; -} - -export function agentRuntimeCancelStatus( - runtime: AgentRuntimeState, - runId: string, -) { - return runtime.status === 'cancelling' || runtime.phase === 'cancelling' - ? `正在取消后台任务:${runId}` - : `已取消后台任务:${runId}`; -} - function agentRuntimeProviderRetryStatus(runtime: AgentRuntimeState) { if (runtime.phase !== 'waiting-for-provider-retry') { return null; @@ -1072,32 +573,6 @@ export function agentRuntimeConversationStatus(runtime: AgentRuntimeState) { return waitingOn ? `Agent 正在运行,等待${waitingOn}` : 'Agent 正在运行'; } -export function projectSupervisorChatRuntimeStatus(runtime: AgentRuntimeState) { - if (runtime.status === 'idle' || runtime.phase === 'idle') { - return '等待输入'; - } - if ( - runtime.status === 'needs-reconciliation' || - runtime.phase === 'needs-reconciliation' - ) { - return runtime.error - ? projectRuntimeVisibleError(runtime.error, '项目总控 Agent', true) - : '项目总控 Agent 运行状态需要核对,请打开运行详情后重试'; - } - if (isAgentRuntimeTerminalState(runtime)) { - if (runtime.status === 'failed' || runtime.phase === 'failed') { - return runtime.error - ? projectRuntimeVisibleError(runtime.error, '项目总控 Agent', true) - : 'Agent 运行失败'; - } - if (runtime.status === 'cancelled' || runtime.phase === 'cancelled') { - return '本轮已取消'; - } - return '本轮已完成'; - } - return agentRuntimeConversationStatus(runtime); -} - export function formatAgentRuntimeEvent(event: AgentRuntimeEventRecord) { const isFailureEvent = [ 'error', @@ -1146,21 +621,6 @@ export function formatAgentRuntimeTaskQueue( return `任务队列:${parts.join(' · ')}`; } -export function formatAgentRuntimeLoopProgress( - runtime: Pick< - AgentRuntimeState, - 'loopIteration' | 'maxLoopIterations' | 'toolActionBudget' - >, -) { - const loopIteration = runtime.loopIteration ?? 0; - if (loopIteration <= 0) { - return null; - } - return `Loop:${loopIteration}/${runtime.maxLoopIterations ?? 3} · 工具预算 ${ - runtime.toolActionBudget ?? 3 - }`; -} - export function formatAgentRuntimePlanStep( step: AgentRuntimePlanStep, fallbackIndex = 0, @@ -1193,31 +653,6 @@ export function agentRuntimeActivePlanStep( ); } -export function agentRuntimeCanCancel(status: string) { - return [ - 'pending', - 'running', - 'waiting-for-confirmation', - 'waiting-for-user-input', - ].includes(status); -} - -export function agentRuntimeCanRetry(status: string) { - return ['cancelled', 'failed', 'completed', 'idle'].includes(status); -} - -export function agentGoalStatusIsPaused(status: string | null | undefined) { - return ['pause-requested', 'pausing', 'paused'].includes(status ?? ''); -} - -export function agentGoalStatusIsTerminal(status: string | null | undefined) { - return ['completed', 'cleared'].includes(status ?? ''); -} - -export function agentRuntimeCanConfirm(status: string) { - return status === 'waiting-for-confirmation'; -} - export function formatAgentRuntimeDelegationSource(runtime: { source: string; parentAgentId?: string | null; @@ -1248,20 +683,6 @@ export function createAgentRuntimeUserInputResponseId() { return `app-user-input-${Date.now().toString(36)}-${entropy}`.slice(0, 160); } -export function isMissingAgentSessionCommandError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - const normalized = message.toLowerCase(); - return ( - normalized.includes('list_game_creator_agent_sessions') && - (normalized.includes('not found') || - normalized.includes('unknown command') || - normalized.includes('unexpected invoke') || - normalized.includes('unexpected command') || - normalized.includes('不存在') || - normalized.includes('未找到')) - ); -} - export function isMissingAgentRuntimeResumeCommandError(error: unknown) { const message = error instanceof Error ? error.message : String(error); const normalized = message.toLowerCase(); @@ -1276,20 +697,6 @@ export function isMissingAgentRuntimeResumeCommandError(error: unknown) { ); } -export function isMissingAgentGoalCommandError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - const normalized = message.toLowerCase(); - return ( - normalized.includes('game_creator_agent_goal') && - (normalized.includes('not found') || - normalized.includes('unknown command') || - normalized.includes('unexpected invoke') || - normalized.includes('unexpected command') || - normalized.includes('不存在') || - normalized.includes('未找到')) - ); -} - export function createDefaultChatMessages(): ChatMessage[] { // 默认问候「想做什么游戏?」已移除:它在对话记录里没有信息量,而且会出现在用户消息之后。 // 空对话由空状态提示(panels.tsx 的引导文案)承担,不再往消息列表里塞占位消息。 @@ -1315,210 +722,6 @@ export function projectNameFromPath(projectPath: string) { ); } -export function projectWorkspaceStatusForDisplay(workspaceStatus: string) { - const openedPrefix = '已打开:'; - if (!workspaceStatus.startsWith(openedPrefix)) { - return workspaceStatus; - } - const projectPath = workspaceStatus.slice(openedPrefix.length).trim(); - if (!/[\\/]/u.test(projectPath)) { - return workspaceStatus; - } - return `${openedPrefix}${projectNameFromPath(projectPath)}`; -} - -export function mergeProjectSupervisorConversation( - projectRecords: LocalConversationMessageRecord[], - supervisorRecords: LocalConversationMessageRecord[], -): ChatMessage[] { - const supervisorRecordIndexByCorrelation = new Map(); - supervisorRecords.forEach((record, index) => { - if (record.role !== 'user') { - return; - } - const correlationId = agentRuntimeMessageCorrelationId(record.messageId); - if ( - correlationId && - !supervisorRecordIndexByCorrelation.has(correlationId) - ) { - supervisorRecordIndexByCorrelation.set(correlationId, index); - } - }); - const records = [ - ...projectRecords.map((record, index) => { - const runtimeOwned = Boolean( - record.messageId - ?.trim() - .startsWith(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX), - ); - const accepted = - runtimeOwned && - record.content === '任务已接收,项目总控 Agent 正在启动处理。'; - const correlationId = agentRuntimeMessageCorrelationId(record.messageId); - const supervisorRecordIndex = correlationId - ? supervisorRecordIndexByCorrelation.get(correlationId) - : undefined; - const runtimeRecordIndex = - supervisorRecordIndex ?? supervisorRecords.length + index; - return { - record, - runtimeOwned, - sameSecondLane: runtimeOwned ? 1 : 0, - sameSecondOrder: runtimeOwned - ? runtimeRecordIndex * 4 + (accepted ? 1 : 2) - : index, - stableIndex: index, - }; - }), - ...supervisorRecords.map((record, index) => { - const userMessage = record.role === 'user'; - return { - record, - runtimeOwned: true, - sameSecondLane: 1, - // Conversation timestamps have second precision. Runtime task and - // public status IDs carry the same opaque run correlation digest, so - // status ordering follows its actual task record instead of guessing - // from independent user/accepted/terminal category counters. - sameSecondOrder: index * 4 + (userMessage ? 0 : 3), - stableIndex: projectRecords.length + index, - }; - }), - ] - .filter( - ({ record }) => record.role === 'user' || record.role === 'assistant', - ) - .sort( - (left, right) => - left.record.updatedAt - right.record.updatedAt || - left.sameSecondLane - right.sameSecondLane || - left.sameSecondOrder - right.sameSecondOrder || - left.stableIndex - right.stableIndex, - ); - const seen = new Set(); - const messages: ChatMessage[] = []; - for (const { record, runtimeOwned } of records) { - const messageId = record.messageId?.trim(); - const identity = messageId - ? `message:${messageId}` - : `identity:${JSON.stringify([ - record.role, - record.agentId, - record.updatedAt, - record.content, - ])}`; - if (seen.has(identity)) { - continue; - } - seen.add(identity); - messages.push({ - role: record.role as ChatMessage['role'], - text: record.content, - messageId: messageId ?? null, - agentId: record.agentId, - updatedAt: record.updatedAt, - runtimeOwned, - }); - } - return messages.length > 0 ? messages : createDefaultChatMessages(); -} - -export function projectSupervisorRuntimeStatusLabel( - runtime: AgentRuntimeState | null, - runtimeError: string, -) { - if ( - runtime?.status === 'needs-reconciliation' || - runtime?.phase === 'needs-reconciliation' - ) { - return '待核对'; - } - if ( - runtimeError || - runtime?.status === 'failed' || - runtime?.phase === 'failed' - ) { - return '失败'; - } - if (!runtime) { - return null; - } - if ( - runtime.userInputRequest || - runtime.status === 'waiting-for-user-input' || - runtime.phase === 'waiting-for-user-input' - ) { - return '需要回答'; - } - if ( - runtime.pendingToolAction || - runtime.status === 'waiting-for-confirmation' || - runtime.phase === 'waiting-for-confirmation' - ) { - return '等待确认'; - } - if ( - runtime.phase === 'waiting-for-delegate-receipts' || - runtime.phase === 'waiting-for-isolated-join' - ) { - return '等待专业 Agent'; - } - if (runtime.phase === 'waiting-for-manifest-tasks') { - return '等待项目任务'; - } - if ( - ['action', 'observation', 'executing'].includes(runtime.phase) || - runtime.status === 'executing' - ) { - return '执行'; - } - if (runtime.phase === 'response' || runtime.phase === 'finalizing') { - return '回复中'; - } - if (runtime.status === 'cancelled' || runtime.phase === 'cancelled') { - return '已取消'; - } - if (runtime.status === 'completed' || runtime.phase === 'completed') { - return '已完成'; - } - if (runtime.status === 'idle') { - return null; - } - return '分析'; -} - -export function projectSupervisorCollaboratingAgentRuntimes( - supervisorRuntime: AgentRuntimeState | null, - runtimeByAgentId: Record, -) { - if (!supervisorRuntime?.runId) { - return []; - } - const runtimesByAgentId = new Map(); - for (const runtime of Object.values(runtimeByAgentId)) { - const isVisibleChildSource = [ - 'agent-delegate', - 'agent-delegate-retry', - ].includes(runtime?.source ?? ''); - if ( - !runtime || - runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID || - !isVisibleChildSource || - runtime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || - runtime.parentRunId !== supervisorRuntime.runId - ) { - continue; - } - const previous = runtimesByAgentId.get(runtime.agentId); - if (!previous || runtime.updatedAt >= previous.updatedAt) { - runtimesByAgentId.set(runtime.agentId, runtime); - } - } - return Array.from(runtimesByAgentId.values()).sort((left, right) => - left.agentId.localeCompare(right.agentId), - ); -} - export function projectProfessionalAgentLabel(agentId: string) { if (agentId === 'design-foundation') { return '玩法策划 Agent'; @@ -1560,65 +763,6 @@ export function isAgentFinalizationMessageId( return /^agent-finalization-[0-9a-f]{32}$/u.test(messageId ?? ''); } -export function projectRuntimeStatusPresentation(runtime: AgentRuntimeState) { - if (runtime.phase === 'needs-reconciliation') { - return { label: '待核对', tone: 'failed' }; - } - if ( - runtime.userInputRequest || - runtime.status === 'waiting-for-user-input' || - runtime.phase === 'waiting-for-user-input' - ) { - return { label: '待回答', tone: 'waiting' }; - } - if ( - runtime.pendingToolAction || - runtime.status === 'waiting-for-confirmation' || - runtime.phase === 'waiting-for-confirmation' - ) { - return { label: '待确认', tone: 'waiting' }; - } - if (runtime.status === 'failed' || runtime.phase === 'failed') { - return { label: '失败', tone: 'failed' }; - } - if (runtime.status === 'completed' || runtime.phase === 'completed') { - return { label: '已完成', tone: 'completed' }; - } - if (runtime.status === 'cancelled' || runtime.phase === 'cancelled') { - return { label: '已取消', tone: 'idle' }; - } - if (runtime.status === 'idle' || runtime.phase === 'idle') { - return { label: '等待中', tone: 'idle' }; - } - if (runtime.phase === 'planning') { - return { label: '分析中', tone: 'running' }; - } - if ( - runtime.phase === 'waiting-for-delegate-receipts' || - runtime.phase === 'waiting-for-isolated-join' - ) { - return { label: '协作中', tone: 'running' }; - } - if (runtime.phase === 'waiting-for-manifest-tasks') { - return { - label: '项目任务中', - tone: 'running', - }; - } - return { label: '执行中', tone: 'running' }; -} - -export function projectRuntimePlanProgress(runtime: AgentRuntimeState) { - const steps = (runtime.planSteps ?? []).filter( - (step) => agentRuntimePlanStepText(step).length > 0, - ); - return { - completed: steps.filter((step) => step.status === 'completed').length, - total: steps.length, - active: agentRuntimeActivePlanStep(runtime), - }; -} - export function projectRuntimeVisibleCurrentWork(runtime: AgentRuntimeState) { const providerRetryStatus = agentRuntimeProviderRetryStatus(runtime); if (providerRetryStatus) { @@ -2071,6 +1215,11 @@ export function projectRuntimeVisibleError( visibleMessage === 'LLM 服务暂时不可用,请检查配置后重试' || visibleMessage === '请先回答项目总控 Agent 当前的澄清问题' || visibleMessage === '待回答请求已变更,请刷新 Runtime 状态' || + // 工作台壳直接写入的运行态提示(App.tsx 的 setProjectChatError):这些文案本身 + // 就是给用户看的动作指引,不能被通用兜底改成「策划 Agent 执行失败」。 + visibleMessage === '待回答问题已变更,请刷新策划状态' || + visibleMessage === '需要在 Tauri App 内运行。' || + visibleMessage === '请先初始化本地项目' || /^回答提交失败:[^\r\n]{1,80}$/.test(visibleMessage); if ( preservePublicMessage && @@ -2082,125 +1231,6 @@ export function projectRuntimeVisibleError( return `${subject} 执行失败,请稍后重试`; } -export function projectSupervisorVisibleConversationText( - message: string, - role: ChatMessage['role'] | 'tool' = 'assistant', - subject = '项目总控 Agent', -) { - const failurePrefix = '后台任务失败:'; - if (role !== 'assistant' || !message.startsWith(failurePrefix)) { - return message; - } - return projectRuntimeVisibleError( - message.slice(failurePrefix.length), - subject, - true, - ); -} - -export function projectSupervisorChatMessageText( - message: Pick, -) { - return message.role === 'assistant' - ? projectSupervisorVisibleConversationText(message.text, message.role) - : message.text; -} - -export function projectRuntimeVisibleToolSummary(summary: string) { - return summary - .split('·') - .map((part) => part.trim()) - .filter( - (part) => - part.length > 0 && - !/^(contentChars|contentBytes|sha256|fingerprint)=/i.test(part), - ) - .join(' · '); -} - -export function projectSupervisorPendingActionPresentation( - action: AgentRuntimePendingToolActionSummary, -) { - if (action.tool === 'agent.delegate') { - const agentId = - action.inputSummary?.match(/(?:^|·)\s*agentId=([^·]+)/)?.[1]?.trim() ?? - ''; - const agentLabel = agentId - ? projectProfessionalAgentLabel(agentId) - : '专业 Agent'; - const isRepair = - action.inputSummary?.includes('repairOf=') && - !action.inputSummary.includes('repairOf=null'); - return { - title: isRepair ? `安排${agentLabel}返工` : `安排${agentLabel}执行任务`, - detail: isRepair - ? '确认后将在当前项目创建一轮带原验收合同的返工任务' - : '确认后将在当前项目启动新的专业任务', - }; - } - return { - title: action.tool, - detail: action.inputSummary - ? projectRuntimeVisibleToolSummary(action.inputSummary) - : '确认后继续当前项目任务', - }; -} - -export function projectSupervisorPendingRepairMatchesProfessional( - action: AgentRuntimePendingToolActionSummary | null | undefined, - runtime: AgentRuntimeState, -) { - const summary = action?.inputSummary ?? ''; - return Boolean( - action?.tool === 'agent.delegate' && - runtime.delegationId && - summary.includes(`agentId=${runtime.agentId}`) && - summary.includes(`repairOf=${runtime.delegationId}`), - ); -} - -export function formatProjectRuntimeUpdatedAt(updatedAt: number) { - if (!Number.isFinite(updatedAt) || updatedAt <= 0) { - return '更新时间未知'; - } - const milliseconds = - updatedAt < 1_000_000_000_000 ? updatedAt * 1000 : updatedAt; - return `${new Date(milliseconds).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - })} 更新`; -} - -export function formatProjectSupervisorCompactProgress( - runtime: AgentRuntimeState, - collaboratingAgentCount: number, -) { - const planSteps = (runtime.planSteps ?? []).filter( - (step) => agentRuntimePlanStepText(step).length > 0, - ); - const completedPlanStepCount = planSteps.filter( - (step) => step.status === 'completed', - ).length; - const activePlanStep = agentRuntimeActivePlanStep(runtime); - const currentPlanStep = activePlanStep - ? agentRuntimePlanStepText(activePlanStep) - : planSteps.length > 0 && completedPlanStepCount === planSteps.length - ? '已完成' - : '暂无'; - const waitingOn = - runtime.waitingOn?.trim() || agentRuntimeWaitingOnFromPhase(runtime.phase); - const nextStep = - runtime.nextStep?.trim() || agentRuntimeNextStepFromPhase(runtime.phase); - return [ - `计划完成:${completedPlanStepCount}/${planSteps.length}`, - `当前步骤:${currentPlanStep}`, - `等待:${waitingOn}`, - `下一步:${nextStep}`, - `专业 Agent 协作:${collaboratingAgentCount}`, - ].join(' · '); -} - export function taskRowsFromManifest( manifest: GameCreationAppManifest, ): GameCreationAppTaskState[] { diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx deleted file mode 100644 index 95b50ccbe..000000000 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx +++ /dev/null @@ -1,1327 +0,0 @@ -import { - ChevronDown, - ChevronUp, - Pause, - Pencil, - Play, - Target, - Trash2, -} from 'lucide-react'; -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; - -import { closeDialogOnEscape } from '../../app/dialogs'; -import type { - AgentGoalRecord, - AgentRuntimeState, - AgentRuntimeUserInputRequest, -} from '../../app/types'; -import type { ProjectAgentResultSummary } from '../../view/project-development'; -import { - agentGoalStatusIsPaused, - agentGoalStatusIsTerminal, - agentRuntimeCanCancel, - agentRuntimeCanConfirm, - agentRuntimeCanRetry, - agentRuntimeNeedsUserInput, - agentRuntimeNextStepFromPhase, - agentRuntimePlanStepText, - agentRuntimeWaitingOnFromPhase, - createAgentRuntimeUserInputResponseId, - formatAgentRecentRuntimeTask, - formatAgentRuntimeDelegationSource, - formatAgentRuntimeEvent, - formatAgentRuntimeLoopProgress, - formatAgentRuntimePlanStep, - formatAgentRuntimeTaskQueue, - formatProjectRuntimeUpdatedAt, - formatProjectSupervisorCompactProgress, - isAgentRuntimeTerminalState, - projectProfessionalAgentLabel, - projectRuntimePlanProgress, - projectRuntimeStatusPresentation, - projectRuntimeVisibleCurrentWork, - projectRuntimeVisibleError, - projectRuntimeVisibleToolSummary, - projectSupervisorCollaboratingAgentRuntimes, - projectSupervisorPendingActionPresentation, - projectSupervisorRuntimeStatusLabel, -} from './model'; - -export function AgentGoalStatusPanel({ - goal, - runtime, - error, - controlBusy = false, - readOnly = false, - onStart, - onEdit, - onPause, - onResume, - onClear, -}: { - goal: AgentGoalRecord | null; - runtime: AgentRuntimeState | null; - error?: string | null; - controlBusy?: boolean; - readOnly?: boolean; - onStart?: () => void; - onEdit?: () => void; - onPause?: () => void; - onResume?: () => void; - onClear?: () => void; -}) { - const runtimeMatchesGoal = Boolean( - runtime?.goalId && (!goal || runtime.goalId === goal.goalId), - ); - const goalId = runtimeMatchesGoal ? runtime?.goalId : goal?.goalId; - const status = - (runtimeMatchesGoal ? runtime?.goalStatus : null) ?? goal?.status ?? null; - const revision = - (runtimeMatchesGoal ? runtime?.goalRevision : undefined) ?? - goal?.revision ?? - 0; - const outcome = - (runtimeMatchesGoal ? runtime?.goalOutcome : null) ?? goal?.outcome ?? ''; - const constraints = - (runtimeMatchesGoal ? runtime?.goalConstraints : undefined) ?? - goal?.constraints ?? - []; - const verification = - (runtimeMatchesGoal ? runtime?.goalVerification : undefined) ?? - goal?.verification ?? - []; - const hasGoal = Boolean(goalId); - const canStart = - Boolean(onStart) && (!hasGoal || agentGoalStatusIsTerminal(status)); - const canEdit = - Boolean(goal && onEdit) && - ['active', 'pause-requested', 'paused'].includes(status ?? ''); - const canPause = Boolean(goal && onPause) && status === 'active'; - const canResume = Boolean(goal && onResume) && status === 'paused'; - const canClear = - Boolean(goal && onClear) && - !['clearing', 'cleared', 'needs-reconciliation'].includes(status ?? ''); - return ( -
-
-
- 持久目标 - - {hasGoal - ? `Status:${status ?? '-'} · Revision:${revision}` - : '尚未开始'} - -
-
- {canStart ? ( - - ) : null} - {canEdit ? ( - - ) : null} - {canPause ? ( - - ) : null} - {canResume ? ( - - ) : null} - {canClear ? ( - - ) : null} -
-
- {hasGoal ? ( -
-

{`Outcome:${outcome || '-'}`}

- {`Verification:${verification.join(';') || '-'}`} - {constraints.length > 0 ? ( - {`Constraints:${constraints.join(';')}`} - ) : null} -
- ) : null} - {goal?.error ? {goal.error} : null} - {error ? {error} : null} -
- ); -} - -export function AgentRuntimeUserInputCard({ - request, - controlBusy, - onSubmit, -}: { - request: AgentRuntimeUserInputRequest; - controlBusy: boolean; - onSubmit?: ( - request: AgentRuntimeUserInputRequest, - responseId: string, - answers: Record, - ) => void | Promise; -}) { - const [answers, setAnswers] = useState>({}); - const [submitting, setSubmitting] = useState(false); - const [responseId] = useState( - () => request.responseId ?? createAgentRuntimeUserInputResponseId(), - ); - const answerPrepared = request.status === 'answer-prepared'; - const allAnswered = request.questions.every((question) => - Boolean(answers[question.id]?.trim()), - ); - const disabled = controlBusy || submitting || answerPrepared; - - return ( -
-
- 需要你的回答 - - {answerPrepared ? '已提交,正在继续' : '回答后继续当前任务'} - -
- {request.questions.map((question, index) => { - const answer = answers[question.id] ?? ''; - return ( -
- {`${index + 1}. ${question.header}`} -

{question.question}

-
- {question.options.map((option) => ( - - ))} -
-