From 1c2f82e246b58a894919387585e96010e68026fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E7=BE=BD=E7=A7=A6?= Date: Mon, 31 Aug 2026 14:36:58 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E4=BC=98=E5=8C=96=20Game=20Agent=20?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83=E5=B1=95=E7=A4=BA=E4=B8=8E?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E7=BC=A9=E6=94=BE=20(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 ### 1. 普通资源画布隐藏游戏代码 - 资源画布的大纲、分页和卡片展示不再显示“游戏代码”栏目。 - 保留内部完整的五栏目布局契约,避免已有代码资源坐标被重写。 - 补充回归测试,确认游戏代码不会出现在普通资源画布中,且代码端点不可见时不会渲染对应依赖边。 ### 2. 增加资源类型角标 - 资源卡右上角增加类型角标,用于快速识别资源类型。 - 类型从既有资源投影字段推导,覆盖图片、SVG、视频、音频、文档、任务产物、Agent 回执、项目版本等类型,并为未知类型兜底。 - 该能力仅用于前端展示,不新增后端字段或修改资源投影契约。 ### 3. 限制默认自动适配缩放 - 画布首次自动适配和显式复位时的默认最大缩放统一限制为 `1.5`,避免内容较少或只有一个资源时卡片被放得过大。 - 该限制对设计文档、美术、音频、游戏代码、项目版本全部资源栏目生效。 - 用户主动缩放仍沿用现有上限,不受默认适配上限影响。 - 用户调整后的画布缩放会按排序模式和栏目保留;切换到其他栏目再返回后,仍恢复用户调整后的 viewport,而不是重新自动放大。 ## 兼容性 - 不修改后端 API、SpacetimeDB schema 或 `/api/external/v1`。 - 资源类型角标是展示层推导逻辑,不改变资源数据结构。 - 内部布局仍保留游戏代码栏目契约,已有布局数据不需要迁移。 ## 验证 - `resourceCanvasLayoutModel.test.ts` - `project-development.suite.ts` 相关 App Surface 用例 - `npm run typecheck` - `npm run check:encoding` - Prettier 相关文件检查 - `git diff --check` 历史分支检查不替代最新 head CI;PR 更新后将以最新 CI 结果为准。 --------- Co-authored-by: 段舒康 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/208 Co-authored-by: 董羽秦 Co-committed-by: 董羽秦 --- apps/ai-game-creator-shell/src/styles.css | 25 +++ .../src/view/project-development/index.tsx | 74 ++++---- .../resourceCanvasLayoutModel.ts | 10 +- .../resourceProjectionModel.ts | 61 +++++++ .../appSurface/project-development.suite.ts | 166 +++++++++++++++--- .../projectResourceProjectionModel.test.ts | 120 ++++++++++++- .../tests/resourceCanvasLayoutModel.test.ts | 25 ++- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 10 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 9 files changed, 423 insertions(+), 70 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index b150722b5..c733e6704 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5458,6 +5458,31 @@ iframe.preview-frame { visibility: visible; } +.game-resource-card-type-badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + display: inline-flex; + max-width: calc(100% - 16px); + align-items: center; + min-width: 0; + padding: 4px 8px; + overflow: hidden; + border: 1px solid rgb(255 255 255 / 72%); + border-radius: 999px; + background: rgb(75 48 38 / 84%); + color: #fff; + font-size: 10px; + font-weight: 900; + line-height: 1; + letter-spacing: 0.02em; + pointer-events: none; + text-overflow: ellipsis; + white-space: nowrap; + box-shadow: 0 8px 18px rgb(96 62 47 / 20%); +} + .game-resource-card-open { position: absolute; z-index: 1; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 272916fa5..f539fc02f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -82,10 +82,10 @@ import { normalizeInfiniteResourceCanvasViewport, RESOURCE_CANVAS_DRAG_THRESHOLD, RESOURCE_CANVAS_FIT_PADDING, - RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, RESOURCE_CANVAS_SECTION_MIN_HEIGHT, RESOURCE_CANVAS_SECTION_MIN_WIDTH, RESOURCE_CANVAS_SECTION_ORDER, + RESOURCE_CANVAS_VISIBLE_SECTION_ORDER, type ResourceCanvasCardSize, resourceCanvasCardSize, resourceCanvasContentBounds, @@ -123,6 +123,7 @@ import { type ProjectResource, type ProjectResourceCategory, projectResourcesFromReadModels, + projectResourceTypeLabel, } from './resourceProjectionModel'; import { clampProjectResourceSectionZoom, @@ -393,6 +394,7 @@ export type ProjectDevelopmentViewProps = { }; const categoryOrder = RESOURCE_CANVAS_SECTION_ORDER; +const visibleCategoryOrder = RESOURCE_CANVAS_VISIBLE_SECTION_ORDER; const categoryLabels: Record = { code: '游戏代码', @@ -592,6 +594,7 @@ const ResourceCard = memo(function ResourceCard({ const [decodedIdentity, setDecodedIdentity] = useState(null); const Icon = categoryIcons[resource.category]; const kind = projectResourceCardPreviewKind(resource); + const resourceTypeLabel = projectResourceTypeLabel(resource); const isMedia = kind === 'video' || kind === 'audio'; const mediaActive = activeMediaIdentity === previewIdentity; const sourceUrl = @@ -797,6 +800,13 @@ const ResourceCard = memo(function ResourceCard({ + + {resourceTypeLabel} + + {onMakeGame ? ( + + ) : null} {openError ? ( ) : null} + {makeGameError ? ( + + {makeGameError} + + ) : null} ) : null} {detailsOpen && deliveredGdd ? ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 1e22942f0..dba7534dc 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -68,6 +68,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { action: PlanGddDecisionAction, comment: string | null, ) => Promise; + onMakeGameFromApprovedGdd?: () => Promise; }; export function ProjectSupervisorView({ @@ -99,6 +100,7 @@ export function ProjectSupervisorView({ planGddError, onPlanGddRefresh, onPlanGddDecision, + onMakeGameFromApprovedGdd, ...runtimePanelProps }: ProjectSupervisorViewProps) { const submitLabel = needsUserInput @@ -121,6 +123,7 @@ export function ProjectSupervisorView({ error={planGddError} onRefresh={onPlanGddRefresh} onDecision={onPlanGddDecision} + onMakeGame={onMakeGameFromApprovedGdd} />
Promise; + onMakeGameFromApprovedGdd?: () => Promise; queueAgentRunControlFromPanel: (action: 'kill' | 'retry' | 'resume') => void; queueOrExecuteProjectIndex: () => Promise; queuePendingCommand: (command: PendingCommand) => void; @@ -278,6 +279,7 @@ export function ProjectWorkspaceChatPane({ planGddError, onPlanGddRefresh, onPlanGddDecision, + onMakeGameFromApprovedGdd, queueAgentRunControlFromPanel, queueOrExecuteProjectIndex, queuePendingCommand, @@ -376,6 +378,7 @@ export function ProjectWorkspaceChatPane({ error={planGddError} onRefresh={onPlanGddRefresh} onDecision={onPlanGddDecision} + onMakeGame={onMakeGameFromApprovedGdd} />
-
- {isPlanningLaneRuntime(projectSupervisorRuntime) ? ( - - ) : ( - - )} + {pendingCommand ? (
diff --git a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts index 1960bd5cd..e87da4da5 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts @@ -4,21 +4,34 @@ import { resolve } from 'node:path'; import { PlanGddStageProgress } from '../../src/features/project-workspace/GddApprovalCard'; import { agentRuntimeUserInputRequest, + App, createPlanGddStateView, createProjectSupervisorRuntimeHarness, expect, fireEvent, it, - openMainProject, React, render, - renderAppAt, screen, vi, waitFor, within, } from './harness'; +function mountFormalSupervisor( + harness: ReturnType, +) { + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); +} + async function mountApprovalCard( harness: ReturnType, ) { @@ -26,8 +39,7 @@ async function mountApprovalCard( core: { invoke: harness.invoke }, event: { listen: harness.listen }, }; - renderAppAt('/'); - await openMainProject(harness.projectPath); + mountFormalSupervisor(harness); return await screen.findByLabelText('GDD 审批卡'); } @@ -38,8 +50,7 @@ async function mountPlanningSurface( core: { invoke: harness.invoke }, event: { listen: harness.listen }, }; - renderAppAt('/'); - await openMainProject(harness.projectPath); + mountFormalSupervisor(harness); return await screen.findByLabelText('立项策划阶段进度'); } From f0142f70193e624e520e0879638e363ed52a9252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E7=BE=BD=E7=A7=A6?= Date: Tue, 1 Sep 2026 14:27:39 +0800 Subject: [PATCH 7/8] =?UTF-8?q?Close213/215=20=E6=9C=AC=E5=9C=B0=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E6=B3=A8=E5=86=8C=E4=B8=8E=E5=90=8E=E5=8F=B0=E8=A1=A8?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 关联 Issue - Close #213:补充本地资源注册类型 - Close #215:后台表查询添加字段查询 ## 背景 本 PR 合并处理两个规模较小、已完成统一 review 的客户端与后台能力改动,整体已基于远端最新 master。 ## 本地资源注册(#213) - AGC 本地项目资源支持图片、字体、音频、视频、文档和代码类型。 - 支持 PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP、TTF/OTF/WOFF/WOFF2、MP3/WAV/OGG/FLAC/M4A/AAC/OPUS、MP4/WEBM/MOV,以及常见文本、配置和源码扩展名。 - 同步更新 Direct Tool Bridge、MCP schema、Runtime/Provider Prompt、AGC Skill 文档和技术方案。 - 保留项目相对路径、敏感目录、符号链接、文件大小、字体解析、UTF-8、项目锁、manifest/revision 和幂等语义。 - 修复从 game 等目录复制到 assets/uploads 时扩展名退化为 .bin 的问题。 ## 后台表查询(#215) - 使用结构化筛选条件替换手写筛选 JSON。 - 支持多条件、比较、包含、前后缀、列表、空值和旧 JSON object 兼容。 - 优化预览表格、详情复制、Toast、JSON 预览和排序交互。 - 修复数字范围筛选,包括数据库字符串化数字的数值比较。 - 补充前后端回归测试与文档。 ## 验证 - AGC 本地资源导入测试:6/6 - AGC Direct/MCP 工具测试:8/8 - 本地资源扩展名与 Prompt 映射回归测试通过 - 后台 admin 数据库测试:17/17 - 后台页面 Vitest:16/16 - `npm run admin-web:typecheck` - `npm run admin-web:build` - `npm run check:encoding` - `git diff --check` - `rustfmt --edition 2024 --check server-rs/crates/api-server/src/admin.rs` ## 限制 - 未执行真实生产 SpacetimeDB、OSS 或其它付费服务验证。 - Native shell CI 及其它 PR CI 以最新 head 的远端结果为准。 - 当前 PR 保持 WIP,待 reviewer 基于最新 head 重新 review。 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/232 Co-authored-by: 董羽秦 Co-committed-by: 董羽秦 --- .../admin-database-table-query-2026-05-08.md | 7 +- .../pages/AdminDatabaseTablesPage.test.tsx | 407 +++++++- .../src/pages/AdminDatabaseTablesPage.tsx | 939 ++++++++++++++++-- apps/admin-web/src/styles/admin.css | 679 ++++++++++++- apps/admin-web/src/styles/admin.test.ts | 35 +- .../agc-skills/agc-client-projection/SKILL.md | 2 +- .../references/projection-contract.md | 2 +- .../agc-skills/agc-project-structure/SKILL.md | 2 +- .../references/structure-contract.md | 2 +- .../resources/agc-skills/manifest.json | 4 +- .../src-tauri/src/agent/direct_runtime.rs | 2 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 46 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 17 +- .../src/agent/generation/prompt_context.rs | 41 +- .../provider_request_builders.rs | 4 +- .../src/agent/runtime_tools/context.rs | 4 +- .../src-tauri/src/agent_native_tools.rs | 2 +- .../src-tauri/src/assets.rs | 123 ++- .../src-tauri/src/commands.rs | 307 +++++- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- server-rs/crates/api-server/src/admin.rs | 525 +++++++++- .../crates/shared-contracts/src/admin.rs | 4 + 23 files changed, 2912 insertions(+), 248 deletions(-) diff --git a/.codex/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md b/.codex/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md index 63ba34365..0f9adf8b9 100644 --- a/.codex/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md +++ b/.codex/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md @@ -2,7 +2,7 @@ ## 需求落点 - 后台“总览”页的表统计仍保留,只把每张表的表名改成可点击跳转到 `#tables?table=`。 -- 新增独立 `#tables` 页承载表选择、关键词搜索、JSON filters、limit、行详情弹窗。 +- 新增独立 `#tables` 页承载表选择、关键词搜索、结构化字段筛选、limit、行详情弹窗(详情内保留字段复制,列头漏斗按钮可按列添加条件;每条条件可勾选启用或停用,停用时保留字段和值;`in` / `notIn` 使用逐项值标签,支持粘贴多行值,逗号不再作为隐式分隔符)。 ## 后端实现要点 - 新增只读接口: @@ -13,12 +13,13 @@ - `search` / `filters` 不进入 SQL 字符串: - SQL 只负责 `SELECT * FROM {table_name} LIMIT {limit}` - 返回后在 api-server 内存中过滤 - - `filters` 仅接受 JSON object,按列名匹配;非 object 直接 400 + - `filters` 接受两种 JSON 形式:object(列名到等值,如 `{"user_id":"u1"}`)与条件数组(如 `[{"column":"points","op":"gt","value":"5"}]`,运算符含 `eq`、`ne`、`gt`、`gte`、`lt`、`lte`、`contains`、`notContains`、`startsWith`、`endsWith`、`in`、`notIn`、`isEmpty`、`isNotEmpty`,允许同列多条件,条件间为 AND);非 object 且非数组直接 400,未知运算符或 value 形态不匹配也 400。数组中的 `eq` / `ne` 和其他标量运算符一样必须提供 `value`;显式空值判断使用 `isEmpty` / `isNotEmpty` - SpacetimeDB HTTP SQL 返回可能是 statement array + rows,解析时要兼容这一层结构。 ## 前端实现要点 - `adminRoutes` 必须新增 `tables`,`AdminShell.routeIcons` 也要同步覆盖。 - `AdminApp` 需要显式渲染 `AdminDatabaseTablesPage`。 +- 预览表格数据行直接点击(或行自身聚焦后按 Enter / Space)打开详情,行内按钮 / 输入控件的键盘操作不冒泡打开详情;详情按钮不单独占列。详情字段仅提供复制操作,成功、剪贴板失败和空字段复制都使用右下角自动消失的 Toast,JSON 预览与平台亮色 / 暗色主题保持一致,表头保持单行并在空间不足时省略显示。表单和标题区查询按钮共用筛选完整性校验。 - worktree 下可能没有本地 `node_modules/typescript/bin/tsc`,而根目录有依赖;在验证前可以临时把根目录 `node_modules` 软链到 worktree 再执行 `npm run admin-web:typecheck`,验证后删除软链,避免污染 git 状态。 ## 验证结果 @@ -26,4 +27,4 @@ - `cargo fmt --manifest-path Cargo.toml -p api-server -p shared-contracts --check` 通过。 - `npm run admin-web:typecheck` 通过。 - `npm run admin-web:build` 通过。 -- `npm run check:encoding` 通过。 \ No newline at end of file +- `npm run check:encoding` 通过。 diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx index d6bf2e71f..d246812c6 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -8,6 +8,7 @@ import { getAdminDatabaseTableRows, getAdminDatabaseTables, } from '../api/adminApiClient'; +import type { AdminDatabaseTableRowsResponse } from '../api/adminApiTypes'; import { AdminDatabaseTablesPage, resolveAdminDatabaseUserReference, @@ -23,7 +24,10 @@ vi.mock('../api/adminApiClient', () => ({ })); vi.mock('../components/AdminUserReferenceButton', () => ({ - AdminUserReferenceButton: ({ userId, publicUserCode }: { + AdminUserReferenceButton: ({ + userId, + publicUserCode, + }: { userId?: string; publicUserCode?: string; }) => ( @@ -68,6 +72,7 @@ const referralRows = [ ]; beforeEach(() => { + vi.clearAllMocks(); window.location.hash = '#tables?table=profile_referral_relation'; vi.mocked(getAdminDatabaseTables).mockResolvedValue({ fetchErrors: [], @@ -124,7 +129,10 @@ test('后台表查询页通过页面级固定栏翻页并提示扫描结果可 ), ).toBeTruthy(); - await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件'); + await user.type( + screen.getByRole('textbox', { name: '关键词' }), + '未执行条件', + ); await user.click(screen.getByRole('button', { name: '下一页' })); await waitFor(() => { @@ -141,6 +149,53 @@ test('后台表查询页通过页面级固定栏翻页并提示扫描结果可 }); }); +test('后台表查询页不会让旧表请求覆盖新表结果', async () => { + const user = userEvent.setup(); + let resolveFirstRequest!: (response: AdminDatabaseTableRowsResponse) => void; + const firstRequest = new Promise( + (resolve) => { + resolveFirstRequest = resolve; + }, + ); + const oldTableResponse = { + columns: ['id'], + limit: 100, + page: 1, + rows: [{ cells: { id: 'old-row' }, raw: ['old-row'] }], + scannedCount: 1, + scanLimit: 50000, + scanLimitReached: false, + tableName: 'profile_referral_relation', + totalMatched: 1, + totalReturned: 1, + } satisfies AdminDatabaseTableRowsResponse; + const newTableResponse = { + ...oldTableResponse, + rows: [{ cells: { id: 'new-row' }, raw: ['new-row'] }], + tableName: 'profile_wallet', + } satisfies AdminDatabaseTableRowsResponse; + vi.mocked(getAdminDatabaseTables).mockResolvedValueOnce({ + fetchErrors: [], + tables: ['profile_referral_relation', 'profile_wallet'], + }); + vi.mocked(getAdminDatabaseTableRows) + .mockImplementationOnce(() => firstRequest) + .mockResolvedValueOnce(newTableResponse); + + render( + , + ); + const tableSelect = await screen.findByRole('combobox'); + await user.selectOptions(tableSelect, 'profile_wallet'); + + expect(await screen.findByText('new-row')).toBeTruthy(); + resolveFirstRequest(oldTableResponse); + await waitFor(() => { + expect(screen.queryByText('old-row')).toBeNull(); + }); + expect(screen.getByText('new-row')).toBeTruthy(); +}); + test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => { const user = userEvent.setup(); const { container } = render( @@ -165,7 +220,9 @@ test('后台表查询页把表头排序交给后端并从第一页展示排序 ).toBe('原始表名:profile_referral_relation。邀请关系记录表。'); expect( screen.getByRole('button', { name: '被邀请人ID' }).getAttribute('title'), - ).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。'); + ).toBe( + '原始字段名:invitee_user_id。被邀请人的用户标识。点击列名可在正序、倒序和不排序之间循环切换。 当前状态:不排序。', + ); expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']); vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({ @@ -219,6 +276,88 @@ test('后台表查询页把表头排序交给后端并从第一页展示排序 ); expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']); }); + + vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({ + columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'], + limit: 100, + page: 1, + rows: referralRows, + scannedCount: 3, + scanLimit: 50000, + scanLimitReached: false, + tableName: 'profile_referral_relation', + totalMatched: 3, + totalReturned: 3, + }); + await user.click(screen.getByRole('button', { name: '邀请人ID' })); + await waitFor(() => { + expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith( + 'admin-token', + 'profile_referral_relation', + expect.objectContaining({ + page: 1, + sortColumn: undefined, + sortDirection: undefined, + }), + ); + expect( + screen + .getAllByRole('columnheader', { name: '邀请人ID' }) + .filter((header) => header.textContent?.trim() === '邀请人ID') + .some((header) => header.getAttribute('aria-sort') === 'none'), + ).toBe(true); + }); +}); + +test('后台表查询页行详情将对象值以语法高亮 JSON 预览', async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({ + columns: ['id', 'metadata'], + limit: 100, + page: 1, + rows: [ + { + cells: { + id: 'row-1', + metadata: { enabled: true, count: 2, label: 'demo' }, + }, + raw: ['row-1', { enabled: true, count: 2, label: 'demo' }], + }, + ], + scannedCount: 1, + scanLimit: 50000, + scanLimitReached: false, + tableName: 'profile_referral_relation', + totalMatched: 1, + totalReturned: 1, + }); + render( + , + ); + + await screen.findByText('row-1'); + expect(screen.queryByRole('button', { name: /^详情$/ })).toBeNull(); + await user.click(screen.getByRole('row', { name: /row-1/ })); + + const jsonPreview = document.querySelector('pre.admin-json-preview'); + expect(jsonPreview).toBeTruthy(); + expect(jsonPreview?.querySelector('.admin-json-token-key')).toBeTruthy(); + expect(jsonPreview?.querySelector('.admin-json-token-boolean')).toBeTruthy(); + expect(jsonPreview?.querySelector('.admin-json-token-number')).toBeTruthy(); + expect(jsonPreview?.querySelector('.admin-json-token-string')).toBeTruthy(); + expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(2); + await user.click(screen.getByRole('button', { name: '复制元数据' })); + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + JSON.stringify({ enabled: true, count: 2, label: 'demo' }, null, 2), + ); + }); + expect(screen.getByRole('status').textContent).toBe('已复制 元数据'); }); test('数据库用户字段显示查看按钮且点击不会打开行详情', async () => { @@ -227,11 +366,28 @@ test('数据库用户字段显示查看按钮且点击不会打开行详情', as , ); - const userButton = await screen.findByRole('button', { name: '查看用户 u-b' }); + const userButton = await screen.findByRole('button', { + name: '查看用户 u-b', + }); await user.click(userButton); expect(screen.queryByRole('dialog')).toBeNull(); }); +test('数据库用户查看按钮上的键盘操作不会同时打开行详情', async () => { + const user = userEvent.setup(); + render( + , + ); + + const userButton = await screen.findByRole('button', { + name: '查看用户 u-b', + }); + userButton.focus(); + await user.keyboard('{Enter}'); + + expect(screen.queryByRole('dialog')).toBeNull(); +}); + test('数据库用户字段识别会排除后台操作者与合成邀请码字段', () => { expect( resolveAdminDatabaseUserReference('profile_wallet', 'owner_user_id', 'u-1'), @@ -250,13 +406,254 @@ test('数据库用户字段识别会排除后台操作者与合成邀请码字 resolveAdminDatabaseUserReference('audit_log', 'admin_user_id', 'u-1'), ).toBeNull(); expect( - resolveAdminDatabaseUserReference('profile_wallet', 'user_id', 'admin:root'), + resolveAdminDatabaseUserReference( + 'profile_wallet', + 'user_id', + 'admin:root', + ), ).toBeNull(); expect( resolveAdminDatabaseUserReference('profile_invite_code', 'user_id', 'u-1'), ).toBeNull(); }); +test('后台表查询页字段条件构建后透传 filters 并支持列表值', async () => { + const user = userEvent.setup(); + render( + , + ); + + await screen.findByText('u-b'); + + await user.click(screen.getByRole('button', { name: '添加条件' })); + expect(screen.getAllByRole('columnheader', { name: '字段' })).toHaveLength(1); + expect(screen.getAllByRole('columnheader', { name: '条件' })).toHaveLength(1); + expect(screen.getAllByRole('columnheader', { name: '值' })).toHaveLength(1); + await user.selectOptions( + screen.getByRole('combobox', { name: '条件 1 字段' }), + 'invite_code', + ); + await user.type( + screen.getByRole('textbox', { name: '条件 1 值' }), + 'INV-1001', + ); + await user.click(screen.getByRole('button', { name: '添加条件' })); + await user.selectOptions( + screen.getByRole('combobox', { name: '条件 2 字段' }), + 'inviter_user_id', + ); + await user.selectOptions( + screen.getByRole('combobox', { name: '条件 2 运算符' }), + 'in', + ); + expect(screen.getByLabelText('条件 2 值列表').className).toContain( + 'admin-database-filter-list-editor', + ); + await user.type( + screen.getByRole('textbox', { name: '条件 2 值输入' }), + 'u-a,b', + ); + await user.click(screen.getByRole('button', { name: '添加条件 2 的值' })); + await user.type( + screen.getByRole('textbox', { name: '条件 2 值输入' }), + 'u-c', + ); + await user.click(screen.getByRole('button', { name: '添加条件 2 的值' })); + expect( + screen.getByText('u-a,b').closest('.admin-database-filter-values-row'), + ).toBeTruthy(); + expect( + screen + .getAllByText('u-c') + .some((element) => element.closest('.admin-database-filter-values-row')), + ).toBe(true); + await user.click(getFormSubmitQueryButton()); + + await waitFor(() => { + expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith( + 'admin-token', + 'profile_referral_relation', + expect.objectContaining({ + filters: JSON.stringify([ + { column: 'invite_code', op: 'eq', value: 'INV-1001' }, + { + column: 'inviter_user_id', + op: 'in', + value: ['u-a,b', 'u-c'], + }, + ]), + page: 1, + }), + ); + }); +}); + +test('后台表查询页可以勾选或取消勾选条件来切换筛选组合', async () => { + const user = userEvent.setup(); + render( + , + ); + + await screen.findByText('u-b'); + + await user.click(screen.getByRole('button', { name: '添加条件' })); + await user.selectOptions( + screen.getByRole('combobox', { name: '条件 1 字段' }), + 'invite_code', + ); + await user.type( + screen.getByRole('textbox', { name: '条件 1 值' }), + 'INV-1001', + ); + await user.click(screen.getByRole('button', { name: '添加条件' })); + await user.selectOptions( + screen.getByRole('combobox', { name: '条件 2 字段' }), + 'inviter_user_id', + ); + await user.type(screen.getByRole('textbox', { name: '条件 2 值' }), 'u-a'); + await user.click(screen.getByRole('checkbox', { name: '启用条件 2' })); + await user.click(getFormSubmitQueryButton()); + + await waitFor(() => { + expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith( + 'admin-token', + 'profile_referral_relation', + expect.objectContaining({ + filters: JSON.stringify([ + { column: 'invite_code', op: 'eq', value: 'INV-1001' }, + ]), + page: 1, + }), + ); + }); +}); + +test('后台表查询页会提示未完成的启用条件而不是静默忽略', async () => { + const user = userEvent.setup(); + const rowsRequest = vi.mocked(getAdminDatabaseTableRows); + render( + , + ); + + await screen.findByText('u-b'); + rowsRequest.mockClear(); + await user.click(screen.getByRole('button', { name: '添加条件' })); + await user.click(getFormSubmitQueryButton()); + + expect(await screen.findByText('条件 1 未完成,请补充后再查询')).toBeTruthy(); + expect(rowsRequest).not.toHaveBeenCalled(); +}); + +test('后台表查询页顶部查询按钮会提示未完成的启用条件而不是静默忽略', async () => { + const user = userEvent.setup(); + const rowsRequest = vi.mocked(getAdminDatabaseTableRows); + render( + , + ); + + await screen.findByText('u-b'); + rowsRequest.mockClear(); + await user.click(screen.getByRole('button', { name: '添加条件' })); + await user.click(getHeadingQueryButton()); + + expect(await screen.findByText('条件 1 未完成,请补充后再查询')).toBeTruthy(); + expect(rowsRequest).not.toHaveBeenCalled(); +}); + +test('后台表查询页列头筛选按钮会带字段添加条件', async () => { + const user = userEvent.setup(); + render( + , + ); + + await screen.findByText('u-b'); + + await user.click( + screen.getByRole('button', { name: '按被邀请人ID添加条件' }), + ); + const columnSelect = screen.getByRole('combobox', { + name: '条件 1 字段', + }) as HTMLSelectElement; + expect(columnSelect.value).toBe('invitee_user_id'); + await user.type(screen.getByRole('textbox', { name: '条件 1 值' }), 'u-b'); + await user.click(getFormSubmitQueryButton()); + + await waitFor(() => { + expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith( + 'admin-token', + 'profile_referral_relation', + expect.objectContaining({ + filters: JSON.stringify([ + { column: 'invitee_user_id', op: 'eq', value: 'u-b' }, + ]), + page: 1, + }), + ); + }); +}); + +test('后台表查询页行详情仅保留复制操作', async () => { + const user = userEvent.setup(); + render( + , + ); + + await screen.findByText('u-b'); + await user.click(screen.getByText('INV-1001').closest('tr')!); + expect(await screen.findByRole('dialog')).toBeTruthy(); + expect(screen.queryByRole('button', { name: '按邀请码筛选' })).toBeNull(); + expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(4); +}); + +test('后台表查询页空字段详情复制会明确提示无法复制空值', async () => { + const user = userEvent.setup(); + const writeText = vi.fn(); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({ + columns: ['id', 'deleted_at'], + limit: 100, + page: 1, + rows: [ + { + cells: { + id: 'row-null', + deleted_at: null, + }, + raw: ['row-null', null], + }, + ], + scannedCount: 1, + scanLimit: 50000, + scanLimitReached: false, + tableName: 'profile_referral_relation', + totalMatched: 1, + totalReturned: 1, + }); + render( + , + ); + + await screen.findByText('row-null'); + await user.click(screen.getByText('row-null').closest('tr')!); + await user.click(screen.getByRole('button', { name: '复制删除时间' })); + + expect(screen.getByRole('alert').textContent).toBe('该字段为空,无法复制'); + expect(writeText).not.toHaveBeenCalled(); +}); + +function getFormSubmitQueryButton() { + const buttons = screen.getAllByRole('button', { name: '查询' }); + return buttons[buttons.length - 1]!; +} + +function getHeadingQueryButton() { + const buttons = screen.getAllByRole('button', { name: '查询' }); + return buttons[0]!; +} + function readFirstColumnValues(container: HTMLElement) { return Array.from(container.querySelectorAll('tbody tr')).map( (row) => row.querySelector('td')?.textContent?.trim() ?? '', diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index f0d114373..22478cf32 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -4,13 +4,16 @@ import { ArrowUpDown, ChevronLeft, ChevronRight, - Eye, + Filter, + Plus, RefreshCcw, Search, X, } from 'lucide-react'; import { + ClipboardEvent, FormEvent, + KeyboardEvent, useEffect, useLayoutEffect, useMemo, @@ -36,6 +39,11 @@ interface AdminDatabaseTablesPageProps { type SortDirection = 'asc' | 'desc'; +type AdminCopyToast = { + message: string; + tone: 'success' | 'error'; +}; + export function AdminDatabaseTablesPage({ token, onUnauthorized, @@ -48,10 +56,11 @@ export function AdminDatabaseTablesPage({ sortColumn: '', sortDirection: 'asc' as SortDirection, }); + const rowsRequestIdRef = useRef(0); const [tables, setTables] = useState([]); const [tableName, setTableName] = useState(() => readHashTableName()); const [search, setSearch] = useState(''); - const [filters, setFilters] = useState(''); + const [conditions, setConditions] = useState([]); const [limit, setLimit] = useState('100'); const [result, setResult] = useState( null, @@ -59,7 +68,7 @@ export function AdminDatabaseTablesPage({ const [detailRow, setDetailRow] = useState(null); const [errorMessage, setErrorMessage] = useState(''); - const [copyMessage, setCopyMessage] = useState(''); + const [copyToast, setCopyToast] = useState(null); const [sortColumn, setSortColumn] = useState(''); const [sortDirection, setSortDirection] = useState('asc'); const [isLoadingTables, setIsLoadingTables] = useState(false); @@ -99,6 +108,14 @@ export function AdminDatabaseTablesPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tableName]); + useEffect(() => { + if (!copyToast) { + return; + } + const timer = window.setTimeout(() => setCopyToast(null), 2200); + return () => window.clearTimeout(timer); + }, [copyToast]); + useLayoutEffect(() => { const pageElement = pageRef.current; if (!pageElement) { @@ -147,6 +164,26 @@ export function AdminDatabaseTablesPage({ return firstRow ? Object.keys(firstRow.cells) : []; }, [result]); + const detailFields = useMemo(() => { + if (!detailRow) { + return []; + } + const cells = detailRow.cells; + if (typeof cells !== 'object' || cells === null || Array.isArray(cells)) { + return []; + } + return Object.entries(cells).map(([column, value]) => { + const columnHeader = getDatabaseTableColumnHeader(tableName, column); + return { + cellValue: formatCellValue(value, column), + column, + description: columnHeader.description, + jsonPreview: getJsonPreviewText(value), + label: columnHeader.label, + }; + }); + }, [detailRow, tableName]); + const tableOptions = useMemo(() => { const optionNames = tableName && !tables.includes(tableName) @@ -211,10 +248,12 @@ export function AdminDatabaseTablesPage({ return; } const querySearch = options.search ?? search; - const queryFilters = options.filters ?? filters; + const queryFilters = + options.filters ?? buildDatabaseFiltersJson(conditions); const queryLimit = options.limit ?? limit; const querySortColumn = options.sortColumn ?? sortColumn; const querySortDirection = options.sortDirection ?? sortDirection; + const requestId = ++rowsRequestIdRef.current; setIsLoadingRows(true); setErrorMessage(''); try { @@ -230,6 +269,9 @@ export function AdminDatabaseTablesPage({ sortDirection: querySortColumn ? querySortDirection : undefined, }, ); + if (requestId !== rowsRequestIdRef.current) { + return; + } appliedQueryRef.current = { search: querySearch, filters: queryFilters, @@ -240,22 +282,42 @@ export function AdminDatabaseTablesPage({ setSortColumn(querySortColumn); setSortDirection(querySortDirection); setResult(response); - setCopyMessage(''); + setCopyToast(null); } catch (error: unknown) { + if (requestId !== rowsRequestIdRef.current) { + return; + } handlePageError(error, onUnauthorized, setErrorMessage); } finally { - setIsLoadingRows(false); + if (requestId === rowsRequestIdRef.current) { + setIsLoadingRows(false); + } } } function handleSearch(event: FormEvent) { event.preventDefault(); + const validationMessage = getDatabaseFilterValidationMessage(conditions); + if (validationMessage) { + setErrorMessage(validationMessage); + return; + } + void refreshRows(tableName, { page: 1 }); + } + + function handleQueryRows() { + const validationMessage = getDatabaseFilterValidationMessage(conditions); + if (validationMessage) { + setErrorMessage(validationMessage); + return; + } void refreshRows(tableName, { page: 1 }); } function handleTableChange(nextTableName: string) { setSortColumn(''); setSortDirection('asc'); + setConditions([]); setTableName(nextTableName); const nextHash = `#tables?table=${encodeURIComponent(nextTableName)}`; if (window.location.hash !== nextHash) { @@ -265,7 +327,7 @@ export function AdminDatabaseTablesPage({ function handleResetQuery() { setSearch(''); - setFilters(''); + setConditions([]); setLimit('100'); setSortColumn(''); setSortDirection('asc'); @@ -279,36 +341,194 @@ export function AdminDatabaseTablesPage({ }); } + function handleAddCondition(column = '') { + const nextConditions = [ + ...conditions, + { + column, + op: 'eq' as DatabaseFilterOperator, + value: '', + values: [], + enabled: true, + }, + ]; + setConditions(nextConditions); + focusConditionControl( + nextConditions.length - 1, + column ? 'value' : 'column', + ); + } + + function handleUpdateCondition( + index: number, + patch: Partial, + ) { + setErrorMessage(''); + setConditions((current) => + current.map((condition, conditionIndex) => + conditionIndex === index ? { ...condition, ...patch } : condition, + ), + ); + } + + function handleConditionOperatorChange( + index: number, + op: DatabaseFilterOperator, + ) { + setErrorMessage(''); + setConditions((current) => + current.map((condition, conditionIndex) => { + if (conditionIndex !== index) { + return condition; + } + const nextOption = getDatabaseFilterOperatorOption(op); + const previousOption = getDatabaseFilterOperatorOption(condition.op); + const nextValues = nextOption?.listValue + ? condition.values.length + ? condition.values + : condition.value.trim() + ? [condition.value.trim()] + : [] + : condition.values; + return { + ...condition, + op, + value: + nextOption?.listValue && !previousOption?.listValue + ? '' + : condition.value, + values: nextValues, + }; + }), + ); + } + + function handleAddConditionListValue(index: number) { + setConditions((current) => + current.map((condition, conditionIndex) => { + if (conditionIndex !== index) { + return condition; + } + const value = condition.value.trim(); + return value + ? { ...condition, value: '', values: [...condition.values, value] } + : condition; + }), + ); + setErrorMessage(''); + } + + function handleRemoveConditionListValue(index: number, valueIndex: number) { + setConditions((current) => + current.map((condition, conditionIndex) => + conditionIndex === index + ? { + ...condition, + values: condition.values.filter( + (_, currentValueIndex) => currentValueIndex !== valueIndex, + ), + } + : condition, + ), + ); + setErrorMessage(''); + } + + function handlePasteConditionListValues( + index: number, + event: ClipboardEvent, + ) { + const pastedText = event.clipboardData.getData('text'); + if (!pastedText.includes('\n')) { + return; + } + event.preventDefault(); + const pastedValues = pastedText + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean); + if (!pastedValues.length) { + return; + } + setConditions((current) => + current.map((condition, conditionIndex) => + conditionIndex === index + ? { + ...condition, + values: [...condition.values, ...pastedValues], + } + : condition, + ), + ); + setErrorMessage(''); + } + + function handleRemoveCondition(index: number) { + setConditions((current) => + current.filter((_, conditionIndex) => conditionIndex !== index), + ); + } + function handlePageChange(page: number) { void refreshRows(tableName, { ...appliedQueryRef.current, page }); } + function handleRowKeyDown( + event: KeyboardEvent, + row: AdminDatabaseTableRowPayload, + ) { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + if (isInteractiveFormControl(event.target)) { + return; + } + event.preventDefault(); + setDetailRow(row); + } + function handleSortColumn(column: string) { - const nextDirection = - sortColumn === column && sortDirection === 'asc' ? 'desc' : 'asc'; + let nextSortColumn = column; + let nextDirection: SortDirection = 'asc'; + if (sortColumn === column) { + if (sortDirection === 'asc') { + nextDirection = 'desc'; + } else { + nextSortColumn = ''; + } + } void refreshRows(tableName, { ...appliedQueryRef.current, page: 1, - sortColumn: column, + sortColumn: nextSortColumn, sortDirection: nextDirection, }); } - async function handleCopyDetailJson() { - if (!detailRow) { + async function handleCopyDetailField(column: string, value: unknown) { + const copiedText = stringifyDetailValue(value); + if ( + value === null || + typeof value === 'undefined' || + copiedText.trim() === '' + ) { + setCopyToast({ + message: '该字段为空,无法复制', + tone: 'error', + }); return; } - - const copiedText = JSON.stringify( - detailRow.raw ?? detailRow.cells, - null, - 2, - ); try { await navigator.clipboard.writeText(copiedText); - setCopyMessage('已复制 JSON'); + setCopyToast({ + message: `已复制 ${getDatabaseTableColumnLabel(column)}`, + tone: 'success', + }); } catch { - setCopyMessage('复制失败,请手动选中后复制'); + setCopyToast({ + message: '复制失败,请手动选中后复制', + tone: 'error', + }); } } @@ -336,7 +556,7 @@ export function AdminDatabaseTablesPage({ className="admin-primary-button" disabled={!tableName || isLoadingRows} type="button" - onClick={() => void refreshRows(tableName, { page: 1 })} + onClick={handleQueryRows} >
+ {conditions.length ? ( +
+
+ 启用 + 字段 + 条件 + + 操作 +
+
+ {conditions.map((condition, index) => { + const operatorOption = getDatabaseFilterOperatorOption( + condition.op, + ); + const validationError = + getDatabaseFilterValidationError(condition); + return ( +
+
+ + + + {operatorOption?.listValue ? ( +
+
+ + handleUpdateCondition(index, { + value: event.target.value, + }) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleAddConditionListValue(index); + } + }} + onPaste={(event) => + handlePasteConditionListValues(index, event) + } + /> + +
+
+ ) : operatorOption?.needsValue ? ( + + ) : ( +
+ + 无需填写 + +
+ )} + +
+ {operatorOption?.listValue && condition.values.length ? ( +
+ + 已添加 + +
+ {condition.values.map((value, valueIndex) => ( + + {value} + + + ))} +
+
+ ) : null} + {condition.enabled && validationError ? ( +
+ {validationError} +
+ ) : null} +
+ ); + })} +
+
+ ) : null} + {conditions.length ? ( +
+ + 当前生效 + + {conditions.filter((condition) => condition.enabled).length ? ( + conditions + .filter((condition) => condition.enabled) + .map((condition, index) => ( + + {formatDatabaseFilterSummary(condition)} + + )) + ) : ( + + 未启用筛选,查询全部数据 + + )} +
+ ) : null} +
+ +
@@ -423,7 +893,7 @@ export function AdminDatabaseTablesPage({ ) : null} -
+

{resultTableHeader.label} @@ -446,32 +916,43 @@ export function AdminDatabaseTablesPage({ ? sortDirection === 'asc' ? 'ascending' : 'descending' - : undefined + : 'none' } > - +

); })} - 详情 @@ -480,13 +961,13 @@ export function AdminDatabaseTablesPage({ setDetailRow(row)} + onKeyDown={(event) => handleRowKeyDown(event, row)} > {visibleColumns.map((column) => { - const cellValue = formatCellValue( - row.cells[column], - column, - ); + const rawValue = row.cells[column]; + const cellValue = formatCellValue(rawValue, column); const userReference = resolveAdminDatabaseUserReference( result.tableName, column, @@ -499,7 +980,15 @@ export function AdminDatabaseTablesPage({ className="admin-table-cell-ellipsis" title={cellValue.fullText} > - {cellValue.content} + {getJsonTablePreviewText(rawValue) ? ( + + {renderJsonSyntax( + getJsonTablePreviewText(rawValue) ?? '', + )} + + ) : ( + cellValue.content + )} {userReference ? ( ); })} - - - )) ) : ( - - 暂无数据 - + 暂无数据 )} @@ -574,6 +1048,17 @@ export function AdminDatabaseTablesPage({ ) : null} + {copyToast ? ( +
+ {copyToast.message} +
+ ) : null} + {detailRow ? (

行详情

-
- {copyMessage ? ( -
{copyMessage}
- ) : null} -
-              {JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2)}
-            
+
+
+ 字段 + + 操作 +
+
+ {detailFields.map( + ({ column, label, description, cellValue, jsonPreview }) => ( +
+
+ {label} +
+
+ {jsonPreview ? ( +
+                            {renderJsonSyntax(jsonPreview)}
+                          
+ ) : ( + cellValue.content + )} +
+
+
+ +
+
+
+ ), + )} +
+
) : null} @@ -630,6 +1162,152 @@ function parseLimit(value: string) { return Number.isFinite(parsed) ? parsed : 100; } +type DatabaseFilterOperator = + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'contains' + | 'notContains' + | 'startsWith' + | 'endsWith' + | 'in' + | 'notIn' + | 'isEmpty' + | 'isNotEmpty'; + +interface DatabaseFilterCondition { + column: string; + op: DatabaseFilterOperator; + value: string; + values: string[]; + enabled: boolean; +} + +interface DatabaseFilterOperatorOption { + op: DatabaseFilterOperator; + label: string; + needsValue: boolean; + listValue: boolean; +} + +const databaseFilterOperatorOptions: DatabaseFilterOperatorOption[] = [ + { op: 'eq', label: '等于', needsValue: true, listValue: false }, + { op: 'ne', label: '不等于', needsValue: true, listValue: false }, + { op: 'contains', label: '包含', needsValue: true, listValue: false }, + { op: 'notContains', label: '不包含', needsValue: true, listValue: false }, + { op: 'startsWith', label: '开头是', needsValue: true, listValue: false }, + { op: 'endsWith', label: '结尾是', needsValue: true, listValue: false }, + { op: 'gt', label: '大于', needsValue: true, listValue: false }, + { op: 'gte', label: '大于等于', needsValue: true, listValue: false }, + { op: 'lt', label: '小于', needsValue: true, listValue: false }, + { op: 'lte', label: '小于等于', needsValue: true, listValue: false }, + { op: 'in', label: '在列表中', needsValue: true, listValue: true }, + { op: 'notIn', label: '不在列表中', needsValue: true, listValue: true }, + { op: 'isEmpty', label: '为空', needsValue: false, listValue: false }, + { op: 'isNotEmpty', label: '不为空', needsValue: false, listValue: false }, +]; + +function getDatabaseFilterOperatorOption(op: DatabaseFilterOperator) { + return databaseFilterOperatorOptions.find((option) => option.op === op); +} + +function buildDatabaseFiltersJson(conditions: DatabaseFilterCondition[]) { + const entries = conditions.flatMap((condition) => { + if (!condition.enabled) { + return []; + } + const entry = buildDatabaseFilterEntry(condition); + return entry ? [entry] : []; + }); + return entries.length ? JSON.stringify(entries) : ''; +} + +function buildDatabaseFilterEntry(condition: DatabaseFilterCondition) { + const column = condition.column.trim(); + if (!column) { + return null; + } + const operatorOption = getDatabaseFilterOperatorOption(condition.op); + if (!operatorOption) { + return null; + } + if (!operatorOption.needsValue) { + return { column, op: condition.op }; + } + if (operatorOption.listValue) { + return condition.values.length + ? { column, op: condition.op, value: condition.values } + : null; + } + const value = condition.value.trim(); + if (!value) { + return null; + } + return { column, op: condition.op, value }; +} + +function getDatabaseFilterValidationError(condition: DatabaseFilterCondition) { + if (!condition.enabled) { + return null; + } + if (!condition.column.trim()) { + return '请选择字段'; + } + const operatorOption = getDatabaseFilterOperatorOption(condition.op); + if (!operatorOption) { + return '请选择有效的条件'; + } + if (operatorOption.listValue && !condition.values.length) { + return '请至少添加一个值'; + } + if (operatorOption.needsValue && !operatorOption.listValue) { + return condition.value.trim() ? null : '请输入筛选值'; + } + return null; +} + +function getDatabaseFilterValidationMessage( + conditions: DatabaseFilterCondition[], +) { + const invalidIndex = conditions.findIndex( + (condition) => getDatabaseFilterValidationError(condition) !== null, + ); + return invalidIndex >= 0 + ? `条件 ${invalidIndex + 1} 未完成,请补充后再查询` + : null; +} + +function formatDatabaseFilterSummary(condition: DatabaseFilterCondition) { + const column = condition.column.trim() || '未选择字段'; + const operatorOption = getDatabaseFilterOperatorOption(condition.op); + const operator = operatorOption?.label ?? condition.op; + if (operatorOption?.listValue) { + return `${column} ${operator} ${condition.values.length ? `[${condition.values.join('、')}]` : '[]'}`; + } + if (!operatorOption?.needsValue) { + return `${column} ${operator}`; + } + return `${column} ${operator} ${condition.value.trim() || '(未填写)'}`; +} + +function focusConditionControl(index: number, target: 'column' | 'value') { + window.setTimeout(() => { + document + .getElementById(`admin-filter-condition-${target}-${index}`) + ?.focus(); + }, 0); +} + +function isInteractiveFormControl(target: EventTarget | null) { + return ( + target instanceof HTMLElement && + target.closest('button, a, input, select, textarea') !== null + ); +} + function getDatabaseTableHeader(tableName: string): DatabaseTableHeader { const normalizedName = tableName.trim(); if (!normalizedName) { @@ -710,7 +1388,7 @@ function getDatabaseTableColumnDescription( const exactDescription = databaseTableColumnDescriptionMap[column]; const description = exactDescription ?? `当前表 ${tableName || '未知'} 中的 ${label} 字段`; - return `原始字段名:${column}。${description}。点击可按此列排序。`; + return `原始字段名:${column}。${description}。点击列名可在正序、倒序和不排序之间循环切换。`; } function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) { @@ -744,13 +1422,10 @@ export function resolveAdminDatabaseUserReference( normalizedColumn === 'public_user_code' || normalizedColumn.endsWith('_public_user_code') ) { - return {publicUserCode: normalizedValue}; + return { publicUserCode: normalizedValue }; } - if ( - normalizedColumn === 'user_id' || - normalizedColumn.endsWith('_user_id') - ) { - return {userId: normalizedValue}; + if (normalizedColumn === 'user_id' || normalizedColumn.endsWith('_user_id')) { + return { userId: normalizedValue }; } return null; } @@ -780,6 +1455,73 @@ function formatCellValue(value: unknown, column = ''): FormattedTableCellValue { }; } +function getJsonPreviewText(value: unknown) { + if (value === null || typeof value !== 'object') { + if (typeof value !== 'string') { + return null; + } + try { + const parsed = JSON.parse(value) as unknown; + return parsed !== null && typeof parsed === 'object' + ? stringifyPrettyUnknownValue(parsed) + : null; + } catch { + return null; + } + } + return stringifyPrettyUnknownValue(value); +} + +function getJsonTablePreviewText(value: unknown) { + if (value === null || typeof value !== 'object') { + if (typeof value !== 'string') { + return null; + } + try { + const parsed = JSON.parse(value) as unknown; + return parsed !== null && typeof parsed === 'object' + ? stringifyUnknownValue(parsed) + : null; + } catch { + return null; + } + } + return stringifyUnknownValue(value); +} + +function renderJsonSyntax(text: string) { + const tokenPattern = + /("(?:\\.|[^"\\])*")(?=\s*:)|("(?:\\.|[^"\\])*")|(-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b)|(\btrue\b|\bfalse\b)|(\bnull\b)/gi; + const parts = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = tokenPattern.exec(text))) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + const token = match[0]; + const className = match[1] + ? 'admin-json-token-key' + : match[2] + ? 'admin-json-token-string' + : match[3] + ? 'admin-json-token-number' + : match[4] + ? 'admin-json-token-boolean' + : 'admin-json-token-null'; + parts.push( + + {token} + , + ); + lastIndex = tokenPattern.lastIndex; + } + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + return parts; +} + function formatReadableTimestampValue( value: string | number | boolean, column: string, @@ -899,6 +1641,19 @@ function stringifyUnknownValue(value: unknown) { } } +function stringifyDetailValue(value: unknown) { + if (value === null || typeof value === 'undefined') { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return stringifyPrettyUnknownValue(value); +} + interface DatabaseTableHeader { name: string; label: string; @@ -1040,7 +1795,9 @@ const databaseTableColumnLabelMap: Record = { record_id: '记录ID', created_by: '创建人', updated_by: '更新人', + updated_by_admin_user_id: '更新管理员', operator_user_id: '操作人ID', + writeridentity: '写入身份', total_count: '总数', max_uses: '最大使用次数', global_used_count: '全局使用次数', diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 719ecf714..e72017719 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -696,7 +696,7 @@ button:disabled { .admin-table-query-grid { display: grid; - grid-template-columns: minmax(180px, 1fr) minmax(160px, 1fr) minmax(220px, 1.2fr) minmax(96px, 0.45fr) auto; + grid-template-columns: minmax(180px, 1fr) minmax(160px, 1fr) minmax(96px, 0.45fr) auto; gap: 12px; align-items: end; } @@ -1084,15 +1084,17 @@ button:disabled { .admin-detail-panel { display: grid; - width: min(100%, 760px); - max-height: min(90dvh, 760px); - gap: 16px; + width: min(100%, 960px); + max-height: min(90dvh, 820px); + gap: 14px; overflow: auto; + scrollbar-width: none; + -ms-overflow-style: none; border: 1px solid #e1ccbb; - border-radius: 10px; + border-radius: 14px; background: #ffffff; box-shadow: 0 22px 60px rgba(112, 57, 30, 0.24); - padding: 18px; + padding: 20px; } .admin-user-detail-backdrop { @@ -1362,10 +1364,20 @@ button:disabled { .admin-table-wrap { max-width: 100%; overflow: auto; - scrollbar-gutter: stable; + scrollbar-width: none; + -ms-overflow-style: none; + scrollbar-gutter: auto; -webkit-overflow-scrolling: touch; } +.admin-table-wrap::-webkit-scrollbar, +.admin-detail-panel::-webkit-scrollbar, +.admin-json-preview::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + .admin-table { width: 100%; min-width: 620px; @@ -1819,20 +1831,82 @@ button:disabled { width: max-content; min-width: 100%; table-layout: fixed; + border-collapse: separate; + border-spacing: 0; +} + +.admin-database-result-panel { + gap: 0; + overflow: hidden; + border-color: #ead8ca; + background: #fffdf9; + box-shadow: 0 12px 28px rgba(112, 57, 30, 0.08); +} + +.admin-database-result-panel > .admin-panel-heading { + padding: 2px 0 14px; +} + +.admin-database-result-panel > .admin-panel-heading span { + color: #9c8373; + font-size: 12px; + font-weight: 750; +} + +.admin-database-table thead { + position: sticky; + top: 0; + z-index: 2; } .admin-database-table th, .admin-database-table td { - width: 220px; - min-width: 220px; - max-width: 220px; + width: 164px; + min-width: 164px; + max-width: 164px; + padding: 12px 13px; + border-bottom: 1px solid #eee2d8; + color: #4d3327; + font-size: 13px; + line-height: 1.4; } -.admin-database-table th:last-child, -.admin-database-table td:last-child { - width: 112px; - min-width: 112px; - max-width: 112px; +.admin-database-table th { + border-bottom-color: #e4d3c5; + color: #8f7868; + background: #f8efe8; + font-size: 11px; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.admin-database-table tbody tr[data-clickable="true"] { + cursor: pointer; + transition: background-color 140ms ease; +} + +.admin-database-table tbody tr:nth-child(even) { + background: #fffbf7; +} + +.admin-database-table tbody tr[data-clickable="true"]:hover { + background: #fff3e9; +} + +.admin-database-table tbody tr:last-child td { + border-bottom: 0; +} + +.admin-table-json-preview { + display: block; + max-width: 100%; + overflow: hidden; + color: inherit; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 11px; + line-height: 1.35; + white-space: nowrap; + text-overflow: ellipsis; } .admin-database-tables-page { @@ -1861,6 +1935,8 @@ button:disabled { .admin-table-sort-button { display: inline-flex; + min-width: 0; + flex: 1 1 auto; align-items: center; gap: 6px; max-width: 100%; @@ -1871,12 +1947,15 @@ button:disabled { text-align: left; font-size: 12px; font-weight: 800; + overflow: hidden; + white-space: nowrap; } .admin-table-sort-button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } .admin-table-sort-button svg { @@ -1898,20 +1977,535 @@ button:disabled { outline: none; } -.admin-json-preview { - max-width: 360px; - max-height: 160px; - margin: 0; - overflow: auto; - color: #4b2412; - font-family: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +.admin-database-column-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; +} + +.admin-table-filter-button { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + border: 0; + color: #b6a396; + background: transparent; + padding: 2px; +} + +.admin-table-filter-button:hover, +.admin-table-filter-button:focus-visible { + color: #8f3f27; + outline: none; +} + +.admin-database-filter-grid { + min-width: 0; + margin-top: 12px; + overflow-x: auto; + padding: 2px 2px 4px; +} + +.admin-database-filter-header, +.admin-database-filter-row { + display: grid; + grid-template-columns: + 52px + minmax(180px, 1.1fr) + minmax(120px, 0.75fr) + minmax(260px, 1.8fr) + 42px; + min-width: 720px; + gap: 10px; + align-items: center; +} + +.admin-database-filter-header { + border-bottom: 1px solid #eaded2; + padding: 0 11px 8px; + color: #9c8373; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.admin-database-filter-header > span:first-child, +.admin-database-filter-header > span:last-child { + text-align: center; +} + +.admin-database-filter-list { + display: grid; + gap: 2px; +} + +.admin-database-filter-item { + display: grid; + gap: 4px; + border: 0; + border-radius: 0; + background: transparent; + padding: 8px 10px; + transition: + background-color 160ms ease, + opacity 160ms ease; +} + +.admin-database-filter-item:focus-within { + background: #fffaf5; +} + +.admin-database-filter-row { + width: 100%; +} + +.admin-database-filter-row[data-enabled='false'] { + opacity: 0.58; +} + +.admin-database-filter-row .admin-field { + gap: 0; +} + +.admin-database-filter-toggle { + display: inline-flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 4px; +} + +.admin-database-filter-toggle input { + width: 16px; + height: 16px; + accent-color: #8f3f27; +} + +.admin-database-filter-field-label { + display: block; + margin-bottom: 4px; + color: #9c8373; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.02em; +} + +.admin-database-filter-list-editor { + display: block; + min-width: 0; + min-height: 38px; +} + +.admin-database-filter-list-input-row { + display: flex; + flex: 1 1 240px; + min-width: 240px; + align-items: center; + gap: 6px; +} + +.admin-database-filter-list-input-row input { + flex: 1 1 auto; + width: 100%; + min-width: 0; + min-height: 34px; + border: 1px solid #dfc8b7; + border-radius: 7px; + color: #3d1f10; + background: #ffffff; + padding: 7px 9px; + outline: none; +} + +.admin-database-filter-list-input-row input:focus { + border-color: #b6623f; + box-shadow: 0 0 0 3px rgba(204, 117, 76, 0.14); +} + +.admin-database-filter-add-value { + min-height: 38px; + padding: 7px 10px; +} + +.admin-database-filter-value-chips { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 5px; + min-height: 28px; + align-items: center; +} + +.admin-database-filter-values-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: 52px minmax(0, 1fr); + gap: 10px; + align-items: start; + padding-top: 2px; +} + +.admin-database-filter-values-label { + padding-top: 6px; + color: #a08b7d; + font-size: 11px; + font-weight: 750; + text-align: center; +} + +.admin-database-filter-value-chip { + display: inline-flex; + min-width: 0; + max-width: 100%; + align-items: center; + gap: 4px; + border: 1px solid #e5c7b5; + border-radius: 999px; + color: #6d321f; + background: #fff3ea; + padding: 5px 6px 5px 9px; font-size: 12px; - line-height: 1.45; + line-height: 1.2; +} + +.admin-database-filter-value-chip > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-database-filter-value-chip button { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 0; + border-radius: 50%; + color: #9c6751; + background: transparent; + padding: 2px; +} + +.admin-database-filter-value-chip button:hover, +.admin-database-filter-value-chip button:focus-visible { + color: #6d321f; + background: rgba(143, 63, 39, 0.12); + outline: none; +} + +.admin-database-filter-value-slot { + display: grid; + align-content: center; + min-height: 38px; +} + +.admin-database-filter-no-value { + color: #b09a8b; + font-size: 13px; +} + +.admin-database-filter-error { + margin-left: 34px; + color: #a13b2b; + font-size: 12px; + font-weight: 700; +} + +.admin-database-filter-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 10px; + color: #755a49; + font-size: 12px; +} + +.admin-database-filter-summary-label { + margin-right: 2px; + color: #9c8373; + font-weight: 800; +} + +.admin-database-filter-summary-chip { + max-width: min(420px, 100%); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border: 1px solid #eaded2; + border-radius: 999px; + background: #fffaf5; + padding: 4px 9px; +} + +.admin-database-filter-summary-empty { + color: #a08b7d; +} + +.admin-database-filter-remove { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + min-height: 38px; + padding: 0; +} + +.admin-database-filter-actions { + margin-top: 10px; + justify-content: flex-start; +} + +.admin-database-detail-table { + display: grid; + min-width: 0; + overflow: hidden; + border: 1px solid #eaded2; + border-radius: 10px; + background: #fffdf9; +} + +.admin-database-detail-table-header, +.admin-database-detail-field { + display: grid; + grid-template-columns: minmax(120px, 0.32fr) minmax(0, 1fr) auto; + gap: 14px; + align-items: center; +} + +.admin-database-detail-table-header { + padding: 10px 14px; + border-bottom: 1px solid #eaded2; + color: #9c8373; + background: #f8efe8; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.admin-database-detail-table-header > span:last-child { + min-width: 58px; + text-align: center; +} + +.admin-database-detail-field-list { + display: grid; + min-width: 0; +} + +.admin-database-detail-field { + min-width: 0; + padding: 12px 14px; + border-bottom: 1px solid #f0e5dc; +} + +.admin-database-detail-field:last-child { + border-bottom: 0; +} + +.admin-database-detail-field:nth-child(even) { + background: #fffbf7; +} + +.admin-database-detail-field-name { + min-width: 0; + color: #8f7868; + font-size: 12px; + font-weight: 800; + overflow-wrap: anywhere; +} + +.admin-database-detail-field-value { + min-width: 0; + color: #3d1f10; + font-size: 13.5px; + line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; } +.admin-database-detail-field-value > div { + min-width: 0; +} + +.admin-database-detail-field .admin-secondary-button { + min-width: 58px; + padding: 7px 9px; +} + +.admin-database-detail-row-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.admin-json-preview { + max-width: 100%; + max-height: 240px; + margin: 0; + overflow: auto; + scrollbar-width: thin; + scrollbar-color: #d1b09a transparent; + border: 1px solid #ead8ca; + border-radius: 8px; + color: #4d3327; + background: #fffaf5; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.55; + padding: 11px 13px; + white-space: pre; + tab-size: 2; +} + +.admin-json-preview::-webkit-scrollbar { + display: block; + width: 6px; + height: 6px; + background: transparent; +} + +.admin-json-preview::-webkit-scrollbar-track { + background: transparent; +} + +.admin-json-preview::-webkit-scrollbar-thumb { + border: 1px solid transparent; + border-radius: 999px; + background: #d1b09a; + background-clip: content-box; +} + +.admin-json-token-key { + color: #a34b2f; +} + +.admin-json-token-string { + color: #2e7a52; +} + +.admin-json-token-number { + color: #236b9b; +} + +.admin-json-token-boolean { + color: #6a42a3; +} + +.admin-json-token-null { + color: #a03f55; +} + +.admin-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 120; + max-width: min(360px, calc(100vw - 32px)); + border: 1px solid #b9dfc2; + border-radius: 10px; + color: #27663a; + background: rgba(241, 252, 243, 0.96); + box-shadow: 0 14px 32px rgba(69, 47, 35, 0.16); + padding: 10px 14px; + font-size: 13px; + font-weight: 700; + line-height: 1.4; + pointer-events: none; + animation: admin-toast-in 160ms ease-out; +} + +.admin-toast[data-tone='error'] { + border-color: #efb9aa; + color: #8f3f27; + background: rgba(255, 242, 237, 0.97); +} + +@keyframes admin-toast-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.platform-theme--dark .admin-json-preview, +[data-theme='dark'] .admin-json-preview, +.admin-theme-dark .admin-json-preview { + border-color: #4c3a3f; + color: #f7eee8; + background: #2d2427; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.platform-theme--dark .admin-json-preview, +[data-theme='dark'] .admin-json-preview, +.admin-theme-dark .admin-json-preview { + scrollbar-color: #8c7180 transparent; +} + +.platform-theme--dark .admin-json-preview::-webkit-scrollbar-thumb, +[data-theme='dark'] .admin-json-preview::-webkit-scrollbar-thumb, +.admin-theme-dark .admin-json-preview::-webkit-scrollbar-thumb { + background: #8c7180; + background-clip: content-box; +} + +.platform-theme--dark .admin-json-token-key, +[data-theme='dark'] .admin-json-token-key, +.admin-theme-dark .admin-json-token-key { + color: #f6c177; +} + +.platform-theme--dark .admin-json-token-string, +[data-theme='dark'] .admin-json-token-string, +.admin-theme-dark .admin-json-token-string { + color: #b9e6b1; +} + +.platform-theme--dark .admin-json-token-number, +[data-theme='dark'] .admin-json-token-number, +.admin-theme-dark .admin-json-token-number { + color: #a9d9ff; +} + +.platform-theme--dark .admin-json-token-boolean, +[data-theme='dark'] .admin-json-token-boolean, +.admin-theme-dark .admin-json-token-boolean { + color: #d2b5ff; +} + +.platform-theme--dark .admin-json-token-null, +[data-theme='dark'] .admin-json-token-null, +.admin-theme-dark .admin-json-token-null { + color: #f09aa5; +} + +.platform-theme--dark .admin-toast, +[data-theme='dark'] .admin-toast, +.admin-theme-dark .admin-toast { + border-color: #446d50; + color: #c9f2d0; + background: rgba(35, 54, 41, 0.96); +} + +.platform-theme--dark .admin-toast[data-tone='error'], +[data-theme='dark'] .admin-toast[data-tone='error'], +.admin-theme-dark .admin-toast[data-tone='error'] { + border-color: #7c4a43; + color: #ffc5bb; + background: rgba(67, 39, 38, 0.96); +} + .admin-contract-card { display: grid; gap: 12px; @@ -2050,8 +2644,7 @@ button:disabled { background: #3d1f10; color: #fdf9f5; padding: 14px; - font-family: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12px; line-height: 1.5; white-space: pre-wrap; @@ -2135,7 +2728,6 @@ button:disabled { padding: 5px 8px; } - .admin-bottom-nav { display: none; } @@ -2183,6 +2775,32 @@ button:disabled { grid-template-columns: 1fr; } + .admin-database-filter-grid { + margin-right: -2px; + margin-left: -2px; + } + + .admin-database-filter-header, + .admin-database-filter-row { + min-width: 680px; + grid-template-columns: + 48px + minmax(160px, 1.1fr) + minmax(112px, 0.75fr) + minmax(230px, 1.8fr) + 40px; + } + + .admin-database-filter-item { + padding: 9px; + } + + .admin-database-detail-table-header, + .admin-database-detail-field { + grid-template-columns: minmax(92px, 0.34fr) minmax(0, 1fr) auto; + gap: 9px; + } + .admin-permission-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2254,6 +2872,11 @@ button:disabled { padding: 10px 14px; } + .admin-toast { + right: 16px; + bottom: calc(var(--admin-bottom-nav-height, 64px) + 16px); + } + .admin-database-tables-page { padding-bottom: calc( var(--admin-bottom-nav-height, 64px) + @@ -2272,7 +2895,7 @@ button:disabled { font-weight: 700; } - .admin-bottom-nav-button[data-active="true"] { +.admin-bottom-nav-button[data-active="true"] { color: #8f3f27; background: #f4e5d7; } diff --git a/apps/admin-web/src/styles/admin.test.ts b/apps/admin-web/src/styles/admin.test.ts index 50ac07c9d..2e030f439 100644 --- a/apps/admin-web/src/styles/admin.test.ts +++ b/apps/admin-web/src/styles/admin.test.ts @@ -21,7 +21,9 @@ describe('admin shell scrolling contract', () => { }); test('mobile navigation stays in one horizontally scrollable row', () => { - const mobileStyles = stylesheet.slice(stylesheet.indexOf('@media (max-width: 980px)')); + const mobileStyles = stylesheet.slice( + stylesheet.indexOf('@media (max-width: 980px)'), + ); expect(mobileStyles).toContain('.admin-bottom-nav {'); expect(mobileStyles).toContain('display: flex'); expect(mobileStyles).toContain('overflow-x: auto'); @@ -30,6 +32,37 @@ describe('admin shell scrolling contract', () => { ); expect(mobileStyles).toContain('flex: 0 0 76px'); }); + + test('database preview headers stay on one line and expose ellipsis', () => { + const sortButton = ruleFor('.admin-table-sort-button'); + const sortLabel = ruleFor('.admin-table-sort-button span'); + expect(sortButton).toContain('white-space: nowrap'); + expect(sortButton).toContain('overflow: hidden'); + expect(sortLabel).toContain('text-overflow: ellipsis'); + expect(sortLabel).toContain('white-space: nowrap'); + }); + + test('copy feedback uses an auto-dismissing toast surface', () => { + const toast = ruleFor('.admin-toast'); + expect(toast).toContain('position: fixed'); + expect(toast).toContain('z-index: 120'); + expect(toast).toContain('pointer-events: none'); + expect(toast).toContain('animation: admin-toast-in'); + expect(stylesheet).toContain(".admin-toast[data-tone='error']"); + }); + + test('detail JSON keeps only a slim scrollbar thumb', () => { + const json = ruleFor('.admin-json-preview'); + expect(json).toContain('scrollbar-width: thin'); + expect(json).toContain('scrollbar-color: #d1b09a transparent'); + expect(stylesheet).toContain( + '.admin-json-preview::-webkit-scrollbar-track', + ); + expect(stylesheet).toContain('background: transparent'); + expect(stylesheet).toContain( + '.admin-json-preview::-webkit-scrollbar-thumb', + ); + }); }); function ruleFor(selector: string) { diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index 2ad081566..bc4fa72f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -10,7 +10,7 @@ Let the client derive projections from real disk changes and trusted tool result ## Workflow 1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media. -2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (PNG/JPEG/WEBP) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. +2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. 5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 8bce1c850..15006410b 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -8,7 +8,7 @@ The client projects three distinct facts: Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance. -`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered media path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true only for PNG/JPEG/WEBP files accepted by the current local-image registration contract; GIF/SVG and non-image files remain discoverable but must not be passed to the image importer. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local image. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. +`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/SKILL.md index ad45a6cea..836ac2194 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/SKILL.md @@ -12,7 +12,7 @@ Treat the current working directory as the only project root. 1. Inspect the existing files needed for the request before editing. 2. The current working directory is the selected project root. Read and edit `index.html`, `style.css`, `game.js`, and `assets/` there unless the existing project deliberately uses a `game/` subdirectory for its source. 3. To discover media or other existing project files, call `agc_list_project_files` with an optional project-relative scope. It returns safe project-relative paths (including `assets/` and `game/`) plus bounded metadata; an unregistered file is only a discovery candidate, not a manifest asset. -4. Platform media and project-local media are exposed read-only through approved `agc_tools`; when a user asks to use an unregistered PNG/JPEG/WEBP, pass the returned project-relative path to `agc_import_account_assets.localPaths`, then re-read `agc_list_registered_assets` for the formal identity. Do not infer provenance or fabricate an asset ID from a filename. +4. Platform media and project-local media are exposed read-only through approved `agc_tools`; when a user asks to use an unregistered recognized image, font, audio, video, document, or code file, pass the returned project-relative path to `agc_import_account_assets.localPaths`, then re-read `agc_list_registered_assets` for the formal identity. Do not infer provenance or fabricate an asset ID from a filename. 5. Treat the parent `.agent/` directory as client-owned durable state. Do not read it with native file or shell tools; use the approved AGC tools when project identity or registered asset evidence is needed. Never hand-edit manifests, revisions, versions, ledgers, receipts, or provenance records. 6. Reuse existing files and asset identities. Do not create a second project root, hidden harness, Supervisor workspace, or parallel implementation. 7. Make the smallest coherent change that satisfies the user request, then inspect the actual changed files. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/references/structure-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/references/structure-contract.md index e2dab9c90..ad90265c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/references/structure-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/references/structure-contract.md @@ -5,7 +5,7 @@ | `index.html` | Game source in the current cwd | Read and edit | | `style.css` | Game source in the current cwd | Read and edit | | `game.js` | Game source in the current cwd | Read and edit | -| `assets/` | Project media in the current cwd | Read and edit; import an unregistered PNG/JPEG/WEBP through `agc_import_account_assets.localPaths`; formal identity comes only after manifest registration | +| `assets/` | Project media in the current cwd | Read and edit; import an unregistered recognized resource through `agc_import_account_assets.localPaths`; formal identity comes only after manifest registration | | Other project-root-relative files | Existing project files | Discover with `agc_list_project_files` or `file.list`; do not treat a path as a registered asset or expose sensitive/control paths | | `.agent/` | AGC client state | Do not read or write with native tools | diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index a5d8a510d..6d38b0b09 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -21,7 +21,7 @@ "agents/openai.yaml", "references/structure-contract.md" ], - "sha256": "2556b40c4e73c8af5c027d1222b569d34129880dfeabff38b50ad248307b5c0c" + "sha256": "85dd861201d7b9a5d34b702b7b79ce18012e06784dddf2fb8e836e085fee4b00" }, { "name": "taonier-art-assets", @@ -106,7 +106,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "2e11baf232bd1a786cc3189a9183e3a687b846c4e0816393e5b5687551a5eeb7" + "sha256": "7800e8ee4b5baa6f473b904f262197f5320a46150ae5e51c56a298fc21674e30" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 798bac52e..68c0234c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -3969,7 +3969,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; if prepare_art { - system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实美术资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的 PNG/JPEG/WEBP,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); + system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档或代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); } else { system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。"); 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 7330dd43b..813215a9b 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 @@ -1268,6 +1268,8 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) "png" => ("image", Some("image/png")), "jpg" | "jpeg" => ("image", Some("image/jpeg")), "webp" => ("image", Some("image/webp")), + "avif" => ("image", Some("image/avif")), + "bmp" => ("image", Some("image/bmp")), "gif" => ("image", Some("image/gif")), "svg" => ("image", Some("image/svg+xml")), "ttf" => ("font", Some("font/ttf")), @@ -1279,15 +1281,26 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) "ogg" => ("audio", Some("audio/ogg")), "flac" => ("audio", Some("audio/flac")), "m4a" => ("audio", Some("audio/mp4")), + "aac" => ("audio", Some("audio/aac")), + "opus" => ("audio", Some("audio/opus")), "mp4" => ("video", Some("video/mp4")), "webm" => ("video", Some("video/webm")), "mov" => ("video", Some("video/quicktime")), + "md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml" + | "csv" | "ini" | "conf" | "xml" => ("document", Some(match extension.as_str() { + "json" => "application/json", + "yaml" | "yml" => "application/yaml", + "xml" => "application/xml", + _ => "text/plain", + })), "html" | "htm" => ("code", Some("text/html")), - "css" => ("code", Some("text/css")), + "css" | "scss" | "less" => ("code", Some("text/css")), "js" | "mjs" | "cjs" | "ts" | "tsx" => ("code", Some("text/javascript")), - "json" => ("code", Some("application/json")), - "md" | "txt" => ("code", Some("text/plain")), - "gd" => ("code", Some("text/plain")), + "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" + | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" + | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => { + ("code", Some("text/plain")) + } _ => ("other", None), } } @@ -1295,7 +1308,7 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) fn bridge_project_file_is_asset_importable(path: &str) -> bool { matches!( bridge_project_file_class(path).1, - Some("image/png" | "image/jpeg" | "image/webp") + Some(_) ) } @@ -1332,7 +1345,7 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { .map(|value| value.to_lowercase()); let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)? .unwrap_or_else(|| "all".to_string()); - if !["all", "image", "font", "audio", "video", "code"].contains(&requested_kind.as_str()) { + if !["all", "image", "font", "audio", "video", "document", "code"].contains(&requested_kind.as_str()) { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } let (offset, limit) = bridge_account_asset_page(arguments)?; @@ -1396,7 +1409,7 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { "limit": limit, "nextOffset": next_offset, "files": page, - "next": "仅把未登记且 assetImportable=true 的 PNG/JPEG/WEBP path 作为项目相对 localPaths 交给 agc_import_account_assets;登记后再用 agc_list_registered_assets 获取 localAssetId。" + "next": "仅把未登记且 assetImportable=true 的项目相对路径交给 agc_import_account_assets;登记后再用 agc_list_registered_assets 获取 localAssetId。" })) })(); match result { @@ -1542,7 +1555,7 @@ async fn bridge_list_account_assets(state: &DirectToolBridgeState, arguments: &V "limit": limit, "nextOffset": next_offset, "assets": page, - "next": "账户素材或 project-canvas 资源使用返回的 assetId/resourceId 调用 agc_import_account_assets;项目内本地图片先用 agc_list_project_files;不要提交 objectKey、URL 或宿主绝对路径" + "next": "账户素材或 project-canvas 资源使用返回的 assetId/resourceId 调用 agc_import_account_assets;项目内本地资源先用 agc_list_project_files;不要提交 objectKey、URL 或宿主绝对路径" })) } .await; @@ -1590,7 +1603,7 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments: } } if !local_paths.is_empty() { - match import_local_project_image_assets_for_agent(&state.root, &local_paths) { + match import_local_project_assets_for_agent(&state.root, &local_paths) { Ok(result) => imported.extend(result.assets.into_iter().map(|asset| { json!({ "id": asset.id, @@ -2407,15 +2420,22 @@ mod tests { "supported raster image should be importable: {path}" ); } + for path in ["assets/theme.bin", "assets/unknown.xyz"] { + assert!( + !bridge_project_file_is_asset_importable(path), + "unsupported project file must not be advertised as importable: {path}" + ); + } for path in [ "assets/hero.gif", "assets/hero.svg", "assets/theme.mp3", "game/index.html", + "assets/design.md", ] { assert!( - !bridge_project_file_is_asset_importable(path), - "unsupported project file must not be advertised as importable: {path}" + bridge_project_file_is_asset_importable(path), + "recognized project file should be advertised as importable: {path}" ); } } @@ -2463,8 +2483,8 @@ mod tests { }) .collect::>(); assert_eq!(importability.get("assets/hero.png"), Some(&true)); - assert_eq!(importability.get("assets/preview.gif"), Some(&false)); - assert_eq!(importability.get("assets/vector.svg"), Some(&false)); + assert_eq!(importability.get("assets/preview.gif"), Some(&true)); + assert_eq!(importability.get("assets/vector.svg"), Some(&true)); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 7c896bd3a..624ea552a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -212,7 +212,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }), json!({ "name": "agc_list_project_files", - "description": "列出当前 AGC 项目根下真实存在的安全项目文件,包括尚未登记的本地图片。结果只返回项目相对路径、大小、文件类别、是否已登记及 assetImportable;不会读取或返回文件内容、宿主绝对路径、.agent 控制面或凭据。仅把 assetImportable=true 的 PNG/JPEG/WEBP 项目相对路径交给 agc_import_account_assets.localPaths。", + "description": "列出当前 AGC 项目根下真实存在的安全项目文件,包括尚未登记的本地资源。结果只返回项目相对路径、大小、文件类别、是否已登记及 assetImportable;不会读取或返回文件内容、宿主绝对路径、.agent 控制面或凭据。仅把 assetImportable=true 的项目相对路径交给 agc_import_account_assets.localPaths。", "inputSchema": { "type": "object", "properties": { @@ -229,7 +229,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }, "kind": { "type": "string", - "enum": ["all", "image", "font", "audio", "video", "code"] + "enum": ["all", "image", "font", "audio", "video", "document", "code"] }, "offset": { "type": "integer", "minimum": 0, "maximum": 500 }, "limit": { "type": "integer", "minimum": 1, "maximum": 100 } @@ -239,7 +239,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }), json!({ "name": "agc_list_account_assets", - "description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片安全投影。客户端重新校验当前账号并隐藏 objectKey、URL、签名地址、宿主路径和凭据;结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地图片另用 agc_list_project_files 发现。", + "description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片安全投影。客户端重新校验当前账号并隐藏 objectKey、URL、签名地址、宿主路径和凭据;结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地资源另用 agc_list_project_files 发现。", "inputSchema": { "type": "object", "properties": { @@ -253,7 +253,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }), json!({ "name": "agc_import_account_assets", - "description": "导入账户图片、已绑定网页项目画布图片或项目内本地图片。assetIds 必须使用 agc_list_account_assets 返回的账户 assetId 或 project-canvas resourceId;本地图片只能使用 agc_list_project_files 返回的项目根相对 localPaths(包括 assets/ 与 game/),不得使用 .agent、父级穿越或宿主绝对路径。assetIds 与 localPaths 可混合提交;客户端负责账号/项目归属、换签下载、格式/大小校验、项目锁、manifest 与 revision,模型不得提交 objectKey、URL 或凭据。", + "description": "导入账户图片、已绑定网页项目画布图片或项目内本地资源。assetIds 必须使用 agc_list_account_assets 返回的账户 assetId 或 project-canvas resourceId;本地资源只能使用 agc_list_project_files 返回的项目根相对 localPaths(包括 assets/ 与 game/),不得使用 .agent、父级穿越或宿主绝对路径。assetIds 与 localPaths 可混合提交;客户端负责账号/项目归属、换签下载、格式/大小校验、项目锁、manifest 与 revision,模型不得提交 objectKey、URL 或凭据。", "inputSchema": { "type": "object", "properties": { @@ -569,7 +569,9 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String> } if arguments.get("kind").is_some() { let kind = bounded_tool_string(arguments, "kind", 16)?; - if !["all", "image", "font", "audio", "video", "code"].contains(&kind.as_str()) { + if !["all", "image", "font", "audio", "video", "document", "code"] + .contains(&kind.as_str()) + { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } } @@ -1344,6 +1346,11 @@ mod tests { "limit": 100 })) .is_ok()); + assert!(validate_project_file_list_arguments(&json!({ + "path": "assets", + "kind": "document" + })) + .is_ok()); assert!(validate_project_file_list_arguments(&json!({ "path": "../outside" })) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index e1b1a1aa5..2fb70fe5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -159,10 +159,10 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result Option<&'static str> { "jpg" | "jpeg" => Some("image/jpeg"), "webp" => Some("image/webp"), "gif" => Some("image/gif"), + "avif" => Some("image/avif"), + "bmp" => Some("image/bmp"), "svg" => Some("image/svg+xml"), "ttf" => Some("font/ttf"), "otf" => Some("font/otf"), @@ -210,13 +212,48 @@ fn prompt_context_media_type(path: &str) -> Option<&'static str> { "ogg" => Some("audio/ogg"), "flac" => Some("audio/flac"), "m4a" => Some("audio/mp4"), + "aac" => Some("audio/aac"), + "opus" => Some("audio/opus"), "mp4" => Some("video/mp4"), "webm" => Some("video/webm"), "mov" => Some("video/quicktime"), + "json" => Some("application/json"), + "yaml" | "yml" => Some("application/yaml"), + "xml" => Some("application/xml"), + "html" | "htm" => Some("text/html"), + "md" | "markdown" | "mdx" | "txt" | "toml" | "csv" | "ini" | "conf" => { + Some("text/plain") + } + "css" | "scss" | "less" => Some("text/css"), + "js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" + | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" + | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" + | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"), _ => None, } } +#[cfg(test)] +mod tests { + use super::prompt_context_media_type; + + #[test] + fn prompt_context_media_type_covers_all_importable_resource_categories() { + for (path, expected) in [ + ("assets/icon.avif", "image/avif"), + ("assets/font.woff2", "font/woff2"), + ("assets/theme.opus", "audio/opus"), + ("assets/intro.mov", "video/quicktime"), + ("assets/data.json", "application/json"), + ("game/index.html", "text/html"), + ("game/main.rs", "text/plain"), + ] { + assert_eq!(prompt_context_media_type(path), Some(expected), "{path}"); + } + assert_eq!(prompt_context_media_type("assets/unknown.bin"), None); + } +} + pub(crate) fn render_local_conversation_prompt_context( root: &Path, agent_id: Option<&str>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 51e452b0f..7af11a305 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -384,8 +384,8 @@ fn build_game_creator_agent_background_tool_plan_request_at( "file.list 使用 {{\"path\":\"\"}},path 为空字符串时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}};file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件。\n", "task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 ID;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};{limited_command_contract}\n", "canvas.asset_generate 使用 {{\"prompt\":\"图片描述\",\"outputPath\":null,\"aspectRatio\":null,\"imageSize\":null,\"assetKind\":null,\"assetLabel\":null,\"replaceExisting\":false}};需要指定时,aspectRatio 只允许 1:1|2:3|3:2|9:16|16:9,imageSize 只允许 0.5K|1K|2K,assetKind 只允许 {canvas_asset_kind_catalog}。replaceExisting 只能在带 repairOfDelegationId 的唯一返工委派中设为 true,普通生成必须为 false,并通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets。\n", - "asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;结果只含 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地图片。\n", - "canvas.asset_import 使用 {{\"assetIds\":[],\"localPaths\":[]}};assetIds 必须来自最近一次 asset.library.list(账户素材或 project-canvas 资源均可),localPaths 必须是 file.list 返回的项目根内相对 PNG/JPEG/WEBP 路径(包括 assets/ 与 game/),不能提交 objectKey、URL、绝对路径或凭据。两类数组可以混合提交;导入成功后 Runtime 会下载/校验或登记本地图片、更新 manifest 并推进 revision。\n", + "asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;结果只含 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地资源。\n", + "canvas.asset_import 使用 {{\"assetIds\":[],\"localPaths\":[]}};assetIds 必须来自最近一次 asset.library.list(账户素材或 project-canvas 资源均可),localPaths 必须是 file.list 返回的项目根内相对已识别资源路径(包括 assets/ 与 game/),不能提交 objectKey、URL、绝对路径或凭据。两类数组可以混合提交;导入成功后 Runtime 会下载/校验或登记本地资源、更新 manifest 并推进 revision。\n", "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;当前可信父 Run 传 delegationId 时读取自己已认领的未截断权威返工合同。\n", "只有 conversation.read、asset.list、project.index、project.checkpoint、task.list、preview.start 的 arguments.input 使用空对象 {{}};asset.library.list 也允许使用其广告 schema 中的全 null/分页默认值;其他函数必须提交实际广告 schema 的全部 required 字段。如果已有观察足够,必须调用 respond_to_user 交付最终回复。" ), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs index 0580895f0..cbcc08d97 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs @@ -475,7 +475,7 @@ fn runtime_asset_import_string_array( .any(|part| part.eq_ignore_ascii_case(".codex")) || reject_sensitive_project_file_read(text).is_err() { - return Err("localPaths 只能使用受控项目根内的项目相对图片路径".to_string()); + return Err("localPaths 只能使用受控项目根内的项目相对资源路径".to_string()); } } Ok(text.to_string()) @@ -579,7 +579,7 @@ pub(in crate::agent) async fn observe_agent_runtime_asset_import( } } if !local_paths.is_empty() { - match import_local_project_image_assets_for_agent(root, &local_paths) { + match import_local_project_assets_for_agent(root, &local_paths) { Ok(result) => imported.extend(result.assets.into_iter().map(|asset| { serde_json::json!({ "id": asset.id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 8ef1b13d1..3f34dc890 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1356,7 +1356,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "读取当前登录账户的云端/网页素材库静态图片,以及当前项目已绑定网页画布 project.resources 图片安全投影;不返回 URL、objectKey、签名地址或凭据。" } "canvas.asset_import" => { - "把账户素材库/绑定网页项目画布中的 assetId(resourceId)或项目内 localPaths 图片导入当前项目并登记 manifest;两类输入可混合。账户与画布素材先用 asset.library.list 查询,本地图片先用 file.list 发现;客户端内部负责归属校验、换签下载、格式/大小校验、项目锁和 revision。" + "把账户素材库/绑定网页项目画布中的 assetId(resourceId)或项目内 localPaths 资源导入当前项目并登记 manifest;两类输入可混合。账户与画布素材先用 asset.library.list 查询,本地资源先用 file.list 发现;客户端内部负责归属校验、换签下载、格式/大小校验、项目锁和 revision。" } "project.index" => "刷新并读取有界仓库启动上下文。", "project.search" => "在项目文本文件中做有界字面量搜索。", diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 2abccf8f7..d438bf0df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1428,26 +1428,81 @@ pub(crate) fn percent_encode_query_component(value: &str) -> String { pub(crate) fn infer_file_extension(source: Option<&str>, media_type: &str) -> &'static str { if let Some(source) = source { let path = source.split('?').next().unwrap_or(source); - if path.ends_with(".png") { - return "png"; - } - if path.ends_with(".jpg") || path.ends_with(".jpeg") { - return "jpg"; - } - if path.ends_with(".webp") { - return "webp"; - } - if path.ends_with(".gif") { - return "gif"; - } - if path.ends_with(".mp3") { - return "mp3"; - } - if path.ends_with(".wav") { - return "wav"; - } - if path.ends_with(".mp4") { - return "mp4"; + match Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("png") => return "png", + Some("jpg" | "jpeg") => return "jpg", + Some("webp") => return "webp", + Some("gif") => return "gif", + Some("svg") => return "svg", + Some("avif") => return "avif", + Some("bmp") => return "bmp", + Some("ttf") => return "ttf", + Some("otf") => return "otf", + Some("woff") => return "woff", + Some("woff2") => return "woff2", + Some("mp3") => return "mp3", + Some("wav") => return "wav", + Some("ogg") => return "ogg", + Some("flac") => return "flac", + Some("m4a") => return "m4a", + Some("aac") => return "aac", + Some("opus") => return "opus", + Some("mp4") => return "mp4", + Some("webm") => return "webm", + Some("mov") => return "mov", + Some("md") => return "md", + Some("markdown") => return "markdown", + Some("mdx") => return "mdx", + Some("txt") => return "txt", + Some("json") => return "json", + Some("yaml") => return "yaml", + Some("yml") => return "yml", + Some("toml") => return "toml", + Some("csv") => return "csv", + Some("ini") => return "ini", + Some("conf") => return "conf", + Some("xml") => return "xml", + Some("html") => return "html", + Some("htm") => return "htm", + Some("css") => return "css", + Some("scss") => return "scss", + Some("less") => return "less", + Some("js") => return "js", + Some("mjs") => return "mjs", + Some("cjs") => return "cjs", + Some("ts") => return "ts", + Some("tsx") => return "tsx", + Some("gd") => return "gd", + Some("rs") => return "rs", + Some("py") => return "py", + Some("go") => return "go", + Some("java") => return "java", + Some("kt") => return "kt", + Some("kts") => return "kts", + Some("c") => return "c", + Some("cc") => return "cc", + Some("cpp") => return "cpp", + Some("h") => return "h", + Some("hpp") => return "hpp", + Some("cs") => return "cs", + Some("swift") => return "swift", + Some("php") => return "php", + Some("rb") => return "rb", + Some("lua") => return "lua", + Some("sh") => return "sh", + Some("bash") => return "bash", + Some("zsh") => return "zsh", + Some("sql") => return "sql", + Some("graphql") => return "graphql", + Some("gql") => return "gql", + Some("vue") => return "vue", + Some("svelte") => return "svelte", + _ => {} } } match media_type.split(';').next().unwrap_or(media_type).trim() { @@ -1455,9 +1510,29 @@ pub(crate) fn infer_file_extension(source: Option<&str>, media_type: &str) -> &' "image/jpeg" => "jpg", "image/webp" => "webp", "image/gif" => "gif", + "image/svg+xml" => "svg", + "image/avif" => "avif", + "image/bmp" => "bmp", + "font/ttf" => "ttf", + "font/otf" => "otf", + "font/woff" => "woff", + "font/woff2" => "woff2", "audio/mpeg" => "mp3", "audio/wav" | "audio/x-wav" => "wav", + "audio/ogg" => "ogg", + "audio/flac" => "flac", + "audio/mp4" => "m4a", + "audio/aac" => "aac", + "audio/opus" => "opus", "video/mp4" => "mp4", + "video/webm" => "webm", + "video/quicktime" => "mov", + "application/json" => "json", + "application/yaml" => "yaml", + "application/xml" => "xml", + "text/html" => "html", + "text/css" => "css", + "text/plain" => "txt", _ => "bin", } } @@ -1722,6 +1797,14 @@ mod tests { use super::*; use std::io::{Read, Write}; + #[test] + fn infer_file_extension_preserves_supported_local_resource_extensions() { + assert_eq!(infer_file_extension(Some("game/Index.HTML"), "text/html"), "html"); + assert_eq!(infer_file_extension(Some("assets/theme.MP3"), "audio/mpeg"), "mp3"); + assert_eq!(infer_file_extension(Some("assets/font.woff2"), "font/woff2"), "woff2"); + assert_eq!(infer_file_extension(None, "application/json"), "json"); + } + #[test] fn platform_session_maps_external_shaped_calls_to_first_party_routes() { assert_eq!( 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 465d07443..bcf763784 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2818,7 +2818,7 @@ mod agent_asset_import_tests { } #[test] - fn local_project_image_import_registers_in_place_and_is_idempotent() { + fn local_project_asset_import_registers_multiple_types_and_is_idempotent() { let project = tempfile::tempdir().expect("create project directory"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") @@ -2827,34 +2827,53 @@ mod agent_asset_import_tests { fs::create_dir_all(root.join("game")).expect("create game directory"); fs::write(root.join("assets/in-place.png"), tiny_png()).expect("write in-place image"); fs::write(root.join("game/copied.png"), tiny_png()).expect("write game image"); + fs::write(root.join("assets/theme.mp3"), b"fake-mp3").expect("write audio"); + fs::write(root.join("assets/intro.mp4"), b"fake-mp4").expect("write video"); + fs::write(root.join("game/index.html"), b"").expect("write code"); + fs::write(root.join("assets/design.md"), b"# design").expect("write document"); let revision_before = read_game_creator_agent_runtime_project_revision(root) .expect("read initial revision") .revision; - let first = import_local_project_image_assets_for_agent( + let first = import_local_project_assets_for_agent( root, &[ "assets/in-place.png".to_string(), "game/copied.png".to_string(), + "assets/theme.mp3".to_string(), + "assets/intro.mp4".to_string(), + "game/index.html".to_string(), + "assets/design.md".to_string(), ], ) - .expect("import local images"); - assert_eq!(first.assets.len(), 2); + .expect("import local assets"); + assert_eq!(first.assets.len(), 6); assert_eq!(first.assets[0].local_path, "assets/in-place.png"); assert!(first.assets[1] .local_path .starts_with("assets/uploads/local-")); + assert!(first.assets[1].local_path.ends_with(".png")); + assert_eq!(first.assets[0].asset_kind.as_deref(), Some("ui")); + assert_eq!(first.assets[2].asset_kind.as_deref(), Some("audio")); + assert_eq!(first.assets[3].asset_kind.as_deref(), Some("video")); + assert_eq!(first.assets[4].asset_kind.as_deref(), Some("code")); + assert_eq!(first.assets[5].asset_kind.as_deref(), Some("document")); + assert!(first.assets[4].local_path.ends_with(".html")); assert!(root.join(&first.assets[1].local_path).is_file()); let revision_after_first = read_game_creator_agent_runtime_project_revision(root) .expect("read imported revision") .revision; - assert_eq!(revision_after_first, revision_before + 2); + assert_eq!(revision_after_first, revision_before + 6); - let second = import_local_project_image_assets_for_agent( + let second = import_local_project_assets_for_agent( root, &[ "assets/in-place.png".to_string(), "game/copied.png".to_string(), + "assets/theme.mp3".to_string(), + "assets/intro.mp4".to_string(), + "game/index.html".to_string(), + "assets/design.md".to_string(), ], ) .expect("reimport local images"); @@ -2868,17 +2887,17 @@ mod agent_asset_import_tests { } #[test] - fn local_project_image_import_rejects_absolute_and_case_insensitive_agent_paths() { + fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() { let project = tempfile::tempdir().expect("create project directory"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); - assert!(import_local_project_image_assets_for_agent( + assert!(import_local_project_assets_for_agent( root, &[root.join("image.png").to_string_lossy().into_owned()] ) .is_err()); - assert!(import_local_project_image_assets_for_agent( + assert!(import_local_project_assets_for_agent( root, &[".AGENT/manifest.json".to_string()] ) @@ -2886,7 +2905,7 @@ mod agent_asset_import_tests { } #[test] - fn local_project_image_import_rejects_hidden_and_build_tree_sources() { + fn local_project_asset_import_rejects_hidden_and_build_tree_sources() { let project = tempfile::tempdir().expect("create project directory"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") @@ -2909,11 +2928,32 @@ mod agent_asset_import_tests { .expect("create forbidden source directory"); fs::write(&source, tiny_png()).expect("write forbidden source image"); assert!( - import_local_project_image_assets_for_agent(root, &[relative.clone()]).is_err(), + import_local_project_assets_for_agent(root, &[relative.clone()]).is_err(), "forbidden source path should be rejected: {relative}" ); } } + + #[test] + fn local_project_asset_import_rejects_unknown_and_invalid_text_files() { + let project = tempfile::tempdir().expect("create project directory"); + let root = project.path(); + init_local_game_project_at(root, "agent-local-import", "Agent local import") + .expect("initialize project"); + fs::create_dir_all(root.join("assets")).expect("create assets directory"); + fs::write(root.join("assets/unknown.bin"), b"bytes").expect("write unknown file"); + fs::write(root.join("assets/broken.js"), [0xff, 0xfe]).expect("write invalid source"); + assert!(import_local_project_assets_for_agent( + root, + &["assets/unknown.bin".to_string()] + ) + .is_err()); + assert!(import_local_project_assets_for_agent( + root, + &["assets/broken.js".to_string()] + ) + .is_err()); + } } fn remote_asset_local_path(asset_id: &str, extension: &str) -> String { @@ -3406,10 +3446,18 @@ pub(crate) async fn list_editor_assets_for_agent_at( "status": "completed", "total": assets.len(), "assets": assets, - "next": "账户素材使用 assetId;网页项目画布资源也使用返回的 resourceId/assetId;本地图片先用 file.list,再把项目相对路径交给 canvas.asset_import;不要提交 objectKey、URL 或本地绝对路径" + "next": "账户素材使用 assetId;网页项目画布资源也使用返回的 resourceId/assetId;本地资源先用 file.list,再把项目相对路径交给 canvas.asset_import;不要提交 objectKey、URL 或本地绝对路径" })) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct AgentLocalProjectFileType { + category: &'static str, + asset_kind: &'static str, + media_type: &'static str, + max_file_size: u64, +} + fn agent_image_media_type(bytes: &[u8]) -> Option<&'static str> { if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Some("image/png") @@ -3422,6 +3470,169 @@ fn agent_image_media_type(bytes: &[u8]) -> Option<&'static str> { } } +fn agent_local_project_file_type( + relative_path: &str, + bytes: &[u8], +) -> Result { + let extension = Path::new(relative_path) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let file_type = match extension.as_str() { + "png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp" => { + let media_type = match extension.as_str() { + "png" => agent_image_media_type(bytes), + "jpg" | "jpeg" => { + bytes.starts_with(&[0xff, 0xd8, 0xff]).then_some("image/jpeg") + } + "webp" => (bytes.len() >= 12 + && &bytes[..4] == b"RIFF" + && &bytes[8..12] == b"WEBP") + .then_some("image/webp"), + "gif" => (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) + .then_some("image/gif"), + "svg" => std::str::from_utf8(bytes) + .ok() + .filter(|text| text.to_ascii_lowercase().contains(" (!bytes.is_empty()).then_some("image/avif"), + "bmp" => bytes.starts_with(b"BM").then_some("image/bmp"), + _ => None, + }; + media_type.map(|media_type| AgentLocalProjectFileType { + category: "image", + asset_kind: "ui", + media_type, + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }) + } + "ttf" | "otf" | "woff" | "woff2" => Some(AgentLocalProjectFileType { + category: "font", + asset_kind: "font", + media_type: match extension.as_str() { + "ttf" => "font/ttf", + "otf" => "font/otf", + "woff" => "font/woff", + _ => "font/woff2", + }, + max_file_size: UI_EDITOR_FONT_MAX_FILE_SIZE, + }), + "mp3" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/mpeg", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "wav" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/wav", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "ogg" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/ogg", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "flac" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/flac", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "m4a" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/mp4", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "aac" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/aac", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "opus" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/opus", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "mp4" => Some(AgentLocalProjectFileType { + category: "video", + asset_kind: "video", + media_type: "video/mp4", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "webm" => Some(AgentLocalProjectFileType { + category: "video", + asset_kind: "video", + media_type: "video/webm", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "mov" => Some(AgentLocalProjectFileType { + category: "video", + asset_kind: "video", + media_type: "video/quicktime", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml" + | "csv" | "ini" | "conf" | "xml" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: if extension == "json" { + "application/json" + } else if matches!(extension.as_str(), "yaml" | "yml") { + "application/yaml" + } else if extension == "xml" { + "application/xml" + } else { + "text/plain" + }, + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "html" | "htm" | "css" | "scss" | "less" | "js" | "mjs" | "cjs" | "ts" + | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" + | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" + | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => { + Some(AgentLocalProjectFileType { + category: "code", + asset_kind: "code", + media_type: if matches!(extension.as_str(), "html" | "htm") { + "text/html" + } else if matches!(extension.as_str(), "css" | "scss" | "less") { + "text/css" + } else { + "text/plain" + }, + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }) + } + _ => None, + }; + let file_type = file_type.ok_or_else(|| format!("本地文件类型不受支持:{relative_path}"))?; + if file_type.category == "font" { + FontAsset::from_verified_bytes( + "font-validation", + "assets/fonts/validation.ttf", + Path::new(relative_path) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("font"), + bytes, + )?; + } else if bytes.is_empty() { + return Err(format!("本地文件不能为空:{relative_path}")); + } else if matches!(file_type.category, "document" | "code") + && std::str::from_utf8(bytes).is_err() + { + return Err(format!("本地文本文件不是有效 UTF-8:{relative_path}")); + } + Ok(file_type) +} + fn local_agent_asset_destination(relative_path: &str, bytes: &[u8], extension: &str) -> String { let digest = format!("{:x}", Sha256::digest(bytes)); let stem = Path::new(relative_path) @@ -3437,30 +3648,30 @@ fn local_agent_asset_destination(relative_path: &str, bytes: &[u8], extension: & ) } -fn reject_agent_local_image_source_path(normalized_path: &str) -> Result<(), String> { +fn reject_agent_local_resource_source_path(normalized_path: &str) -> Result<(), String> { if should_skip_project_snapshot_path(normalized_path) || normalized_path .split('/') .any(|part| part.eq_ignore_ascii_case(".codex")) { - return Err("本地图片导入不得访问隐藏、构建或工具控制目录".to_string()); + return Err("本地资源导入不得访问隐藏、构建或工具控制目录".to_string()); } Ok(()) } -/// 从当前项目根目录内的相对路径导入未登记图片。Agent 不能提交宿主绝对路径, +/// 从当前项目根目录内的相对路径导入未登记资源。Agent 不能提交宿主绝对路径, /// 也不能穿越项目根;路径外文件仍由 UI 原生文件选择器导入。 -pub(crate) fn import_local_project_image_assets_for_agent( +pub(crate) fn import_local_project_assets_for_agent( root: &Path, relative_paths: &[String], ) -> Result { enforce_project_permission_policy(root, "canvas.asset_import")?; validate_project_root(root)?; if relative_paths.is_empty() { - return Err("本地图片导入至少需要一个项目相对路径".to_string()); + return Err("本地资源导入至少需要一个项目相对路径".to_string()); } if relative_paths.len() > UI_EDITOR_IMAGE_MAX_COUNT { - return Err(format!("一次最多导入 {} 张图片", UI_EDITOR_IMAGE_MAX_COUNT)); + return Err(format!("一次最多导入 {} 个本地资源", UI_EDITOR_IMAGE_MAX_COUNT)); } let mut seen = std::collections::BTreeSet::new(); let mut destinations = std::collections::BTreeSet::new(); @@ -3470,31 +3681,30 @@ pub(crate) fn import_local_project_image_assets_for_agent( let normalized = normalize_relative_path(raw_path.trim())?; reject_agent_runtime_private_control_path(&normalized)?; reject_sensitive_project_file_read(&normalized)?; - reject_agent_local_image_source_path(&normalized)?; + reject_agent_local_resource_source_path(&normalized)?; let source = resolve_local_project_path(root, &normalized)?; - prepare_game_creator_private_path_for_read(&source, false, "本地图片")?; + prepare_game_creator_private_path_for_read(&source, false, "本地资源")?; let metadata = - fs::symlink_metadata(&source).map_err(|_| format!("本地图片不存在:{normalized}"))?; + fs::symlink_metadata(&source).map_err(|_| format!("本地资源不存在:{normalized}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(format!("本地素材只能是普通图片文件:{normalized}")); + return Err(format!("本地素材只能是普通文件:{normalized}")); } - if metadata.len() > UI_EDITOR_IMAGE_MAX_FILE_SIZE { + let bytes = fs::read(&source).map_err(|_| format!("读取本地资源失败:{normalized}"))?; + let file_type = agent_local_project_file_type(&normalized, &bytes)?; + if metadata.len() > file_type.max_file_size { return Err(format!( - "本地图片超过单文件 {} 字节限制", - UI_EDITOR_IMAGE_MAX_FILE_SIZE + "本地资源超过单文件 {} 字节限制:{normalized}", + file_type.max_file_size )); } - let bytes = fs::read(&source).map_err(|_| format!("读取本地图片失败:{normalized}"))?; - let media_type = agent_image_media_type(&bytes) - .ok_or_else(|| format!("本地文件不是受支持的 PNG/JPEG/WEBP 图片:{normalized}"))?; total_size = total_size .checked_add(bytes.len() as u64) .filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE) - .ok_or_else(|| "本地图片批次总量超过 256 MiB 限制".to_string())?; + .ok_or_else(|| "本地资源批次总量超过 256 MiB 限制".to_string())?; if !seen.insert(normalized.clone()) { continue; } - let extension = infer_file_extension(Some(&normalized), media_type); + let extension = infer_file_extension(Some(&normalized), file_type.media_type); let local_path = if normalized.starts_with("assets/") { normalized.clone() } else { @@ -3503,13 +3713,18 @@ pub(crate) fn import_local_project_image_assets_for_agent( if !destinations.insert(local_path.clone()) { continue; } - inputs.push((normalized, local_path, media_type.to_string(), bytes)); + inputs.push(( + local_path, + file_type.asset_kind.to_string(), + file_type.media_type.to_string(), + bytes, + )); } let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; let manifest = read_existing_manifest_for_project(root)?; let mut imported = Vec::with_capacity(inputs.len()); - for (source_path, local_path, media_type, bytes) in inputs { + for (local_path, asset_kind, media_type, bytes) in inputs { let target = resolve_local_project_path(root, &local_path)?; if let Some(existing) = manifest .assets @@ -3524,15 +3739,15 @@ pub(crate) fn import_local_project_image_assets_for_agent( continue; } if target.exists() { - prepare_game_creator_private_path_for_read(&target, false, "目标图片")?; - let existing_bytes = fs::read(&target).map_err(|_| "读取目标图片失败".to_string())?; + prepare_game_creator_private_path_for_read(&target, false, "目标资源")?; + let existing_bytes = fs::read(&target).map_err(|_| "读取目标资源失败".to_string())?; if existing_bytes != bytes { - return Err(format!("本地图片目标已存在且内容不同:{local_path}")); + return Err(format!("本地资源目标已存在且内容不同:{local_path}")); } } else { if let Some(parent) = target.parent() { - ensure_game_creator_private_directory_tree(parent, "本地图片导入目录")?; - prepare_game_creator_private_path_for_read(parent, true, "本地图片导入目录")?; + ensure_game_creator_private_directory_tree(parent, "本地资源导入目录")?; + prepare_game_creator_private_path_for_read(parent, true, "本地资源导入目录")?; } let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); @@ -3543,8 +3758,8 @@ pub(crate) fn import_local_project_image_assets_for_agent( } let mut file = options .open(&target) - .map_err(|error| format!("写入本地图片失败:{}: {error}", target.display()))?; - if let Err(error) = harden_new_game_creator_private_path(&target, false, "目标图片") + .map_err(|error| format!("写入本地资源失败:{}: {error}", target.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&target, false, "目标资源") { drop(file); let _ = fs::remove_file(&target); @@ -3552,13 +3767,13 @@ pub(crate) fn import_local_project_image_assets_for_agent( } file.write_all(&bytes) .and_then(|_| file.sync_all()) - .map_err(|error| format!("写入本地图片失败:{}: {error}", target.display()))?; + .map_err(|error| format!("写入本地资源失败:{}: {error}", target.display()))?; drop(file); } let registered = register_local_asset_entry( root, &local_path, - "ui", + &asset_kind, &media_type, "local", GameCreationAppAssetSource { @@ -3577,10 +3792,10 @@ pub(crate) fn import_local_project_image_assets_for_agent( imported.push(ImportedAsset { id: registered.id, local_path: registered.local_path, - asset_kind: Some("ui".to_string()), + asset_kind: Some(asset_kind), }); advance_agent_runtime_project_revision_locked(root).map_err(|error| { - format!("reconciliation-required: 本地图片已登记,但项目 revision 未能推进:{error}") + format!("reconciliation-required: 本地资源已登记,但项目 revision 未能推进:{error}") })?; } Ok(RemoteImportResult { assets: imported }) @@ -4008,7 +4223,7 @@ pub(crate) fn list_local_project_files( list_local_project_files_at(root) } -/// 登记当前项目中已经存在、但尚未写入 manifest 的本地图片。 +/// 登记当前项目中已经存在、但尚未写入 manifest 的本地资源。 /// /// 该入口只接受项目根相对路径;实际文件签名、大小、敏感路径、项目锁和 /// manifest/revision 更新统一复用 Agent 的受控导入实现。未登记文件在调用前 @@ -4020,10 +4235,10 @@ pub(crate) async fn import_local_project_image_assets( ) -> Result { let root = PathBuf::from(project_path.trim()); tokio::task::spawn_blocking(move || { - import_local_project_image_assets_for_agent(&root, &relative_paths) + import_local_project_assets_for_agent(&root, &relative_paths) }) .await - .map_err(|error| format!("项目内图片登记任务意外终止:{error}")) + .map_err(|error| format!("项目内资源登记任务意外终止:{error}")) .and_then(|result| result) } diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8211684b9..caa7f9134 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -10,7 +10,7 @@ - 素材读取区分三类来源:`asset.list` / `agc_list_registered_assets` 是当前项目本地 manifest,`agc_list_project_files` / `file.list` 只发现项目目录中实际存在但可能未登记的文件,`asset.library.list` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。 - Agent 只接收稳定素材 ID、类型、尺寸和项目相对路径等安全投影。客户端负责重新校验账号/项目归属、换签下载、媒体校验,以及 manifest/画布原子登记;不得向 Agent 暴露绝对路径、签名 URL、objectKey、token 或 Cookie。 -- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的 PNG/JPEG/WEBP 与仅可发现的 GIF/SVG/其它媒体,Agent 只能提交前者。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 +- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的已识别图片、字体、音频、视频、文档和代码文件与其它文件;Agent 只能提交前者。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 - Runtime `asset.list` 与 `file.list` 的详情使用文件上下文上限,而不是普通工具短摘要上限,确保有界候选/目录清单不会因前部内容较长而整体丢失;`asset.list` 超出 48 项或 `file.list` 超出 40 项时仍显式返回剩余数量,Agent 再按候选父目录(例如 `assets`、`game/assets`)缩小范围查询。 - 结果仅返回成功/跳过/失败数量、安全 ID、相对路径、来源、脱敏失败摘要和实际 `revisionAdvanceCount`;幂等跳过不得虚增 revision,部分失败仍须准确记录已发生的 revision 变化。 - 普通 Prompt 上下文与错误诊断必须使用分离的脱敏边界:Prompt 继续对疑似凭据行整体隐藏;错误诊断保留 HTTP 状态以及 `code / field / message / reason / detail` 等安全字段,仅替换 Token、Cookie、私钥、配置名、URL 和宿主路径等敏感值。`agc_create_or_derive_resource.assetName` 是必填的人类可读资源显示名称,不接受项目路径、URL、objectKey、Token 或其它凭据。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 9e2ff473d..b536f0875 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -901,7 +901,9 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC; 后台通用表查询已经处理 SpacetimeDB 无载荷枚举的 SATS 形态。新增后台表展示时,枚举列优先按表名和列名做业务映射,再落回通用解码。 -后台通用表查询的“每页条数”不是筛选前的 SQL 截断量。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选;关键词 / JSON 过滤、所选列的完整候选集稳定排序和 1-based `page` 分页都基于这一次 SQL 结果,`totalMatched` 不再依赖另一份 `COUNT(*)` 快照。请求页码超过实际总页数时钳制到末页,零结果固定返回第 1 页。存在第 50,001 条哨兵行时响应必须返回 `scanLimitReached=true`,后台固定分页栏上方明确提示匹配总数和分页结果可能不完整,不得把扫描范围外的数据误报为不存在。候选 SQL 响应体仍受 32 MiB 和 20 秒硬限制;宽表即使每页条数很小也可能整次拒绝,不会返回部分结果。实时写入仍可能改变相邻请求的候选快照,精确审计应使用对应业务表的专用查询而不是通用浏览页。 +后台通用表查询的“每页条数”不是筛选前的 SQL 截断量。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选;关键词 / 字段条件过滤、所选列的完整候选集稳定排序和 1-based `page` 分页都基于这一次 SQL 结果,`totalMatched` 不再依赖另一份 `COUNT(*)` 快照。`filters` 支持两种 JSON 形式:object(列名到等值,如 `{"user_id":"u1"}`,兼容旧入口)与条件数组(如 `[{"column":"points","op":"gt","value":"5"}]`,运算符覆盖 `eq`、`ne`、`gt`、`gte`、`lt`、`lte`、`contains`、`notContains`、`startsWith`、`endsWith`、`in`、`notIn`、`isEmpty`、`isNotEmpty`,允许同列多条件,条件间为 AND);两种形式的用户输入都不进入 SQL,只在 API Server 内存中过滤。请求页码超过实际总页数时钳制到末页,零结果固定返回第 1 页。存在第 50,001 条哨兵行时响应必须返回 `scanLimitReached=true`,后台固定分页栏上方明确提示匹配总数和分页结果可能不完整,不得把扫描范围外的数据误报为不存在。候选 SQL 响应体仍受 32 MiB 和 20 秒硬限制;宽表即使每页条数很小也可能整次拒绝,不会返回部分结果。实时写入仍可能改变相邻请求的候选快照,精确审计应使用对应业务表的专用查询而不是通用浏览页。 + +后台表查询页的结构化筛选条件支持逐条勾选启用或停用;停用条件不会进入请求,但会保留当前字段和值。表单和标题区的查询入口共用同一完整性校验,未完成的启用条件不会静默丢弃。`eq` / `ne` 和比较、文本运算符一样必须提供标量 `value`;显式空值判断使用 `isEmpty` / `isNotEmpty`。`in` / `notIn` 使用逐项值标签编辑,支持粘贴多行值,避免把字符串中的逗号误拆成多个条件。行内交互控件的键盘事件不会冒泡触发行详情,空字段详情复制会给出失败反馈。 ## Issue 与交接 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 6a9a69ffe..9b2f29de7 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -3259,34 +3259,312 @@ fn apply_admin_database_table_filters( } if let Some(filters) = normalized_non_empty(query.filters.as_deref()) { - let parsed = serde_json::from_str::(filters).map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST) - .with_message(format!("筛选 JSON 解析失败:{error}")) - })?; - let object = parsed.as_object().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("筛选条件必须是 JSON object") - })?; - rows.retain(|row| row_matches_admin_database_filters(row, object)); + let conditions = parse_admin_database_filter_conditions(filters)?; + if !conditions.is_empty() { + rows.retain(|row| row_matches_admin_database_filter_conditions(row, &conditions)); + } } Ok(()) } -fn row_matches_admin_database_filters( +// 后台表查询字段筛选运算符,覆盖后台浏览常用的等值、区间、包含与空值判断。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AdminDatabaseFilterOperator { + Eq, + Ne, + Gt, + Gte, + Lt, + Lte, + Contains, + NotContains, + StartsWith, + EndsWith, + In, + NotIn, + IsEmpty, + IsNotEmpty, +} + +impl AdminDatabaseFilterOperator { + fn parse(value: &str) -> Option { + match value { + "eq" => Some(Self::Eq), + "ne" => Some(Self::Ne), + "gt" => Some(Self::Gt), + "gte" => Some(Self::Gte), + "lt" => Some(Self::Lt), + "lte" => Some(Self::Lte), + "contains" => Some(Self::Contains), + "notContains" => Some(Self::NotContains), + "startsWith" => Some(Self::StartsWith), + "endsWith" => Some(Self::EndsWith), + "in" => Some(Self::In), + "notIn" => Some(Self::NotIn), + "isEmpty" => Some(Self::IsEmpty), + "isNotEmpty" => Some(Self::IsNotEmpty), + _ => None, + } + } + + fn requires_list_value(self) -> bool { + matches!(self, Self::In | Self::NotIn) + } + + fn requires_scalar_value(self) -> bool { + matches!( + self, + Self::Eq + | Self::Ne + | Self::Gt + | Self::Gte + | Self::Lt + | Self::Lte + | Self::Contains + | Self::NotContains + | Self::StartsWith + | Self::EndsWith + ) + } +} + +// 单列筛选条件:等值形式保持旧 JSON object 语义,运算符形式来自后台结构化筛选。 +#[derive(Clone, Debug)] +enum AdminDatabaseFilterCondition { + Equals(Value), + Operator { + op: AdminDatabaseFilterOperator, + value: Option, + }, +} + +fn admin_database_filter_bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) +} + +// filters 支持两种形式:JSON object(列名到等值,兼容旧入口)与条件数组(column/op/value 运算符),数组形式允许同列多条件。 +fn parse_admin_database_filter_conditions( + filters: &str, +) -> Result, AppError> { + let parsed = serde_json::from_str::(filters).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(format!("筛选 JSON 解析失败:{error}")) + })?; + match &parsed { + Value::Object(object) => object + .iter() + .map(|(column, expected)| { + Ok(( + column.clone(), + parse_admin_database_filter_condition(expected)?, + )) + }) + .collect(), + Value::Array(items) => items + .iter() + .map(parse_admin_database_filter_array_entry) + .collect(), + _ => Err(admin_database_filter_bad_request( + "筛选条件必须是 JSON object 或条件数组", + )), + } +} + +fn parse_admin_database_filter_condition( + expected: &Value, +) -> Result { + if let Some(object) = expected.as_object() { + if let Some(op) = object.get("op").and_then(Value::as_str) { + let operator = AdminDatabaseFilterOperator::parse(op).ok_or_else(|| { + admin_database_filter_bad_request(format!("不支持的筛选运算符:{op}")) + })?; + let value = object.get("value"); + validate_admin_database_filter_value(operator, value)?; + return Ok(AdminDatabaseFilterCondition::Operator { + op: operator, + value: value.cloned(), + }); + } + } + Ok(AdminDatabaseFilterCondition::Equals(expected.clone())) +} + +fn parse_admin_database_filter_array_entry( + entry: &Value, +) -> Result<(String, AdminDatabaseFilterCondition), AppError> { + let object = entry + .as_object() + .ok_or_else(|| admin_database_filter_bad_request("条件数组中的每一项必须是 JSON object"))?; + let column = object + .get("column") + .and_then(Value::as_str) + .map(str::trim) + .filter(|column| !column.is_empty()) + .ok_or_else(|| admin_database_filter_bad_request("筛选条件缺少 column 字段"))?; + if !object.contains_key("op") { + return Err(admin_database_filter_bad_request( + "条件数组中的每一项必须包含 op 字段", + )); + } + let condition = parse_admin_database_filter_condition(entry)?; + Ok((column.to_string(), condition)) +} + +fn validate_admin_database_filter_value( + op: AdminDatabaseFilterOperator, + value: Option<&Value>, +) -> Result<(), AppError> { + if op.requires_list_value() { + if value.and_then(Value::as_array).is_none() { + return Err(admin_database_filter_bad_request( + "在列表中筛选的 value 必须是数组", + )); + } + return Ok(()); + } + if op.requires_scalar_value() { + let is_scalar = value.is_some_and(|value| { + matches!(value, Value::Bool(_) | Value::Number(_) | Value::String(_)) + }); + if !is_scalar { + return Err(admin_database_filter_bad_request( + "该筛选运算符的 value 必须是字符串或数字", + )); + } + } + Ok(()) +} + +fn row_matches_admin_database_filter_conditions( row: &AdminDatabaseTableRowPayload, - filters: &Map, + conditions: &[(String, AdminDatabaseFilterCondition)], ) -> bool { - let Some(cells) = row.cells.as_object() else { - return filters.is_empty(); - }; - filters.iter().all(|(key, expected)| { - cells - .get(key) - .map(|actual| admin_database_filter_value_matches(actual, expected)) - .unwrap_or(false) + let cells = row.cells.as_object(); + conditions.iter().all(|(column, condition)| { + let actual = cells.and_then(|cells| cells.get(column)); + admin_database_filter_condition_matches(actual, condition) }) } +fn admin_database_filter_condition_matches( + actual: Option<&Value>, + condition: &AdminDatabaseFilterCondition, +) -> bool { + match condition { + AdminDatabaseFilterCondition::Equals(expected) => actual + .map(|actual| admin_database_filter_value_matches(actual, expected)) + .unwrap_or(false), + AdminDatabaseFilterCondition::Operator { op, value } => { + admin_database_filter_operator_matches(actual, *op, value.as_ref()) + } + } +} + +fn admin_database_filter_operator_matches( + actual: Option<&Value>, + op: AdminDatabaseFilterOperator, + value: Option<&Value>, +) -> bool { + match op { + AdminDatabaseFilterOperator::Eq => actual + .map(|actual| { + admin_database_filter_value_matches(actual, value.unwrap_or(&Value::Null)) + }) + .unwrap_or(false), + AdminDatabaseFilterOperator::Ne => actual + .map(|actual| { + !admin_database_filter_value_matches(actual, value.unwrap_or(&Value::Null)) + }) + .unwrap_or(false), + AdminDatabaseFilterOperator::Gt + | AdminDatabaseFilterOperator::Gte + | AdminDatabaseFilterOperator::Lt + | AdminDatabaseFilterOperator::Lte => { + let (Some(actual), Some(expected)) = (actual, value) else { + return false; + }; + match compare_admin_database_filter_values(actual, expected) { + Some(ordering) => match op { + AdminDatabaseFilterOperator::Gt => ordering.is_gt(), + AdminDatabaseFilterOperator::Gte => ordering.is_ge(), + AdminDatabaseFilterOperator::Lt => ordering.is_lt(), + AdminDatabaseFilterOperator::Lte => ordering.is_le(), + _ => unreachable!(), + }, + None => false, + } + } + AdminDatabaseFilterOperator::Contains + | AdminDatabaseFilterOperator::NotContains + | AdminDatabaseFilterOperator::StartsWith + | AdminDatabaseFilterOperator::EndsWith => { + let Some(actual_text) = actual.and_then(value_to_string) else { + return false; + }; + let Some(expected_text) = value.and_then(value_to_string) else { + return false; + }; + let haystack = actual_text.to_lowercase(); + let needle = expected_text.to_lowercase(); + match op { + AdminDatabaseFilterOperator::Contains => haystack.contains(&needle), + AdminDatabaseFilterOperator::NotContains => !haystack.contains(&needle), + AdminDatabaseFilterOperator::StartsWith => haystack.starts_with(&needle), + AdminDatabaseFilterOperator::EndsWith => haystack.ends_with(&needle), + _ => unreachable!(), + } + } + AdminDatabaseFilterOperator::In | AdminDatabaseFilterOperator::NotIn => { + let Some(expected_items) = value.and_then(Value::as_array) else { + return false; + }; + let matched = actual + .map(|actual| { + expected_items + .iter() + .any(|item| admin_database_filter_value_matches(actual, item)) + }) + .unwrap_or(false); + if op == AdminDatabaseFilterOperator::In { + matched + } else { + actual.is_some() && !matched + } + } + AdminDatabaseFilterOperator::IsEmpty => admin_database_value_is_empty(actual), + AdminDatabaseFilterOperator::IsNotEmpty => !admin_database_value_is_empty(actual), + } +} + +// 数值优先、字符串兜底的比较,兼容后台单元格的字符串化值。 +fn compare_admin_database_filter_values(actual: &Value, expected: &Value) -> Option { + let actual_number = actual + .as_f64() + .or_else(|| actual.as_str().and_then(|value| value.trim().parse().ok())); + let expected_number = expected.as_f64().or_else(|| { + expected + .as_str() + .and_then(|value| value.trim().parse().ok()) + }); + if let (Some(actual_number), Some(expected_number)) = (actual_number, expected_number) { + if actual_number.is_finite() && expected_number.is_finite() { + return actual_number.partial_cmp(&expected_number); + } + } + let actual_text = value_to_string(actual)?; + let expected_text = value_to_string(expected)?; + Some(actual_text.cmp(&expected_text)) +} + +// 为空定义为 null、缺失或空字符串;不为空取反。 +fn admin_database_value_is_empty(actual: Option<&Value>) -> bool { + match actual { + None | Some(Value::Null) => true, + Some(Value::String(text)) => text.is_empty(), + _ => false, + } +} + fn admin_database_filter_value_matches(actual: &Value, expected: &Value) -> bool { if actual == expected { return true; @@ -5361,7 +5639,7 @@ mod tests { &mut rows, &AdminDatabaseTableRowsQuery { search: None, - filters: Some("[]".to_string()), + filters: Some("123".to_string()), ..Default::default() }, ) @@ -5370,6 +5648,213 @@ mod tests { assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); } + #[test] + fn apply_admin_database_table_filters_supports_operator_conditions() { + let columns: Vec = ["user_id", "name", "points", "deleted_at"] + .iter() + .map(|column| column.to_string()) + .collect(); + let mut rows = vec![ + build_admin_database_table_row(&json!(["u1", "Alice", 12, null]), &columns), + build_admin_database_table_row(&json!(["u2", "bob", 8, null]), &columns), + build_admin_database_table_row(&json!(["u3", "alan", 8, null]), &columns), + ]; + + apply_admin_database_table_filters( + &mut rows, + &AdminDatabaseTableRowsQuery { + search: None, + filters: Some( + json!([ + {"column": "points", "op": "gt", "value": "5"}, + {"column": "points", "op": "lte", "value": 10}, + {"column": "name", "op": "contains", "value": "AL"}, + {"column": "deleted_at", "op": "isEmpty"}, + ]) + .to_string(), + ), + ..Default::default() + }, + ) + .expect("operator filters should apply"); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].cells["user_id"], json!("u3")); + } + + #[test] + fn apply_admin_database_table_filters_compares_numeric_columns_against_string_inputs() { + let columns = vec!["user_id".to_string(), "points".to_string()]; + let mut rows = vec![ + build_admin_database_table_row(&json!(["u2", 2]), &columns), + build_admin_database_table_row(&json!(["u10", 10]), &columns), + build_admin_database_table_row(&json!(["u12", 12]), &columns), + ]; + + apply_admin_database_table_filters( + &mut rows, + &AdminDatabaseTableRowsQuery { + filters: Some(json!([{"column": "points", "op": "gt", "value": "5"}]).to_string()), + ..Default::default() + }, + ) + .expect("numeric range filters should accept string inputs"); + + let user_ids = rows + .iter() + .filter_map(|row| row.cells.get("user_id").and_then(|value| value.as_str())) + .collect::>(); + assert_eq!(user_ids, vec!["u10", "u12"]); + } + + #[test] + fn apply_admin_database_table_filters_compares_stringified_numeric_columns_numerically() { + let columns = vec!["user_id".to_string(), "points".to_string()]; + let mut rows = vec![ + build_admin_database_table_row(&json!(["u2", "2"]), &columns), + build_admin_database_table_row(&json!(["u10", "10"]), &columns), + build_admin_database_table_row(&json!(["u12", "12"]), &columns), + ]; + + apply_admin_database_table_filters( + &mut rows, + &AdminDatabaseTableRowsQuery { + filters: Some(json!([{"column": "points", "op": "gt", "value": "5"}]).to_string()), + ..Default::default() + }, + ) + .expect("stringified numeric range filters should compare numerically"); + + let user_ids = rows + .iter() + .filter_map(|row| row.cells.get("user_id").and_then(|value| value.as_str())) + .collect::>(); + assert_eq!(user_ids, vec!["u10", "u12"]); + } + + #[test] + fn apply_admin_database_table_filters_supports_list_and_negation_operators() { + let columns: Vec = ["user_id", "name", "points", "deleted_at"] + .iter() + .map(|column| column.to_string()) + .collect(); + let mut rows = vec![ + build_admin_database_table_row(&json!(["u1", "alice", 12, "2026-05-01"]), &columns), + build_admin_database_table_row(&json!(["u2", "bob", 8, null]), &columns), + build_admin_database_table_row(&json!(["u3", "alan", 5, "2026-05-02"]), &columns), + ]; + + apply_admin_database_table_filters( + &mut rows, + &AdminDatabaseTableRowsQuery { + search: None, + filters: Some( + json!([ + {"column": "user_id", "op": "notIn", "value": ["u1", "u9"]}, + {"column": "name", "op": "startsWith", "value": "a"}, + {"column": "name", "op": "notContains", "value": "ob"}, + {"column": "points", "op": "ne", "value": "8"}, + {"column": "deleted_at", "op": "isNotEmpty"}, + ]) + .to_string(), + ), + ..Default::default() + }, + ) + .expect("list and negation filters should apply"); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].cells["user_id"], json!("u3")); + } + + #[test] + fn apply_admin_database_table_filters_supports_legacy_object_with_operators() { + let columns: Vec = ["user_id", "name", "points"] + .iter() + .map(|column| column.to_string()) + .collect(); + let mut rows = vec![ + build_admin_database_table_row(&json!(["u1", "alice", 12]), &columns), + build_admin_database_table_row(&json!(["u2", "bob", 8]), &columns), + ]; + + apply_admin_database_table_filters( + &mut rows, + &AdminDatabaseTableRowsQuery { + search: None, + filters: Some( + r#"{"name":{"op":"startsWith","value":"a"},"points":12}"#.to_string(), + ), + ..Default::default() + }, + ) + .expect("mixed legacy and operator filters should apply"); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].cells["user_id"], json!("u1")); + + apply_admin_database_table_filters( + &mut rows, + &AdminDatabaseTableRowsQuery { + search: None, + filters: Some("[]".to_string()), + ..Default::default() + }, + ) + .expect("empty condition array should be a no-op"); + + assert_eq!(rows.len(), 1); + } + + #[test] + fn apply_admin_database_table_filters_rejects_invalid_operator_forms() { + let invalid_filters = [ + json!([{"column": "points", "op": "eq"}]).to_string(), + json!([{"column": "points", "op": "ne", "value": null}]).to_string(), + json!([{"column": "points", "op": "between", "value": "1"}]).to_string(), + json!([{"column": "points", "op": "in", "value": "1"}]).to_string(), + json!([{"column": "points", "op": "contains"}]).to_string(), + json!([{"op": "eq", "value": "1"}]).to_string(), + json!([{"column": "points", "value": "1"}]).to_string(), + json!(["points"]).to_string(), + ]; + for filters in invalid_filters { + let mut rows = vec![build_admin_database_table_row( + &json!(["u1"]), + &["user_id".to_string()], + )]; + let error = apply_admin_database_table_filters( + &mut rows, + &AdminDatabaseTableRowsQuery { + search: None, + filters: Some(filters), + ..Default::default() + }, + ) + .expect_err("invalid operator filter should fail"); + assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); + } + } + + #[test] + fn apply_admin_database_table_filters_rejects_eq_without_required_value() { + let mut rows = vec![build_admin_database_table_row( + &json!(["u1"]), + &["user_id".to_string()], + )]; + + let error = apply_admin_database_table_filters( + &mut rows, + &AdminDatabaseTableRowsQuery { + filters: Some(json!([{"column": "user_id", "op": "eq"}]).to_string()), + ..Default::default() + }, + ) + .expect_err("eq without value should fail as a malformed request"); + + assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); + } + #[test] fn build_admin_tracking_events_sql_quotes_filters_and_clamps_limit() { let sql = build_admin_tracking_events_sql(&AdminTrackingEventListQuery { diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index ab8c690c9..52f0e9733 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -682,6 +682,10 @@ pub struct AdminDatabaseTableListResponse { } // 后台通用表查询参数,用户输入不进入 SQL,只在 API Server 内存中过滤。 +// filters 支持两种 JSON 形式:object(列名到等值,如 {"user_id":"u1"},兼容旧入口) +// 与条件数组(如 [{"column":"points","op":"gt","value":"5"}])。条件数组运算符包含 +// eq、ne、gt、gte、lt、lte、contains、notContains、startsWith、endsWith、in、notIn、 +// isEmpty、isNotEmpty;允许同列多条件,全部条件之间为 AND。 #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AdminDatabaseTableRowsQuery { From ae79d50b14af835b55d7244c1d3c3c9925dc34a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 14:35:45 +0800 Subject: [PATCH 8/8] =?UTF-8?q?Fix/=E4=BF=AE=E5=A4=8Dui-editor=E8=B0=83?= =?UTF-8?q?=E7=94=A8llm=E4=B8=8E=E5=AE=9E=E9=99=85=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=20(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用统一的LLM调用, 原来的方法内部会默认不stream传输, 一些上游要求流式传输会出现问题 --------- Co-authored-by: 段舒康 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/235 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../src-tauri/src/agent.rs | 1 + .../src/ui_editor/commands/binding.rs | 37 +++++++++++----- .../src-tauri/src/ui_editor/commands/merge.rs | 39 +++++++++++------ .../src/ui_editor/commands/recognition.rs | 38 ++++++++++------ .../commands/ui_design_suggestion.rs | 43 +++++++++++-------- .../src-tauri/src/ui_editor/commands/utils.rs | 18 ++++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 7 files changed, 124 insertions(+), 54 deletions(-) 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 bc2bedb87..bb0f70aee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -73,6 +73,7 @@ pub(crate) async fn request_game_creator_ui_editor_llm_at( .with_api_kind(api_kind) .with_model(config.llm.model.clone()) .with_request_timeout_ms(config.llm.request_timeout_ms); + let request = apply_game_creator_llm_reasoning_effort(request, &config.llm)?; let snapshot = { let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs index 29ba2ab80..8ede8b652 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -1,6 +1,8 @@ -use crate::config::build_game_creator_llm_client_from_config; +use crate::config::build_game_creator_llm_client_from_llm_config; +use crate::config::load_game_creator_app_config; use crate::ui_editor::commands::utils::{ - parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, strict_json_schema, + parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm, + strict_json_schema, }; use crate::ui_editor::component::text::FontSource; use crate::ui_editor::component::Component; @@ -343,10 +345,21 @@ pub(crate) async fn bind_components_impl_with_provider( }); parts.push(LlmMessageContentPart::InputImage { image_url }); } - let client = if provider_identity.is_none() { - Some(build_game_creator_llm_client_from_config()?) + let (llm, client) = if provider_identity.is_none() { + let llm = load_game_creator_app_config() + .map_err(|error| { + eprintln!("ui_binding.error stage=build_client error={error}"); + error + })? + .llm; + let client = + build_game_creator_llm_client_from_llm_config(&llm, "llm").map_err(|error| { + eprintln!("ui_binding.error stage=build_client error={error}"); + error + })?; + (Some(llm), Some(client)) } else { - None + (None, None) }; let tool = LlmFunctionTool::new( "bind_ui_components", @@ -371,11 +384,15 @@ pub(crate) async fn bind_components_impl_with_provider( .await .map_err(platform_llm::LlmError::InvalidRequest) } else { - client - .as_ref() - .expect("provider client exists without runtime identity") - .run(request) - .await + request_ui_editor_llm( + client + .as_ref() + .expect("provider client exists without runtime identity"), + llm.as_ref() + .expect("LLM config exists without runtime identity"), + request, + ) + .await } .map_err(|error| format!("组件绑定失败:{error}"))?; let call = response diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs index aeaadfd1e..4b9852063 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs @@ -1,5 +1,8 @@ -use crate::config::build_game_creator_llm_client_from_config; -use crate::ui_editor::commands::utils::{parse_limited_llm_tool_arguments, strict_json_schema}; +use crate::config::build_game_creator_llm_client_from_llm_config; +use crate::config::load_game_creator_app_config; +use crate::ui_editor::commands::utils::{ + parse_limited_llm_tool_arguments, request_ui_editor_llm, strict_json_schema, +}; use crate::ui_editor::state::{State, UITree}; use platform_llm::{LlmFunctionTool, LlmMessage, LlmRunRequest, LlmToolChoice}; use serde::{Deserialize, Serialize}; @@ -426,15 +429,21 @@ pub(crate) async fn merge_ui_impl_with_provider( ); return Err(format!("UI 合并输入超过 {MAX_MERGE_INPUT_BYTES} 字节上限")); } - let client = if provider_identity.is_none() { - Some( - build_game_creator_llm_client_from_config().map_err(|error| { + let (llm, client) = if provider_identity.is_none() { + let llm = load_game_creator_app_config() + .map_err(|error| { eprintln!("ui_merge.error stage=build_client error={error}"); error - })?, - ) + })? + .llm; + let client = + build_game_creator_llm_client_from_llm_config(&llm, "llm").map_err(|error| { + eprintln!("ui_merge.error stage=build_client error={error}"); + error + })?; + (Some(llm), Some(client)) } else { - None + (None, None) }; let schema = llm_contract::schema().map_err(|error| { eprintln!("ui_merge.error stage=build_schema error={error}"); @@ -463,11 +472,15 @@ pub(crate) async fn merge_ui_impl_with_provider( .await .map_err(platform_llm::LlmError::InvalidRequest) } else { - client - .as_ref() - .expect("provider client exists without runtime identity") - .run(request) - .await + request_ui_editor_llm( + client + .as_ref() + .expect("provider client exists without runtime identity"), + llm.as_ref() + .expect("LLM config exists without runtime identity"), + request, + ) + .await } .map_err(|error| { eprintln!("ui_merge.error stage=llm_request error={error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs index ce6b8bdab..408b95e4e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -1,6 +1,8 @@ -use crate::config::build_game_creator_llm_client_from_config; +use crate::config::build_game_creator_llm_client_from_llm_config; +use crate::config::load_game_creator_app_config; use crate::ui_editor::commands::utils::{ - parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, strict_json_schema, + parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm, + strict_json_schema, }; use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::control_layout::ControlLayout; @@ -608,15 +610,21 @@ pub(crate) async fn recognize_ui_impl_with_provider( eprintln!("ui_recognition.error stage=validate reason=no_root_image"); return Err("至少需要一张可作为识别上下文根的界面图".to_string()); } - let client = if provider_identity.is_none() { - Some( - build_game_creator_llm_client_from_config().map_err(|error| { + let (llm, client) = if provider_identity.is_none() { + let llm = load_game_creator_app_config() + .map_err(|error| { eprintln!("ui_recognition.error stage=build_client error={error}"); error - })?, - ) + })? + .llm; + let client = + build_game_creator_llm_client_from_llm_config(&llm, "llm").map_err(|error| { + eprintln!("ui_recognition.error stage=build_client error={error}"); + error + })?; + (Some(llm), Some(client)) } else { - None + (None, None) }; let schema = recognition_json_schema().map_err(|error| { eprintln!("ui_recognition.error stage=build_schema error={error}"); @@ -687,11 +695,15 @@ pub(crate) async fn recognize_ui_impl_with_provider( .await .map_err(platform_llm::LlmError::InvalidRequest) } else { - client - .as_ref() - .expect("provider client exists without runtime identity") - .run(request) - .await + request_ui_editor_llm( + client + .as_ref() + .expect("provider client exists without runtime identity"), + llm.as_ref() + .expect("LLM config exists without runtime identity"), + request, + ) + .await } .map_err(|error| { eprintln!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs index 296835e62..2bb51d67b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs @@ -1,6 +1,8 @@ -use crate::config::build_game_creator_llm_client_from_config; +use crate::config::build_game_creator_llm_client_from_llm_config; +use crate::config::load_game_creator_app_config; use crate::ui_editor::commands::utils::{ - parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, strict_json_schema, + parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm, + strict_json_schema, }; use crate::ui_editor::resource::ui_design_image::UIDesignImageRole; use crate::ui_editor::state::State; @@ -172,7 +174,13 @@ pub(crate) async fn suggest_ui_design_semantic_impl( }); parts.push(LlmMessageContentPart::InputImage { image_url }); } - let client = build_game_creator_llm_client_from_config().map_err(|error| { + let llm = load_game_creator_app_config() + .map_err(|error| { + eprintln!("ui_design_suggestion.error stage=build_client error={error}"); + error + })? + .llm; + let client = build_game_creator_llm_client_from_llm_config(&llm, "llm").map_err(|error| { eprintln!("ui_design_suggestion.error stage=build_client error={error}"); error })?; @@ -186,20 +194,21 @@ pub(crate) async fn suggest_ui_design_semantic_impl( schema, ) .with_strict(true); - let response = client - .run( - LlmRunRequest::new(vec![ - LlmMessage::system(SYSTEM_PROMPT), - LlmMessage::user_multimodal(parts), - ]) - .with_function_tools(vec![tool]) - .with_tool_choice(LlmToolChoice::Required), - ) - .await - .map_err(|error| { - eprintln!("ui_design_suggestion.error stage=llm_request error={error}"); - format!("UI 参考图语义识别失败:{error}") - })?; + let response = request_ui_editor_llm( + &client, + &llm, + LlmRunRequest::new(vec![ + LlmMessage::system(SYSTEM_PROMPT), + LlmMessage::user_multimodal(parts), + ]) + .with_function_tools(vec![tool]) + .with_tool_choice(LlmToolChoice::Required), + ) + .await + .map_err(|error| { + eprintln!("ui_design_suggestion.error stage=llm_request error={error}"); + format!("UI 参考图语义识别失败:{error}") + })?; eprintln!( "ui_design_suggestion.llm_output text_present={} tool_call_count={}", !response.text.trim().is_empty(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs index 5468f60a6..4faa40579 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs @@ -1,4 +1,7 @@ +use crate::agent::request_game_creator_llm_text; +use crate::config::{apply_game_creator_llm_reasoning_effort, parse_game_creator_llm_api_kind}; use base64::Engine as _; +use platform_llm::{LlmClient, LlmError, LlmRunRequest, LlmRunResponse}; use schemars::JsonSchema; use std::fs::File; use std::io::Read; @@ -7,6 +10,21 @@ use std::path::{Path, PathBuf}; pub(crate) const LLM_TOOL_ARGUMENT_MAX_BYTES: usize = 1024 * 1024; pub(crate) const UI_REFERENCE_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024; +/// Prepare a UI Editor request and reuse the caller-owned client. The transport +/// selection remains centralized in `request_game_creator_llm_text`. +pub(crate) async fn request_ui_editor_llm( + client: &LlmClient, + llm: &crate::GameCreatorLlmConfig, + request: LlmRunRequest, +) -> Result { + let request = request.with_api_kind( + parse_game_creator_llm_api_kind(&llm.api_kind).map_err(LlmError::InvalidConfig)?, + ); + let request = + apply_game_creator_llm_reasoning_effort(request, llm).map_err(LlmError::InvalidConfig)?; + request_game_creator_llm_text(client, llm, request).await +} + pub(crate) fn parse_limited_llm_tool_arguments( arguments: &str, ) -> Result { diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index caa7f9134..99637cb5f 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1255,7 +1255,7 @@ game-project/ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`。多 Agent、Apps、插件、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`,明确清空外部 MCP 后只注入 `agc_tools`;配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。 ## 2026-08-24 AGC UI 原型桥接与自主 UI workflow -- 2026-08-24 起,`ui-prototype` 与 UI 编辑器的 `UI` JSON 资源明确分离。设计图生成后必须由白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`:为每个功能页面创建并关联 `UI` JSON,载入页面设计图和已登记图片/图标/字体,调用 UI Editor 的 provider-backed 结构识别、多树合并与分批组件绑定,持久化 State/revision,写入 `game/` 应用标记,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 各阶段的 `generationKind` 和 manifest revision 投影给客户端。Provider 未配置、请求失败、工具调用缺失、结果不匹配、未知字体引用、未产出可渲染组件或仍有待审节点时保留最近真实阶段并返回 blocker,不得使用 deterministic seed 冒充完成。工作台点击 `ui-prototype` 时通过 `ensure_ui_design_resource_for_prototype` 幂等补齐关联资源;工作流完成后自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段,交给用户检查和手动调整。只生成图片、登记空 JSON 或进入普通图片画布均不构成 UI 工作流完成,详见 [`【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。 +- 2026-08-24 起,`ui-prototype` 与 UI 编辑器的 `UI` JSON 资源明确分离。设计图生成后必须由白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`:为每个功能页面创建并关联 `UI` JSON,载入页面设计图和已登记图片/图标/字体,调用 UI Editor 的 provider-backed 结构识别、多树合并与分批组件绑定,持久化 State/revision,写入 `game/` 应用标记,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 各阶段的 `generationKind` 和 manifest revision 投影给客户端。Provider 未配置、请求失败、工具调用缺失、结果不匹配、未知字体引用、未产出可渲染组件或仍有待审节点时保留最近真实阶段并返回 blocker,不得使用 deterministic seed 冒充完成。工作台点击 `ui-prototype` 时通过 `ensure_ui_design_resource_for_prototype` 幂等补齐关联资源;工作流完成后自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段,交给用户检查和手动调整。UI 编辑器独立的语义建议请求也必须复用统一 LLM 传输选择,`llm.stream=true` 时发送 `stream=true` 并聚合完整工具调用后再校验结果。只生成图片、登记空 JSON 或进入普通图片画布均不构成 UI 工作流完成,详见 [`【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。 ## 2026-08-28 AGC 自主构建 relaxed 编排覆盖