Close213/215 本地资源注册与后台表查询 (#232)
Project CI / Repository checks (push) Successful in 4m8s
Project CI / Frontend tests (push) Successful in 4m52s
Project CI / Backend tests (push) Successful in 6m35s
Project CI / Native shell tests (push) Successful in 16m33s

## 关联 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: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
This commit was merged in pull request #232.
This commit is contained in:
2026-09-01 14:27:39 +08:00
committed by 段舒康
parent 933da91fa3
commit f0142f7019
23 changed files with 2912 additions and 248 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"
}
]
}
@@ -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)
}
@@ -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 或其它凭据。
@@ -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 与交接
File diff suppressed because it is too large Load Diff
@@ -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 {