Merge branch 'master' into feat/agc_add_on
Project CI / Frontend tests (pull_request) Successful in 4m35s
Project CI / Repository checks (pull_request) Successful in 5m5s
Project CI / Backend tests (pull_request) Successful in 7m44s
Project CI / Native shell tests (pull_request) Successful in 16m42s

This commit is contained in:
2026-09-01 15:20:52 +08:00
29 changed files with 3036 additions and 302 deletions
@@ -2,7 +2,7 @@
## 需求落点
- 后台“总览”页的表统计仍保留,只把每张表的表名改成可点击跳转到 `#tables?table=<name>`
- 新增独立 `#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` 通过。
- `npm run check:encoding` 通过。
@@ -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<AdminDatabaseTableRowsResponse>(
(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(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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() ?? '',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -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) {
@@ -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.
@@ -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`.
@@ -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.
@@ -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 |
@@ -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"
}
]
}
@@ -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,
@@ -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 浏览器证据。客户端会把结构化证据回灌同一会话。");
@@ -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::<BTreeMap<_, _>>();
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]
@@ -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"
}))
@@ -159,10 +159,10 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
output.push_str(
"# 项目内未登记媒体文件(仅发现,不是正式资产)\n\n\
- 这些文件真实存在于当前项目,但尚未取得 manifest assetId/localAssetId、来源或 provenance。\n\
- 只有 `assetImportable=true` 的 PNG/JPEG/WEBP 可交给当前图片导入工具;其它媒体只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
- 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档或代码文件可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
);
for (path, size, media_type) in unregistered.iter().take(48) {
let asset_importable = matches!(*media_type, "image/png" | "image/jpeg" | "image/webp");
let asset_importable = !media_type.is_empty();
output.push_str(&format!(
"- {path} / {media_type} / {size} bytes / registered=false / assetImportable={asset_importable}\n"
));
@@ -200,6 +200,8 @@ fn prompt_context_media_type(path: &str) -> 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>,
@@ -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 替换为合法 IDtask.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:9imageSize 只允许 0.5K|1K|2KassetKind 只允许 {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 必须为 nullagent.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 交付最终回复。"
),
@@ -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,
@@ -1356,7 +1356,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"读取当前登录账户的云端/网页素材库静态图片,以及当前项目已绑定网页画布 project.resources 图片安全投影;不返回 URL、objectKey、签名地址或凭据。"
}
"canvas.asset_import" => {
"把账户素材库/绑定网页项目画布中的 assetIdresourceId)或项目内 localPaths 图片导入当前项目并登记 manifest;两类输入可混合。账户与画布素材先用 asset.library.list 查询,本地图片先用 file.list 发现;客户端内部负责归属校验、换签下载、格式/大小校验、项目锁和 revision。"
"把账户素材库/绑定网页项目画布中的 assetIdresourceId)或项目内 localPaths 资源导入当前项目并登记 manifest;两类输入可混合。账户与画布素材先用 asset.library.list 查询,本地资源先用 file.list 发现;客户端内部负责归属校验、换签下载、格式/大小校验、项目锁和 revision。"
}
"project.index" => "刷新并读取有界仓库启动上下文。",
"project.search" => "在项目文本文件中做有界字面量搜索。",
@@ -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!(
@@ -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"<html></html>").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<AgentLocalProjectFileType, String> {
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("<svg"))
.map(|_| "image/svg+xml"),
"avif" => (!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<RemoteImportResult, String> {
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<LocalImportResult, String> {
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)
}
@@ -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
@@ -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}");
@@ -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!(
@@ -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(),
@@ -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<LlmRunResponse, LlmError> {
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<serde_json::Value, String> {

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