Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a0704e4da | |||
| 9d060de185 | |||
| 8d83b14ae5 | |||
| f983663caf | |||
| 9f225b7a41 | |||
| 5087b600a2 | |||
| 8fd85d0426 | |||
| b840973d82 | |||
| ca82e6b745 | |||
| 3e657e6a6a |
@@ -174,14 +174,6 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
|||||||
|
|
||||||
## 项目开发对话(DirectProject)
|
## 项目开发对话(DirectProject)
|
||||||
|
|
||||||
**DirectProject 专属聊天模块**:
|
|
||||||
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
|
||||||
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
|
|
||||||
|
|
||||||
**项目工作台布局**:
|
|
||||||
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
|
|
||||||
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
|
|
||||||
|
|
||||||
**项目对话历史**:
|
**项目对话历史**:
|
||||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getAdminFeatureGateConfig,
|
getAdminFeatureGateConfig,
|
||||||
getAdminUserDetail,
|
getAdminUserDetail,
|
||||||
importAdminAgcTemplates,
|
importAdminAgcTemplates,
|
||||||
|
listAdminAgcTrackingEvents,
|
||||||
listAdminRechargeOrders,
|
listAdminRechargeOrders,
|
||||||
reconcileAdminUserConsumption,
|
reconcileAdminUserConsumption,
|
||||||
resolveAdminRechargeRefundManualReview,
|
resolveAdminRechargeRefundManualReview,
|
||||||
@@ -21,6 +22,30 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
|
||||||
|
const payload = { entries: [], nextCursor: null };
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ ok: true, data: payload }), {
|
||||||
|
status: 200,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
expect(
|
||||||
|
await listAdminAgcTrackingEvents('token', {
|
||||||
|
userId: 'user+1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
cursor: 'page/2',
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
).toEqual(payload);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/admin/api/agc/tracking-events?userId=user%2B1&projectId=project-1&cursor=page%2F2&limit=50',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({ Authorization: 'Bearer token' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
||||||
const library = { revision: 'revision-new', writable: true, templates: [] };
|
const library = { revision: 'revision-new', writable: true, templates: [] };
|
||||||
const fetchMock = vi.fn().mockImplementation(
|
const fetchMock = vi.fn().mockImplementation(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminAccountListResponse,
|
AdminAccountListResponse,
|
||||||
AdminAgcTemplateLibraryResponse,
|
AdminAgcTemplateLibraryResponse,
|
||||||
|
AdminAgcTrackingEventListResponse,
|
||||||
|
AdminAgcTrackingEventQuery,
|
||||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||||
AdminCreateAccountRequest,
|
AdminCreateAccountRequest,
|
||||||
AdminCreateAccountResponse,
|
AdminCreateAccountResponse,
|
||||||
@@ -406,6 +408,31 @@ export function listAdminTrackingEventKeys(token: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listAdminAgcTrackingEvents(
|
||||||
|
token: string,
|
||||||
|
query: AdminAgcTrackingEventQuery = {},
|
||||||
|
) {
|
||||||
|
return request<AdminAgcTrackingEventListResponse>(
|
||||||
|
`/admin/api/agc/tracking-events${buildQueryString((params) => {
|
||||||
|
for (const key of [
|
||||||
|
'userId',
|
||||||
|
'projectId',
|
||||||
|
'creativeTaskId',
|
||||||
|
'agentRunId',
|
||||||
|
'eventName',
|
||||||
|
'clientVersion',
|
||||||
|
'startTime',
|
||||||
|
'endTime',
|
||||||
|
'cursor',
|
||||||
|
] as const) {
|
||||||
|
appendQueryParam(params, key, query[key]);
|
||||||
|
}
|
||||||
|
appendNumericQueryParam(params, 'limit', query.limit);
|
||||||
|
})}`,
|
||||||
|
{ token },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function listAdminErrorReports(
|
export function listAdminErrorReports(
|
||||||
token: string,
|
token: string,
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -820,6 +820,44 @@ export interface AdminTrackingEventListResponse {
|
|||||||
entries: AdminTrackingEventEntryPayload[];
|
entries: AdminTrackingEventEntryPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminAgcTrackingEventQuery {
|
||||||
|
userId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
creativeTaskId?: string;
|
||||||
|
agentRunId?: string;
|
||||||
|
eventName?: string;
|
||||||
|
clientVersion?: string;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAgcTrackingEventEntry {
|
||||||
|
eventId: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
eventName: string;
|
||||||
|
eventTime: string;
|
||||||
|
userId: string;
|
||||||
|
editorSessionId: string;
|
||||||
|
projectId: string | null;
|
||||||
|
creativeTaskId: string | null;
|
||||||
|
agentRunId: string | null;
|
||||||
|
agentTurnId: string | null;
|
||||||
|
status: string | null;
|
||||||
|
errorCode: string | null;
|
||||||
|
source: string;
|
||||||
|
clientVersion: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
batchId: string;
|
||||||
|
receivedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAgcTrackingEventListResponse {
|
||||||
|
entries: AdminAgcTrackingEventEntry[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminTrackingEventKeyPayload {
|
export interface AdminTrackingEventKeyPayload {
|
||||||
eventKey: string;
|
eventKey: string;
|
||||||
eventTitle: string;
|
eventTitle: string;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||||
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
||||||
|
import { AdminAgcTrackingPage } from '../pages/AdminAgcTrackingPage';
|
||||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||||
@@ -232,6 +233,12 @@ export function AdminApp() {
|
|||||||
onUnauthorized={handleUnauthorized}
|
onUnauthorized={handleUnauthorized}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeRouteId === 'agc-tracking' ? (
|
||||||
|
<AdminAgcTrackingPage
|
||||||
|
token={token}
|
||||||
|
onUnauthorized={handleUnauthorized}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{activeRouteId === 'error-reports' ? (
|
{activeRouteId === 'error-reports' ? (
|
||||||
<AdminErrorReportsPage
|
<AdminErrorReportsPage
|
||||||
token={token}
|
token={token}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const routeIcons = {
|
|||||||
tables: Database,
|
tables: Database,
|
||||||
debug: Bug,
|
debug: Bug,
|
||||||
tracking: Table2,
|
tracking: Table2,
|
||||||
|
'agc-tracking': Table2,
|
||||||
'error-reports': Bug,
|
'error-reports': Bug,
|
||||||
'gray-release': GitBranch,
|
'gray-release': GitBranch,
|
||||||
redeem: TicketPercent,
|
redeem: TicketPercent,
|
||||||
|
|||||||
@@ -8,6 +8,22 @@ import {
|
|||||||
routeHash,
|
routeHash,
|
||||||
} from './adminRoutes';
|
} from './adminRoutes';
|
||||||
|
|
||||||
|
test('客户端埋点路由遵守成员页签权限', () => {
|
||||||
|
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
|
||||||
|
expect(
|
||||||
|
getAccessibleAdminRoutes({
|
||||||
|
accountRole: 'member',
|
||||||
|
tabPermissions: ['agc-tracking'],
|
||||||
|
}).map((route) => route.id),
|
||||||
|
).toEqual(['agc-tracking']);
|
||||||
|
expect(
|
||||||
|
getAccessibleAdminRoutes({
|
||||||
|
accountRole: 'member',
|
||||||
|
tabPermissions: ['tracking'],
|
||||||
|
}).some((route) => route.id === 'agc-tracking'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('后台默认进入 Dashboard', () => {
|
test('后台默认进入 Dashboard', () => {
|
||||||
expect(adminRoutes[0]).toEqual({
|
expect(adminRoutes[0]).toEqual({
|
||||||
id: 'dashboard',
|
id: 'dashboard',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type AdminRouteId =
|
|||||||
| 'tables'
|
| 'tables'
|
||||||
| 'debug'
|
| 'debug'
|
||||||
| 'tracking'
|
| 'tracking'
|
||||||
|
| 'agc-tracking'
|
||||||
| 'error-reports'
|
| 'error-reports'
|
||||||
| 'gray-release'
|
| 'gray-release'
|
||||||
| 'redeem'
|
| 'redeem'
|
||||||
@@ -40,6 +41,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
|||||||
{ id: 'tables', label: '表查询', hash: '#tables' },
|
{ id: 'tables', label: '表查询', hash: '#tables' },
|
||||||
{ id: 'debug', label: 'API 调试', hash: '#debug' },
|
{ id: 'debug', label: 'API 调试', hash: '#debug' },
|
||||||
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
|
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
|
||||||
|
{ id: 'agc-tracking', label: '客户端埋点', hash: '#agc-tracking' },
|
||||||
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
|
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
|
||||||
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
|
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
|
||||||
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
|
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdminApiError,
|
||||||
|
listAdminAgcTrackingEvents,
|
||||||
|
} from '../api/adminApiClient';
|
||||||
|
import type { AdminAgcTrackingEventEntry } from '../api/adminApiTypes';
|
||||||
|
import { AdminAgcTrackingPage } from './AdminAgcTrackingPage';
|
||||||
|
|
||||||
|
vi.mock('../api/adminApiClient', async (original) => ({
|
||||||
|
...(await original<typeof import('../api/adminApiClient')>()),
|
||||||
|
listAdminAgcTrackingEvents: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../components/AdminUserReferenceButton', () => ({
|
||||||
|
AdminUserReferenceButton: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const entry: AdminAgcTrackingEventEntry = {
|
||||||
|
eventId: 'event-1',
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventName: 'project_save',
|
||||||
|
eventTime: '2026-09-21T12:00:00.123Z',
|
||||||
|
userId: 'user-1',
|
||||||
|
editorSessionId: 'session-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
creativeTaskId: 'project-1',
|
||||||
|
agentRunId: null,
|
||||||
|
agentTurnId: null,
|
||||||
|
status: 'success',
|
||||||
|
errorCode: null,
|
||||||
|
source: 'gui',
|
||||||
|
clientVersion: '1.0',
|
||||||
|
properties: { reason: 'manual' },
|
||||||
|
batchId: 'batch-1',
|
||||||
|
receivedAt: '2026-09-21T12:15:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(listAdminAgcTrackingEvents).mockReset();
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
test('翻页沿用游标,筛选和刷新从第一页重新查询,详情保留 null 和事件属性', async () => {
|
||||||
|
vi.mocked(listAdminAgcTrackingEvents)
|
||||||
|
.mockResolvedValueOnce({ entries: [entry], nextCursor: 'cursor-page-2' })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
entries: [{ ...entry, eventId: 'event-2' }],
|
||||||
|
nextCursor: null,
|
||||||
|
})
|
||||||
|
.mockResolvedValue({ entries: [entry], nextCursor: 'cursor-new' });
|
||||||
|
render(<AdminAgcTrackingPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||||
|
await screen.findByText('保存项目', { selector: 'td' });
|
||||||
|
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
|
||||||
|
cursor: undefined,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('查看详情'));
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
expect(within(dialog).getAllByText('—').length).toBe(3);
|
||||||
|
expect(within(dialog).getByText(/"reason": "manual"/)).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText(entry.eventTime)).toBeTruthy();
|
||||||
|
fireEvent.click(within(dialog).getByLabelText('关闭详情'));
|
||||||
|
fireEvent.click(screen.getByText('下一页'));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
|
||||||
|
cursor: 'cursor-page-2',
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('查询').closest('button')?.disabled).toBe(false),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('上一页'));
|
||||||
|
await screen.findByText('第 1 页');
|
||||||
|
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(2);
|
||||||
|
fireEvent.change(screen.getByLabelText('项目 ID'), {
|
||||||
|
target: { value: 'project-2' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('查询'));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith(
|
||||||
|
'admin-token',
|
||||||
|
expect.objectContaining({
|
||||||
|
projectId: 'project-2',
|
||||||
|
cursor: undefined,
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.getByText('第 1 页')).toBeTruthy();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('刷新').closest('button')?.disabled).toBe(false),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('刷新'));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(4),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空结果、权限失败与登录失效沿用后台反馈', async () => {
|
||||||
|
const unauthorized = vi.fn();
|
||||||
|
vi.mocked(listAdminAgcTrackingEvents)
|
||||||
|
.mockResolvedValueOnce({ entries: [], nextCursor: null })
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new AdminApiError({ status: 403, message: '无权访问客户端埋点' }),
|
||||||
|
)
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new AdminApiError({ status: 401, message: '登录失效' }),
|
||||||
|
);
|
||||||
|
render(<AdminAgcTrackingPage token="token" onUnauthorized={unauthorized} />);
|
||||||
|
await screen.findByText('暂无客户端埋点数据');
|
||||||
|
fireEvent.click(screen.getByText('刷新'));
|
||||||
|
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||||
|
'无权访问客户端埋点',
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('刷新'));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(unauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { Modal } from '@genarrative/shared/components';
|
||||||
|
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
|
||||||
|
import type {
|
||||||
|
AdminAgcTrackingEventEntry,
|
||||||
|
AdminAgcTrackingEventListResponse,
|
||||||
|
AdminAgcTrackingEventQuery,
|
||||||
|
} from '../api/adminApiTypes';
|
||||||
|
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||||
|
import { handlePageError } from './pageUtils';
|
||||||
|
|
||||||
|
const eventLabels: Record<string, string> = {
|
||||||
|
editor_session_start: '编辑器会话开始',
|
||||||
|
editor_session_end: '编辑器会话结束',
|
||||||
|
editor_focus_start: '编辑器获得焦点',
|
||||||
|
editor_focus_end: '编辑器失去焦点',
|
||||||
|
project_create_success: '项目创建成功',
|
||||||
|
project_open: '打开项目',
|
||||||
|
creative_task_submit: '首次提交创作目标',
|
||||||
|
agent_run_completed: 'Agent 运行完成',
|
||||||
|
agent_run_failed: 'Agent 运行失败',
|
||||||
|
project_revision_created: '项目产生修改',
|
||||||
|
preview_ready: '预览就绪',
|
||||||
|
project_save: '保存项目',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
|
||||||
|
eventId: '事件 ID',
|
||||||
|
schemaVersion: '事件版本',
|
||||||
|
eventName: '事件类型',
|
||||||
|
eventTime: '发生时间',
|
||||||
|
userId: '用户 ID',
|
||||||
|
editorSessionId: '编辑器会话 ID',
|
||||||
|
projectId: '项目 ID',
|
||||||
|
creativeTaskId: '创作目标 ID',
|
||||||
|
agentRunId: 'Agent run ID',
|
||||||
|
agentTurnId: 'Agent turn ID',
|
||||||
|
status: '结果',
|
||||||
|
errorCode: '错误码',
|
||||||
|
source: '来源',
|
||||||
|
clientVersion: '客户端版本',
|
||||||
|
properties: '事件属性',
|
||||||
|
batchId: '批次 ID',
|
||||||
|
receivedAt: '入库时间',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAgcTrackingPage({
|
||||||
|
token,
|
||||||
|
onUnauthorized,
|
||||||
|
}: {
|
||||||
|
token: string;
|
||||||
|
onUnauthorized: (message?: string) => void;
|
||||||
|
}) {
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
userId: '',
|
||||||
|
projectId: '',
|
||||||
|
eventName: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
});
|
||||||
|
const [query, setQuery] = useState<AdminAgcTrackingEventQuery>({});
|
||||||
|
const [cursors, setCursors] = useState<Array<string | undefined>>([
|
||||||
|
undefined,
|
||||||
|
]);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [refresh, setRefresh] = useState(0);
|
||||||
|
const [entries, setEntries] = useState<AdminAgcTrackingEventEntry[]>([]);
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [detail, setDetail] = useState<AdminAgcTrackingEventEntry | null>(null);
|
||||||
|
const cursor = cursors[page];
|
||||||
|
// 首页没有入站游标,返回首页时保留原快照,刷新才获取新数据。
|
||||||
|
const firstPage = useRef<{
|
||||||
|
token: string;
|
||||||
|
query: AdminAgcTrackingEventQuery;
|
||||||
|
response: AdminAgcTrackingEventListResponse;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = firstPage.current;
|
||||||
|
if (!cursor && saved?.token === token && saved.query === query) {
|
||||||
|
setEntries(saved.response.entries);
|
||||||
|
setNextCursor(saved.response.nextCursor);
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setEntries([]);
|
||||||
|
setNextCursor(null);
|
||||||
|
void listAdminAgcTrackingEvents(token, { ...query, cursor, limit: 50 })
|
||||||
|
.then((response) => {
|
||||||
|
if (!active) return;
|
||||||
|
if (!cursor) firstPage.current = { token, query, response };
|
||||||
|
setEntries(response.entries);
|
||||||
|
setNextCursor(response.nextCursor);
|
||||||
|
})
|
||||||
|
.catch((failure: unknown) => {
|
||||||
|
if (active) handlePageError(failure, onUnauthorized, setError);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [token, query, cursor, refresh, onUnauthorized]);
|
||||||
|
|
||||||
|
function resetPages() {
|
||||||
|
firstPage.current = null;
|
||||||
|
setCursors([undefined]);
|
||||||
|
setPage(0);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (
|
||||||
|
filters.startTime &&
|
||||||
|
filters.endTime &&
|
||||||
|
new Date(filters.startTime) >= new Date(filters.endTime)
|
||||||
|
) {
|
||||||
|
setError('结束时间必须晚于开始时间');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetPages();
|
||||||
|
setQuery({
|
||||||
|
userId: filters.userId.trim(),
|
||||||
|
projectId: filters.projectId.trim(),
|
||||||
|
eventName: filters.eventName,
|
||||||
|
startTime: filters.startTime
|
||||||
|
? new Date(filters.startTime).toISOString()
|
||||||
|
: undefined,
|
||||||
|
endTime: filters.endTime
|
||||||
|
? new Date(filters.endTime).toISOString()
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function related(
|
||||||
|
key: 'creativeTaskId' | 'agentRunId' | 'clientVersion',
|
||||||
|
value: string,
|
||||||
|
) {
|
||||||
|
resetPages();
|
||||||
|
setFilters({
|
||||||
|
userId: '',
|
||||||
|
projectId: '',
|
||||||
|
eventName: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
});
|
||||||
|
setQuery({ [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-page admin-page-wide">
|
||||||
|
<div className="admin-page-heading">
|
||||||
|
<div>
|
||||||
|
<h2>客户端埋点</h2>
|
||||||
|
<p>按入库时间倒序展示</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-secondary-button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => {
|
||||||
|
resetPages();
|
||||||
|
setRefresh((value) => value + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form className="admin-panel admin-form" onSubmit={search}>
|
||||||
|
<div className="admin-filter-grid">
|
||||||
|
{(['userId', 'projectId'] as const).map((key) => (
|
||||||
|
<label key={key} className="admin-field">
|
||||||
|
<span>{fieldLabels[key]}</span>
|
||||||
|
<input
|
||||||
|
value={filters[key]}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters({ ...filters, [key]: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<label className="admin-field">
|
||||||
|
<span>事件类型</span>
|
||||||
|
<select
|
||||||
|
value={filters.eventName}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters({ ...filters, eventName: event.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">全部</option>
|
||||||
|
{Object.entries(eventLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{(['startTime', 'endTime'] as const).map((key) => (
|
||||||
|
<label key={key} className="admin-field">
|
||||||
|
<span>
|
||||||
|
{key === 'startTime'
|
||||||
|
? '发生时间起点(含)'
|
||||||
|
: '发生时间终点(不含)'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={filters[key]}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters({ ...filters, [key]: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="admin-action-row">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="admin-primary-button"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
|
||||||
|
.filter((key) => query[key])
|
||||||
|
.map((key) => (
|
||||||
|
<p key={key}>
|
||||||
|
{fieldLabels[key]}:{query[key]}(重新查询可清除)
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="admin-error-message">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="admin-panel">
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{[
|
||||||
|
'入库时间',
|
||||||
|
'发生时间',
|
||||||
|
'用户',
|
||||||
|
'事件名称',
|
||||||
|
'项目',
|
||||||
|
'来源',
|
||||||
|
'结果',
|
||||||
|
'客户端版本',
|
||||||
|
'详情',
|
||||||
|
].map((label) => (
|
||||||
|
<th key={label}>{label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<tr key={entry.eventId}>
|
||||||
|
<td>{formatTime(entry.receivedAt)}</td>
|
||||||
|
<td>{formatTime(entry.eventTime)}</td>
|
||||||
|
<td>
|
||||||
|
{entry.userId}
|
||||||
|
<AdminUserReferenceButton
|
||||||
|
token={token}
|
||||||
|
userId={entry.userId}
|
||||||
|
onUnauthorized={onUnauthorized}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
|
||||||
|
<td>{entry.projectId ?? '—'}</td>
|
||||||
|
<td>{entry.source}</td>
|
||||||
|
<td>{entry.status ?? '—'}</td>
|
||||||
|
<td>{entry.clientVersion}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-ghost-button"
|
||||||
|
onClick={() => setDetail(entry)}
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<p role="status">加载中…</p>
|
||||||
|
) : !error && entries.length === 0 ? (
|
||||||
|
<p>暂无客户端埋点数据</p>
|
||||||
|
) : null}
|
||||||
|
<div className="admin-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-secondary-button"
|
||||||
|
disabled={loading || page === 0}
|
||||||
|
onClick={() => setPage((value) => value - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span>第 {page + 1} 页</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-secondary-button"
|
||||||
|
disabled={loading || !nextCursor}
|
||||||
|
onClick={() => {
|
||||||
|
if (!nextCursor) return;
|
||||||
|
setCursors([...cursors.slice(0, page + 1), nextCursor]);
|
||||||
|
setPage(page + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{detail ? (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title="客户端埋点详情"
|
||||||
|
closeLabel="关闭详情"
|
||||||
|
onClose={() => setDetail(null)}
|
||||||
|
className="genarrative-ui"
|
||||||
|
>
|
||||||
|
<dl>
|
||||||
|
{(
|
||||||
|
Object.keys(fieldLabels) as Array<
|
||||||
|
keyof AdminAgcTrackingEventEntry
|
||||||
|
>
|
||||||
|
)
|
||||||
|
.filter((key) => key !== 'properties')
|
||||||
|
.map((key) => (
|
||||||
|
<div key={key}>
|
||||||
|
<dt>{fieldLabels[key]}</dt>
|
||||||
|
<dd style={{ overflowWrap: 'anywhere' }}>
|
||||||
|
{detail[key] == null ? '—' : String(detail[key])}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<div className="admin-action-row">
|
||||||
|
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
|
||||||
|
(key) =>
|
||||||
|
detail[key] ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={key}
|
||||||
|
className="admin-secondary-button"
|
||||||
|
onClick={() => related(key, detail[key]!)}
|
||||||
|
>
|
||||||
|
查询同一{fieldLabels[key]}
|
||||||
|
</button>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3>事件属性</h3>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||||
|
{JSON.stringify(detail.properties, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -185,12 +185,7 @@ test('nextVersion 只在 patch 位递增', () => {
|
|||||||
|
|
||||||
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
||||||
const base = {
|
const base = {
|
||||||
args: [
|
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
|
||||||
'cp',
|
|
||||||
'--force',
|
|
||||||
'/tmp/a.json',
|
|
||||||
'oss://agc-dev/agc/global-version.json',
|
|
||||||
],
|
|
||||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||||
accessKeyId: 'id',
|
accessKeyId: 'id',
|
||||||
accessKeySecret: 'secret',
|
accessKeySecret: 'secret',
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ const appInvokeSources = readSourceFiles(
|
|||||||
new URL('../src/', import.meta.url),
|
new URL('../src/', import.meta.url),
|
||||||
new Set(['.ts', '.tsx']),
|
new Set(['.ts', '.tsx']),
|
||||||
);
|
);
|
||||||
|
const appEntrypointSource = fs.readFileSync(
|
||||||
|
new URL('../src/main.tsx', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
const tauriHandlerSource = fs.readFileSync(
|
const tauriHandlerSource = fs.readFileSync(
|
||||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -131,45 +135,6 @@ const rustSharedContractSource = fs.readFileSync(
|
|||||||
);
|
);
|
||||||
const allowedUncalledTauriCommands = [
|
const allowedUncalledTauriCommands = [
|
||||||
'append_direct_project_conversation_message',
|
'append_direct_project_conversation_message',
|
||||||
// Supervisor 调试窗口、开发者面板、专业 Agent 对话与旧命令聊天的前端调用方已随
|
|
||||||
// Supervisor 前端链路整体删除;命令本身仍注册在 Rust 侧并由 native Runtime、CLI
|
|
||||||
// swarm 与 Rust 测试使用,保留 present,仅不再出现在 App 前端源码里。
|
|
||||||
'answer_game_creator_agent_runtime_user_input',
|
|
||||||
'cancel_game_creator_agent_runtime_task',
|
|
||||||
'chat_with_game_creator_role_agent',
|
|
||||||
'chat_with_game_creator_role_agent_stream',
|
|
||||||
'check_game_creator_llm_config',
|
|
||||||
'confirm_game_creator_agent_runtime_task',
|
|
||||||
'diff_local_project_checkpoint',
|
|
||||||
'get_game_creation_agent_capabilities',
|
|
||||||
'get_limited_local_commands',
|
|
||||||
'list_local_project_export_packages',
|
|
||||||
'read_game_creator_agent_runtime',
|
|
||||||
'read_local_agent_memory',
|
|
||||||
'read_local_game_memory',
|
|
||||||
'reject_game_creator_agent_runtime_task',
|
|
||||||
'retry_game_creator_agent_runtime_task',
|
|
||||||
'schedule_game_creator_agent_ready_tasks',
|
|
||||||
'start_game_creator_agent_runtime_task',
|
|
||||||
'steer_game_creator_agent_runtime_task',
|
|
||||||
'write_local_agent_memory',
|
|
||||||
'write_local_game_memory',
|
|
||||||
'write_local_project_file',
|
|
||||||
// Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。
|
|
||||||
'archive_game_creator_agent_session',
|
|
||||||
'clear_game_creator_agent_goal',
|
|
||||||
'compact_game_creator_agent_runtime_context',
|
|
||||||
'confirm_retry_game_creator_agent_runtime_task',
|
|
||||||
'create_game_creator_agent_session',
|
|
||||||
'edit_game_creator_agent_goal',
|
|
||||||
'fork_game_creator_agent_session',
|
|
||||||
'list_game_creator_agent_sessions',
|
|
||||||
'pause_game_creator_agent_goal',
|
|
||||||
'read_game_creator_agent_goal',
|
|
||||||
'resume_game_creator_agent_goal',
|
|
||||||
'set_active_game_creator_agent_session',
|
|
||||||
'start_game_creator_agent_goal',
|
|
||||||
'start_game_creator_supervisor_runtime_task',
|
|
||||||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||||
'bind_components',
|
'bind_components',
|
||||||
'chat_with_game_creator_agent',
|
'chat_with_game_creator_agent',
|
||||||
@@ -199,26 +164,6 @@ const allowedUncalledTauriCommands = [
|
|||||||
'call_agc_plugin',
|
'call_agc_plugin',
|
||||||
'read_agc_plugin_panel',
|
'read_agc_plugin_panel',
|
||||||
'set_agc_plugin_enabled',
|
'set_agc_plugin_enabled',
|
||||||
// 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口;
|
|
||||||
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 /
|
|
||||||
// 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现,
|
|
||||||
// `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按
|
|
||||||
// native-only 登记,避免孤儿检查一直报错。
|
|
||||||
// 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。
|
|
||||||
'build_local_project_index',
|
|
||||||
'control_agent_run',
|
|
||||||
'create_local_project_checkpoint',
|
|
||||||
'export_local_project_package',
|
|
||||||
'generate_local_game_draft',
|
|
||||||
'generate_platform_art_asset',
|
|
||||||
'import_canvas_asset',
|
|
||||||
'import_canvas_export',
|
|
||||||
'open_canvas_project',
|
|
||||||
'register_local_asset',
|
|
||||||
'restore_local_project_checkpoint',
|
|
||||||
'run_limited_local_command',
|
|
||||||
'sync_canvas_project_assets',
|
|
||||||
'write_project_permission_policy',
|
|
||||||
];
|
];
|
||||||
const sourceExtensions = new Set([
|
const sourceExtensions = new Set([
|
||||||
'.json',
|
'.json',
|
||||||
@@ -1525,7 +1470,13 @@ const eventCapability = JSON.parse(
|
|||||||
);
|
);
|
||||||
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
||||||
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
||||||
for (const windowLabel of ['client', 'main', 'launcher']) {
|
for (const windowLabel of [
|
||||||
|
'client',
|
||||||
|
'developer',
|
||||||
|
'main',
|
||||||
|
'launcher',
|
||||||
|
'supervisor-chat',
|
||||||
|
]) {
|
||||||
if (!eventCapabilityWindows.has(windowLabel)) {
|
if (!eventCapabilityWindows.has(windowLabel)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`AI game creator shell event capability missing window: ${windowLabel}`,
|
`AI game creator shell event capability missing window: ${windowLabel}`,
|
||||||
@@ -1908,6 +1859,20 @@ if (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const snippet of [
|
||||||
|
'import.meta.env.DEV',
|
||||||
|
'supervisorChatMode',
|
||||||
|
'supervisorChatOnly',
|
||||||
|
'open_project_supervisor_chat_window',
|
||||||
|
'index.html?supervisor-chat&projectPath=',
|
||||||
|
]) {
|
||||||
|
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
|
||||||
|
throw new Error(
|
||||||
|
`AI game creator shell developer window guardrail drifted: ${snippet}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AI game creator normal startup must not automatically open the developer window',
|
'AI game creator normal startup must not automatically open the developer window',
|
||||||
@@ -1940,17 +1905,31 @@ for (const snippet of [
|
|||||||
'官方账号服务(固定)',
|
'官方账号服务(固定)',
|
||||||
'runtime_config.save',
|
'runtime_config.save',
|
||||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||||
|
"'activate_local_game_preview'",
|
||||||
|
'已切换到客户端运行视图',
|
||||||
'async function executeRunLocal',
|
'async function executeRunLocal',
|
||||||
'function needsInitializedChatProject',
|
'function needsInitializedChatProject',
|
||||||
|
'function resolvePendingCommandProjectPath',
|
||||||
|
'resolveChatProjectPath(localProject) ?? draftProjectPath',
|
||||||
|
'`permission.cancel ${command.id} missing-project`',
|
||||||
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
||||||
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
||||||
'function parseRememberInput',
|
'function parseRememberInput',
|
||||||
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
||||||
|
'async function executeAgentTraceChat',
|
||||||
|
"relativePath: '.agent/logs/command.log'",
|
||||||
"'permission.pending'",
|
"'permission.pending'",
|
||||||
"'permission.confirm'",
|
"'permission.confirm'",
|
||||||
"'permission.cancel'",
|
"'permission.cancel'",
|
||||||
"'command.auto'",
|
"'command.auto'",
|
||||||
|
"'agent.run_status'",
|
||||||
'function summarizeAgentRunTrace',
|
'function summarizeAgentRunTrace',
|
||||||
|
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
|
||||||
|
'agentRunTrace.error ?',
|
||||||
|
'className="trace-error"',
|
||||||
|
'agentRunTrace.taskGraph.repairRoutes.map',
|
||||||
|
"in: ${step.inputPaths.join(', ') || 'none'}",
|
||||||
|
"out: ${step.outputPaths.join(', ') || 'none'}",
|
||||||
]) {
|
]) {
|
||||||
if (!appSource.includes(snippet)) {
|
if (!appSource.includes(snippet)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -1192,7 +1192,7 @@ async function main() {
|
|||||||
function isDirectModuleExecution() {
|
function isDirectModuleExecution() {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
process.argv[1] &&
|
process.argv[1] &&
|
||||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "developer",
|
||||||
|
"description": "开发窗口允许打开本地素材选择对话框。",
|
||||||
|
"windows": ["developer"],
|
||||||
|
"permissions": ["dialog:allow-open"]
|
||||||
|
}
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "events",
|
"identifier": "events",
|
||||||
"description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。",
|
"description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。",
|
||||||
"windows": ["client", "main", "launcher"],
|
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
|
||||||
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
|
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "window-chrome",
|
"identifier": "window-chrome",
|
||||||
"description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。",
|
"description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。",
|
||||||
"windows": ["client", "main", "launcher"],
|
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
|
|||||||
@@ -1180,6 +1180,7 @@ fn direct_codex_thread_delta_event(
|
|||||||
) -> DirectThreadEvent {
|
) -> DirectThreadEvent {
|
||||||
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
|
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
|
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
|
||||||
///
|
///
|
||||||
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
|
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
|
||||||
@@ -3357,22 +3358,8 @@ impl CodexAppServerConnection {
|
|||||||
codex_app_server_text_prompt(&request)
|
codex_app_server_text_prompt(&request)
|
||||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||||
};
|
};
|
||||||
let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
let mut input =
|
||||||
if let Some(item) = direct_user_item {
|
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
|
||||||
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
|
|
||||||
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
|
|
||||||
direct_codex_user_item_to_codex_turn_input(
|
|
||||||
&self.inner.workspace_path,
|
|
||||||
&canonical,
|
|
||||||
self.inner._skill_roots.as_deref().unwrap_or_default(),
|
|
||||||
)
|
|
||||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
|
||||||
} else {
|
|
||||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
|
||||||
};
|
|
||||||
if thread_created && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
|
if thread_created && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
|
||||||
{
|
{
|
||||||
if let Some(client_turn_id) = direct_client_turn_id {
|
if let Some(client_turn_id) = direct_client_turn_id {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,9 @@
|
|||||||
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
||||||
|
|
||||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
||||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
||||||
|
|
||||||
const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader");
|
const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader");
|
||||||
const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader");
|
const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader");
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ mod validation;
|
|||||||
mod wire;
|
mod wire;
|
||||||
|
|
||||||
pub(crate) use model::{
|
pub(crate) use model::{
|
||||||
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
|
||||||
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||||
};
|
};
|
||||||
pub(crate) use validation::validate_direct_codex_user_item;
|
pub(crate) use validation::validate_direct_codex_user_item;
|
||||||
pub(crate) use wire::{
|
pub(crate) use wire::{
|
||||||
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
|
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||||
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
|
direct_codex_user_item_to_wire_input,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use ts_rs::TS;
|
|||||||
/// DirectProject 本轮 user input 的唯一结构化入口。
|
/// DirectProject 本轮 user input 的唯一结构化入口。
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(tag = "type", deny_unknown_fields)]
|
#[serde(tag = "type", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||||
pub(crate) enum DirectCodexUserItem {
|
pub(crate) enum DirectCodexUserItem {
|
||||||
#[serde(rename = "message")]
|
#[serde(rename = "message")]
|
||||||
Message(DirectCodexUserMessageItem),
|
Message(DirectCodexUserMessageItem),
|
||||||
@@ -12,7 +12,7 @@ pub(crate) enum DirectCodexUserItem {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||||
pub(crate) struct DirectCodexUserMessageItem {
|
pub(crate) struct DirectCodexUserMessageItem {
|
||||||
pub(crate) role: DirectCodexUserRole,
|
pub(crate) role: DirectCodexUserRole,
|
||||||
pub(crate) content: Vec<DirectCodexUserContentPart>,
|
pub(crate) content: Vec<DirectCodexUserContentPart>,
|
||||||
@@ -21,43 +21,26 @@ pub(crate) struct DirectCodexUserMessageItem {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||||
pub(crate) enum DirectCodexUserRole {
|
pub(crate) enum DirectCodexUserRole {
|
||||||
User,
|
User,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||||
pub(crate) enum DirectCodexUserContentPart {
|
pub(crate) enum DirectCodexUserContentPart {
|
||||||
#[serde(rename = "input_text")]
|
#[serde(rename = "input_text")]
|
||||||
InputText { text: String },
|
InputText { text: String },
|
||||||
#[serde(rename = "agc_resource_reference")]
|
#[serde(rename = "agc_resource_reference")]
|
||||||
AgcResourceReference { resource_id: String },
|
AgcResourceReference { resource_id: String },
|
||||||
#[serde(rename = "agc_skill_reference")]
|
|
||||||
AgcSkillReference { name: String },
|
|
||||||
#[serde(rename = "agc_runtime_region_reference")]
|
#[serde(rename = "agc_runtime_region_reference")]
|
||||||
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
||||||
/// Uploaded project attachment kept inline in canonical content.
|
|
||||||
#[serde(rename = "agc_attachment_reference")]
|
|
||||||
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||||
pub(crate) struct DirectCodexUserAttachmentReferencePart {
|
|
||||||
pub(crate) name: String,
|
|
||||||
pub(crate) media_type: String,
|
|
||||||
#[ts(type = "number")]
|
|
||||||
pub(crate) size: u64,
|
|
||||||
pub(crate) local_path: String,
|
|
||||||
pub(crate) status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
|
||||||
pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
||||||
pub(crate) label: String,
|
pub(crate) label: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+14
-249
@@ -4,20 +4,15 @@ use super::model::{
|
|||||||
};
|
};
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
||||||
MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS,
|
|
||||||
MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS,
|
|
||||||
};
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||||
// Skill 引用也是非文本 part,但没有走 reference_count:它每一条都会触发一次
|
|
||||||
// `root/<name>/SKILL.md` 文件探测并往 turn input 里加一项,所以单独设上限。
|
|
||||||
pub(crate) const MAX_DIRECT_CODEX_SKILL_REFERENCES: usize = 32;
|
|
||||||
|
|
||||||
pub(crate) fn validate_direct_codex_user_item(
|
pub(crate) fn validate_direct_codex_user_item(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
) -> Result<GameCreationAppManifest, String> {
|
) -> Result<(), String> {
|
||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
if !matches!(message.role, DirectCodexUserRole::User) {
|
if !matches!(message.role, DirectCodexUserRole::User) {
|
||||||
return Err("DirectProject 只接受 user message item".to_string());
|
return Err("DirectProject 只接受 user message item".to_string());
|
||||||
@@ -25,98 +20,38 @@ pub(crate) fn validate_direct_codex_user_item(
|
|||||||
if message.id.trim().is_empty() {
|
if message.id.trim().is_empty() {
|
||||||
return Err("DirectProject user item 缺少稳定 id".to_string());
|
return Err("DirectProject user item 缺少稳定 id".to_string());
|
||||||
}
|
}
|
||||||
// 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
|
if message.content.is_empty() {
|
||||||
// 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。
|
|
||||||
if !content_has_meaningful_input(&message.content) {
|
|
||||||
return Err("聊天内容不能为空".to_string());
|
return Err("聊天内容不能为空".to_string());
|
||||||
}
|
}
|
||||||
let manifest = read_manifest_for_project(root)?;
|
let manifest = read_manifest_for_project(root)?;
|
||||||
let mut reference_count = 0usize;
|
let mut reference_count = 0usize;
|
||||||
let mut attachment_count = 0usize;
|
let mut has_effective_content = false;
|
||||||
let mut skill_count = 0usize;
|
|
||||||
for part in &message.content {
|
for part in &message.content {
|
||||||
match part {
|
match part {
|
||||||
DirectCodexUserContentPart::InputText { .. } => {}
|
DirectCodexUserContentPart::InputText { text } => {
|
||||||
|
if !text.trim().is_empty() {
|
||||||
|
has_effective_content = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
reference_count = reference_count.saturating_add(1);
|
reference_count = reference_count.saturating_add(1);
|
||||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
||||||
}
|
has_effective_content = true;
|
||||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
|
||||||
skill_count = skill_count.saturating_add(1);
|
|
||||||
if skill_count > MAX_DIRECT_CODEX_SKILL_REFERENCES {
|
|
||||||
return Err(format!(
|
|
||||||
"一次最多引用 {MAX_DIRECT_CODEX_SKILL_REFERENCES} 个 Skill"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let name = name.trim();
|
|
||||||
if name.is_empty()
|
|
||||||
|| name.chars().count() > 120
|
|
||||||
|| matches!(name, "." | "..")
|
|
||||||
|| name.chars().any(|character| {
|
|
||||||
character.is_control()
|
|
||||||
|| character.is_whitespace()
|
|
||||||
|| matches!(character, '/' | '\\' | ':' | '$')
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
reference_count = reference_count.saturating_add(1);
|
reference_count = reference_count.saturating_add(1);
|
||||||
validate_runtime_region_reference(&manifest, reference)?;
|
validate_runtime_region_reference(&manifest, reference)?;
|
||||||
}
|
has_effective_content = true;
|
||||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
|
||||||
attachment_count = attachment_count.saturating_add(1);
|
|
||||||
if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS {
|
|
||||||
return Err(format!(
|
|
||||||
"一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if reference.name.trim().is_empty() {
|
|
||||||
return Err("附件缺少文件名".to_string());
|
|
||||||
}
|
|
||||||
let name = reference.name.trim();
|
|
||||||
if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS
|
|
||||||
|| name.chars().any(char::is_control)
|
|
||||||
{
|
|
||||||
return Err("附件文件名无效或过长".to_string());
|
|
||||||
}
|
|
||||||
let media_type = reference.media_type.trim();
|
|
||||||
if media_type.is_empty()
|
|
||||||
|| media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS
|
|
||||||
|| media_type.chars().any(|character| {
|
|
||||||
!(character.is_ascii_alphanumeric()
|
|
||||||
|| matches!(character, '/' | '+' | '-' | '.' | '_'))
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return Err("附件媒体类型无效或过长".to_string());
|
|
||||||
}
|
|
||||||
let status = reference.status.trim();
|
|
||||||
if status == "imported" && reference.local_path.trim().is_empty() {
|
|
||||||
return Err("已导入附件缺少项目路径".to_string());
|
|
||||||
}
|
|
||||||
if !reference.local_path.trim().is_empty() {
|
|
||||||
sanitize_attachment_local_path(&reference.local_path)
|
|
||||||
.ok_or_else(|| "附件项目路径无效".to_string())?;
|
|
||||||
}
|
|
||||||
if !matches!(status, "imported" | "failed") {
|
|
||||||
return Err("附件状态无效".to_string());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||||
}
|
}
|
||||||
Ok(manifest)
|
if !has_effective_content {
|
||||||
}
|
return Err("聊天内容不能为空".to_string());
|
||||||
|
}
|
||||||
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
|
Ok(())
|
||||||
pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool {
|
|
||||||
content.iter().any(|part| match part {
|
|
||||||
DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(),
|
|
||||||
_ => true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_resource_id_and_manifest(
|
pub(crate) fn validate_resource_id_and_manifest(
|
||||||
@@ -157,173 +92,3 @@ fn validate_runtime_region_reference(
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
content_has_meaningful_input, validate_direct_codex_user_item,
|
|
||||||
MAX_DIRECT_CODEX_SKILL_REFERENCES,
|
|
||||||
};
|
|
||||||
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
fn input_text(text: &str) -> DirectCodexUserContentPart {
|
|
||||||
DirectCodexUserContentPart::InputText {
|
|
||||||
text: text.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_all_blank_content_counts_as_empty_input() {
|
|
||||||
// 空数组与「整条只有空白」是同一种空输入。
|
|
||||||
assert!(!content_has_meaningful_input(&[]));
|
|
||||||
assert!(!content_has_meaningful_input(&[input_text(" \n ")]));
|
|
||||||
assert!(!content_has_meaningful_input(&[
|
|
||||||
input_text("\n"),
|
|
||||||
input_text(" "),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn whitespace_parts_are_valid_next_to_meaningful_input() {
|
|
||||||
// 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。
|
|
||||||
assert!(content_has_meaningful_input(&[
|
|
||||||
input_text("\n"),
|
|
||||||
input_text("看"),
|
|
||||||
]));
|
|
||||||
assert!(content_has_meaningful_input(&[
|
|
||||||
input_text("看"),
|
|
||||||
input_text("\n\n"),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_text_parts_always_count_as_input() {
|
|
||||||
assert!(content_has_meaningful_input(&[
|
|
||||||
DirectCodexUserContentPart::AgcResourceReference {
|
|
||||||
resource_id: "asset-hero".to_string(),
|
|
||||||
},
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn inline_attachment_count_is_bounded_independently() {
|
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
|
||||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
|
||||||
.expect("init project");
|
|
||||||
let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS)
|
|
||||||
.map(|index| {
|
|
||||||
json!({
|
|
||||||
"type": "agc_attachment_reference",
|
|
||||||
"name": format!("attachment-{index}.txt"),
|
|
||||||
"mediaType": "text/plain",
|
|
||||||
"size": 1,
|
|
||||||
"localPath": "",
|
|
||||||
"status": "failed"
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let item = serde_json::from_value(json!({
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"content": content,
|
|
||||||
"id": "turn-1:user"
|
|
||||||
}))
|
|
||||||
.expect("deserialize user item");
|
|
||||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
|
||||||
.expect_err("too many inline attachments must be rejected");
|
|
||||||
assert!(error.contains("最多携带"), "{error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn imported_attachment_requires_a_project_path() {
|
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
|
||||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
|
||||||
.expect("init project");
|
|
||||||
let item = serde_json::from_value(json!({
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"content": [{
|
|
||||||
"type": "agc_attachment_reference",
|
|
||||||
"name": "attachment.txt",
|
|
||||||
"mediaType": "text/plain",
|
|
||||||
"size": 1,
|
|
||||||
"localPath": "",
|
|
||||||
"status": "imported"
|
|
||||||
}],
|
|
||||||
"id": "turn-1:user"
|
|
||||||
}))
|
|
||||||
.expect("deserialize user item");
|
|
||||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
|
||||||
.expect_err("imported attachment without a project path must fail");
|
|
||||||
assert!(error.contains("缺少项目路径"), "{error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn inline_skill_reference_count_is_bounded_independently() {
|
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
|
||||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
|
||||||
.expect("init project");
|
|
||||||
let content = (0..=MAX_DIRECT_CODEX_SKILL_REFERENCES)
|
|
||||||
.map(|index| json!({ "type": "agc_skill_reference", "name": format!("skill-{index}") }))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let item = serde_json::from_value(json!({
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"content": content,
|
|
||||||
"id": "turn-1:user"
|
|
||||||
}))
|
|
||||||
.expect("deserialize user item");
|
|
||||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
|
||||||
.expect_err("too many skill references must be rejected");
|
|
||||||
assert!(error.contains("最多引用"), "{error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn attachment_name_and_media_type_are_bounded_and_well_formed() {
|
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
|
||||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
|
||||||
.expect("init project");
|
|
||||||
let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1);
|
|
||||||
let cases = [
|
|
||||||
(
|
|
||||||
json!({
|
|
||||||
"name": "bad\nname.txt",
|
|
||||||
"mediaType": "text/plain"
|
|
||||||
}),
|
|
||||||
"文件名",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
json!({
|
|
||||||
"name": "ok.txt",
|
|
||||||
"mediaType": "text/plain\nsecret"
|
|
||||||
}),
|
|
||||||
"媒体类型",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
json!({
|
|
||||||
"name": long_name,
|
|
||||||
"mediaType": "text/plain"
|
|
||||||
}),
|
|
||||||
"文件名",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
for (metadata, expected) in cases {
|
|
||||||
let mut value = metadata;
|
|
||||||
value["type"] = json!("agc_attachment_reference");
|
|
||||||
value["size"] = json!(1);
|
|
||||||
value["localPath"] = json!("");
|
|
||||||
value["status"] = json!("failed");
|
|
||||||
let item = serde_json::from_value(json!({
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"content": [value],
|
|
||||||
"id": "turn-1:user"
|
|
||||||
}))
|
|
||||||
.expect("deserialize user item");
|
|
||||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
|
||||||
.expect_err("invalid attachment metadata must fail");
|
|
||||||
assert!(error.contains(expected), "{error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
use super::model::{
|
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
|
||||||
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
|
||||||
DirectCodexUserMessageItem, DirectCodexUserRuntimeRegionPart,
|
|
||||||
};
|
|
||||||
use super::validation::validate_direct_codex_user_item;
|
use super::validation::validate_direct_codex_user_item;
|
||||||
use crate::agent::{
|
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
||||||
read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type,
|
|
||||||
sanitize_attachment_name, GameCreationAppManifest,
|
|
||||||
};
|
|
||||||
use crate::ui_editor::persistence::{
|
use crate::ui_editor::persistence::{
|
||||||
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
||||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||||
@@ -61,90 +55,54 @@ fn direct_codex_user_item_to_response_content(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resource_reference_summary(
|
|
||||||
manifest: &GameCreationAppManifest,
|
|
||||||
resource_id: &str,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let resource_id = resource_id.trim();
|
|
||||||
let asset = manifest
|
|
||||||
.assets
|
|
||||||
.iter()
|
|
||||||
.find(|asset| asset.id == resource_id)
|
|
||||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
|
||||||
let path = sanitize_attachment_local_path(&asset.local_path)
|
|
||||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
|
||||||
Ok(format!(
|
|
||||||
"[素材引用 resourceId={resource_id};项目路径={path}]"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
|
|
||||||
let resources = reference
|
|
||||||
.resource_ids
|
|
||||||
.iter()
|
|
||||||
.map(|id| id.trim())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",");
|
|
||||||
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
|
||||||
if let Some(run_id) = reference.run_id.as_deref() {
|
|
||||||
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
|
||||||
}
|
|
||||||
if let Some(role) = reference.element_role.as_deref() {
|
|
||||||
summary.push_str(&format!("角色={} ", role.trim()));
|
|
||||||
}
|
|
||||||
if let Some(text) = reference.text.as_deref() {
|
|
||||||
summary.push_str(&format!("文本={} ", text.trim()));
|
|
||||||
}
|
|
||||||
if !resources.is_empty() {
|
|
||||||
summary.push_str(&format!("关联素材={resources}"));
|
|
||||||
}
|
|
||||||
summary.push(']');
|
|
||||||
summary
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 附件引用的安全摘要。
|
|
||||||
///
|
|
||||||
/// turn 输入与 history/prompt 投影共用这一份清洗:文件名取 basename 并去控制字符、
|
|
||||||
/// media type 与项目路径同样过白名单,避免两条路径对同一个引用给出不同摘要。
|
|
||||||
fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePart) -> String {
|
|
||||||
let name = sanitize_attachment_name(&reference.name);
|
|
||||||
let media_type = sanitize_attachment_media_type(&reference.media_type);
|
|
||||||
let mut summary = format!(
|
|
||||||
"[附件:名称={name};类型={media_type};大小={} 字节",
|
|
||||||
reference.size
|
|
||||||
);
|
|
||||||
if let Some(local_path) = sanitize_attachment_local_path(&reference.local_path) {
|
|
||||||
summary.push_str(&format!(";项目路径={local_path}"));
|
|
||||||
}
|
|
||||||
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
|
||||||
summary.push(']');
|
|
||||||
summary
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
||||||
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
||||||
pub(crate) fn direct_codex_user_item_to_wire_input(
|
pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
// validate 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。
|
validate_direct_codex_user_item(root, item)?;
|
||||||
let manifest = validate_direct_codex_user_item(root, item)?;
|
let manifest = read_manifest_for_project(root)?;
|
||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
let mut input = Vec::with_capacity(message.content.len());
|
let mut input = Vec::with_capacity(message.content.len());
|
||||||
for part in &message.content {
|
for part in &message.content {
|
||||||
let text = match part {
|
let text = match part {
|
||||||
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
resource_reference_summary(&manifest, resource_id)?
|
let asset = manifest
|
||||||
}
|
.assets
|
||||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
.iter()
|
||||||
format!("${}", name.trim())
|
.find(|asset| asset.id == resource_id.trim())
|
||||||
|
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||||
|
let path = sanitize_attachment_local_path(&asset.local_path)
|
||||||
|
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||||
|
format!(
|
||||||
|
"[素材引用 resourceId={};项目路径={path}]",
|
||||||
|
resource_id.trim()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
runtime_region_summary(reference)
|
let resources = reference
|
||||||
}
|
.resource_ids
|
||||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
.iter()
|
||||||
attachment_reference_summary(reference)
|
.map(|id| id.trim())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
||||||
|
if let Some(run_id) = reference.run_id.as_deref() {
|
||||||
|
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
||||||
|
}
|
||||||
|
if let Some(role) = reference.element_role.as_deref() {
|
||||||
|
summary.push_str(&format!("角色={} ", role.trim()));
|
||||||
|
}
|
||||||
|
if let Some(text) = reference.text.as_deref() {
|
||||||
|
summary.push_str(&format!("文本={} ", text.trim()));
|
||||||
|
}
|
||||||
|
if !resources.is_empty() {
|
||||||
|
summary.push_str(&format!("关联素材={resources}"));
|
||||||
|
}
|
||||||
|
summary.push(']');
|
||||||
|
summary
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||||
@@ -152,55 +110,6 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
|||||||
Ok(Value::Array(input))
|
Ok(Value::Array(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
|
|
||||||
root: &Path,
|
|
||||||
item: &DirectCodexUserItem,
|
|
||||||
skill_roots: &[std::path::PathBuf],
|
|
||||||
) -> Result<Value, String> {
|
|
||||||
let manifest = validate_direct_codex_user_item(root, item)?;
|
|
||||||
let DirectCodexUserItem::Message(message) = item;
|
|
||||||
let mut input = Vec::with_capacity(message.content.len());
|
|
||||||
for part in &message.content {
|
|
||||||
match part {
|
|
||||||
DirectCodexUserContentPart::InputText { text } => {
|
|
||||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
|
||||||
input.push(serde_json::json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": resource_reference_summary(&manifest, resource_id)?,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
|
||||||
let name = name.trim();
|
|
||||||
let path = skill_roots
|
|
||||||
.iter()
|
|
||||||
.map(|root| root.join(name).join("SKILL.md"))
|
|
||||||
.find(|path| path.is_file())
|
|
||||||
.ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?;
|
|
||||||
input.push(serde_json::json!({
|
|
||||||
"type": "skill",
|
|
||||||
"name": name,
|
|
||||||
"path": path,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
|
||||||
input.push(serde_json::json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": runtime_region_summary(reference),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
|
||||||
input.push(serde_json::json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": attachment_reference_summary(reference),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Value::Array(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn direct_codex_user_item_to_prompt(
|
pub(crate) fn direct_codex_user_item_to_prompt(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
@@ -209,10 +118,17 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
|||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
let mut prompt = wire
|
let mut prompt = wire
|
||||||
.as_array()
|
.as_array()
|
||||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())?
|
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
||||||
.iter()
|
.and_then(|parts| {
|
||||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
parts
|
||||||
.collect::<String>();
|
.iter()
|
||||||
|
.map(|part| {
|
||||||
|
part.get("text")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())
|
||||||
|
})
|
||||||
|
.collect::<Result<String, String>>()
|
||||||
|
})?;
|
||||||
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
||||||
prompt.push('\n');
|
prompt.push('\n');
|
||||||
prompt.push_str(&code_context);
|
prompt.push_str(&code_context);
|
||||||
@@ -283,9 +199,8 @@ fn render_ui_design_code_context(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||||
direct_codex_user_item_to_wire_input, validate_direct_codex_user_item,
|
validate_direct_codex_user_item,
|
||||||
};
|
};
|
||||||
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
|
|
||||||
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use shared_contracts::game_creation_app::{
|
use shared_contracts::game_creation_app::{
|
||||||
@@ -368,83 +283,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ui_design_doc_reference_appends_generated_code_context() {
|
|
||||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
|
||||||
let item: super::DirectCodexUserItem =
|
|
||||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
|
||||||
.expect("canonical user item");
|
|
||||||
let prompt =
|
|
||||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
|
||||||
assert!(
|
|
||||||
prompt.contains(&format!(
|
|
||||||
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
|
|
||||||
)),
|
|
||||||
"{prompt}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
|
|
||||||
"{prompt}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn history_projection_never_writes_generated_ui_design_code() {
|
|
||||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
|
||||||
let item = user_item_with_resource_reference(&asset_id);
|
|
||||||
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
|
|
||||||
let generated_root =
|
|
||||||
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
|
|
||||||
let generated_files = std::fs::read_dir(generated_root)
|
|
||||||
.expect("read ui directory")
|
|
||||||
.filter_map(|entry| entry.ok())
|
|
||||||
.filter(|entry| {
|
|
||||||
entry
|
|
||||||
.file_name()
|
|
||||||
.to_string_lossy()
|
|
||||||
.starts_with("generated-")
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
assert_eq!(
|
|
||||||
generated_files, 0,
|
|
||||||
"历史回读只做纯投影,不得生成 UI 设计代码"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
|
|
||||||
let (project, asset_id) = ui_design_doc_fixture(false);
|
|
||||||
let item: super::DirectCodexUserItem =
|
|
||||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
|
||||||
.expect("canonical user item");
|
|
||||||
let prompt =
|
|
||||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
|
||||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
|
||||||
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
|
||||||
let project = prompt_context_project();
|
|
||||||
let asset_id = register_fixture_asset(
|
|
||||||
project.path(),
|
|
||||||
"assets/hero.png",
|
|
||||||
GameCreationAppAssetKind::Character,
|
|
||||||
"image/png",
|
|
||||||
);
|
|
||||||
let item: super::DirectCodexUserItem =
|
|
||||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
|
||||||
.expect("canonical user item");
|
|
||||||
let prompt =
|
|
||||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
|
||||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
|
||||||
assert!(
|
|
||||||
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
|
||||||
"{prompt}"
|
|
||||||
);
|
|
||||||
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||||
let item = json!({
|
let item = json!({
|
||||||
@@ -459,23 +297,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_item_projection_uses_input_text_not_turn_input_text() {
|
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
|
||||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
|
||||||
.expect("init project");
|
|
||||||
let item = json!({
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"id": "turn-1:user",
|
|
||||||
"content": [{"type": "input_text", "text": "你好"}]
|
|
||||||
});
|
|
||||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
|
||||||
.expect("user response item should project");
|
|
||||||
assert_eq!(projected["content"][0]["type"], "input_text");
|
|
||||||
assert_ne!(projected["content"][0]["type"], "text");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
|
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
@@ -624,97 +445,79 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attachment_parts_remain_in_canonical_order_when_projected() {
|
fn ui_design_doc_reference_appends_generated_code_context() {
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
let item: super::DirectCodexUserItem =
|
||||||
.expect("init project");
|
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||||
let item = json!({
|
.expect("canonical user item");
|
||||||
"type": "message",
|
let prompt =
|
||||||
"role": "user",
|
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||||
"id": "turn-1:user",
|
assert!(
|
||||||
"content": [
|
prompt.contains(&format!(
|
||||||
{"type": "input_text", "text": "先看"},
|
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
|
||||||
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
|
)),
|
||||||
]
|
"{prompt}"
|
||||||
});
|
);
|
||||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
assert!(
|
||||||
.expect("user response item should project");
|
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
|
||||||
let content = projected["content"].as_array().expect("content array");
|
"{prompt}"
|
||||||
assert_eq!(content.len(), 2);
|
);
|
||||||
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
|
|
||||||
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attachment_metadata_is_sanitized_before_prompt_projection() {
|
fn history_projection_never_writes_generated_ui_design_code() {
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
let item = user_item_with_resource_reference(&asset_id);
|
||||||
.expect("init project");
|
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
|
||||||
let item = json!({
|
let generated_root =
|
||||||
"type": "message",
|
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
|
||||||
"role": "user",
|
let generated_files = std::fs::read_dir(generated_root)
|
||||||
"id": "turn-1:user",
|
.expect("read ui directory")
|
||||||
"content": [{
|
.filter_map(|entry| entry.ok())
|
||||||
"type": "agc_attachment_reference",
|
.filter(|entry| {
|
||||||
"name": "C:\\tmp\\notes.md",
|
entry
|
||||||
"mediaType": "text/plain",
|
.file_name()
|
||||||
"size": 4,
|
.to_string_lossy()
|
||||||
"localPath": "assets\\.\\notes.txt",
|
.starts_with("generated-")
|
||||||
"status": "imported"
|
})
|
||||||
}]
|
.count();
|
||||||
});
|
assert_eq!(
|
||||||
let wire = super::direct_codex_user_item_to_wire_input(
|
generated_files, 0,
|
||||||
root.path(),
|
"历史回读只做纯投影,不得生成 UI 设计代码"
|
||||||
&serde_json::from_value(item).expect("deserialize user item"),
|
);
|
||||||
)
|
|
||||||
.expect("attachment metadata should project");
|
|
||||||
let text = wire[0]["text"].as_str().expect("wire text");
|
|
||||||
assert!(text.contains("名称=notes.md"), "{text}");
|
|
||||||
assert!(text.contains("类型=text/plain"), "{text}");
|
|
||||||
assert!(text.contains("项目路径=assets/notes.txt"), "{text}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whitespace_only_text_parts_survive_validation() {
|
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
let (project, asset_id) = ui_design_doc_fixture(false);
|
||||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
let item: super::DirectCodexUserItem =
|
||||||
.expect("init project");
|
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||||
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
.expect("canonical user item");
|
||||||
"type": "message",
|
let prompt =
|
||||||
"role": "user",
|
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||||
"id": "turn-1:user",
|
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||||
"content": [
|
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||||
{"type": "input_text", "text": "先看"},
|
|
||||||
{"type": "input_text", "text": "\n"},
|
|
||||||
{"type": "input_text", "text": " "}
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
.expect("deserialize user item");
|
|
||||||
let wire = direct_codex_user_item_to_wire_input(root.path(), &item)
|
|
||||||
.expect("whitespace-only part next to real text must pass");
|
|
||||||
let parts = wire.as_array().expect("wire input array");
|
|
||||||
assert_eq!(parts.len(), 3);
|
|
||||||
assert_eq!(parts[1]["text"].as_str(), Some("\n"));
|
|
||||||
assert_eq!(parts[2]["text"].as_str(), Some(" "));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_blank_content_is_rejected() {
|
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
let project = prompt_context_project();
|
||||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
let asset_id = register_fixture_asset(
|
||||||
.expect("init project");
|
project.path(),
|
||||||
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
"assets/hero.png",
|
||||||
"type": "message",
|
GameCreationAppAssetKind::Character,
|
||||||
"role": "user",
|
"image/png",
|
||||||
"id": "turn-1:user",
|
);
|
||||||
"content": [
|
let item: super::DirectCodexUserItem =
|
||||||
{"type": "input_text", "text": "\n"},
|
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||||
{"type": "input_text", "text": " "}
|
.expect("canonical user item");
|
||||||
]
|
let prompt =
|
||||||
}))
|
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||||
.expect("deserialize user item");
|
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||||
let error = direct_codex_user_item_to_wire_input(root.path(), &item)
|
assert!(
|
||||||
.expect_err("all-blank content must fail closed");
|
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
||||||
assert!(error.contains("不能为空"), "{error}");
|
"{prompt}"
|
||||||
|
);
|
||||||
|
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,10 +93,17 @@ pub(super) struct ExecutionLedger {
|
|||||||
pub(super) plan: Option<Value>,
|
pub(super) plan: Option<Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(super) last_failed_write_revision: Option<u64>,
|
pub(super) last_failed_write_revision: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) analytics_run: Option<crate::analytics::run::Metadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SessionData {
|
struct SessionData {
|
||||||
ledger: ExecutionLedger,
|
ledger: ExecutionLedger,
|
||||||
|
analytics_capture: Option<(
|
||||||
|
crate::analytics::contract::Context,
|
||||||
|
crate::analytics::store::AnalyticsWriter,
|
||||||
|
)>,
|
||||||
|
analytics_output_revision: Option<u64>,
|
||||||
started: Instant,
|
started: Instant,
|
||||||
initial_elapsed_ms: u64,
|
initial_elapsed_ms: u64,
|
||||||
lease_started: BTreeMap<String, Instant>,
|
lease_started: BTreeMap<String, Instant>,
|
||||||
@@ -153,6 +160,8 @@ fn executor_digest(path: &Path) -> Result<String, String> {
|
|||||||
|
|
||||||
pub(super) struct ExecutionSession {
|
pub(super) struct ExecutionSession {
|
||||||
pub(super) root: PathBuf,
|
pub(super) root: PathBuf,
|
||||||
|
/// 本次确实新建执行账本;恢复和旧预算迁移均不构成新的用户受理。
|
||||||
|
pub(super) newly_accepted: bool,
|
||||||
state_path: PathBuf,
|
state_path: PathBuf,
|
||||||
_owner: File,
|
_owner: File,
|
||||||
data: Mutex<SessionData>,
|
data: Mutex<SessionData>,
|
||||||
@@ -216,6 +225,16 @@ pub(crate) struct WritePermit {
|
|||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
impl WritePermit {
|
impl WritePermit {
|
||||||
|
pub(super) fn record_analytics_revision(
|
||||||
|
&self,
|
||||||
|
revision: u64,
|
||||||
|
change_kind: crate::analytics::contract::ChangeKind,
|
||||||
|
files_changed_count: u64,
|
||||||
|
) {
|
||||||
|
self.session
|
||||||
|
.record_analytics_revision(revision, change_kind, files_changed_count);
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn run<T>(&self, write: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
|
pub(crate) fn run<T>(&self, write: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
|
||||||
let mut data = self.session.lock()?;
|
let mut data = self.session.lock()?;
|
||||||
self.session.tick_locked(&mut data)?;
|
self.session.tick_locked(&mut data)?;
|
||||||
@@ -401,6 +420,7 @@ pub(super) async fn begin(
|
|||||||
prompt: &str,
|
prompt: &str,
|
||||||
requires_contract: bool,
|
requires_contract: bool,
|
||||||
config: DirectValidationConfig,
|
config: DirectValidationConfig,
|
||||||
|
analytics_run: Option<crate::analytics::run::Metadata>,
|
||||||
) -> Result<ExecutionSessionGuard, String> {
|
) -> Result<ExecutionSessionGuard, String> {
|
||||||
let root = root.to_path_buf();
|
let root = root.to_path_buf();
|
||||||
let prompt_hash = hash(prompt.as_bytes());
|
let prompt_hash = hash(prompt.as_bytes());
|
||||||
@@ -408,13 +428,14 @@ pub(super) async fn begin(
|
|||||||
let turn = super::direct_taonier_active_invocation_id_at(&root)?;
|
let turn = super::direct_taonier_active_invocation_id_at(&root)?;
|
||||||
let host = crate::game_creator_runtime_config_dir()
|
let host = crate::game_creator_runtime_config_dir()
|
||||||
.ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?;
|
.ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?;
|
||||||
open_at(
|
open_with_analytics_at(
|
||||||
&host.join("direct-executions"),
|
&host.join("direct-executions"),
|
||||||
&root,
|
&root,
|
||||||
&turn,
|
&turn,
|
||||||
&prompt_hash,
|
&prompt_hash,
|
||||||
requires_contract,
|
requires_contract,
|
||||||
&config,
|
&config,
|
||||||
|
analytics_run,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -462,6 +483,26 @@ pub(super) fn open_at(
|
|||||||
request_hash: &str,
|
request_hash: &str,
|
||||||
requires_contract: bool,
|
requires_contract: bool,
|
||||||
config: &DirectValidationConfig,
|
config: &DirectValidationConfig,
|
||||||
|
) -> Result<Arc<ExecutionSession>, String> {
|
||||||
|
open_with_analytics_at(
|
||||||
|
host,
|
||||||
|
root,
|
||||||
|
turn,
|
||||||
|
request_hash,
|
||||||
|
requires_contract,
|
||||||
|
config,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn open_with_analytics_at(
|
||||||
|
host: &Path,
|
||||||
|
root: &Path,
|
||||||
|
turn: &str,
|
||||||
|
request_hash: &str,
|
||||||
|
requires_contract: bool,
|
||||||
|
config: &DirectValidationConfig,
|
||||||
|
analytics_run: Option<crate::analytics::run::Metadata>,
|
||||||
) -> Result<Arc<ExecutionSession>, String> {
|
) -> Result<Arc<ExecutionSession>, String> {
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
let root = root
|
let root = root
|
||||||
@@ -521,6 +562,7 @@ pub(super) fn open_at(
|
|||||||
};
|
};
|
||||||
let project_id = super::read_existing_manifest_for_project(&root)?.project_id;
|
let project_id = super::read_existing_manifest_for_project(&root)?.project_id;
|
||||||
let is_new = existing.is_none();
|
let is_new = existing.is_none();
|
||||||
|
let mut newly_accepted = is_new;
|
||||||
let mut ledger = existing.unwrap_or_else(|| ExecutionLedger {
|
let mut ledger = existing.unwrap_or_else(|| ExecutionLedger {
|
||||||
schema_version: SCHEMA.into(),
|
schema_version: SCHEMA.into(),
|
||||||
client_turn_id: turn.into(),
|
client_turn_id: turn.into(),
|
||||||
@@ -546,6 +588,7 @@ pub(super) fn open_at(
|
|||||||
delivery_reviews: 0,
|
delivery_reviews: 0,
|
||||||
plan: None,
|
plan: None,
|
||||||
last_failed_write_revision: None,
|
last_failed_write_revision: None,
|
||||||
|
analytics_run,
|
||||||
});
|
});
|
||||||
if is_new {
|
if is_new {
|
||||||
// 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。
|
// 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。
|
||||||
@@ -558,6 +601,8 @@ pub(super) fn open_at(
|
|||||||
512 * 1024,
|
512 * 1024,
|
||||||
)?;
|
)?;
|
||||||
if let Some(legacy) = legacy {
|
if let Some(legacy) = legacy {
|
||||||
|
newly_accepted = false;
|
||||||
|
ledger.analytics_run = None;
|
||||||
let used = legacy["usedRuns"]
|
let used = legacy["usedRuns"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.and_then(|n| u32::try_from(n).ok());
|
.and_then(|n| u32::try_from(n).ok());
|
||||||
@@ -614,10 +659,13 @@ pub(super) fn open_at(
|
|||||||
let (changed, _) = tokio::sync::watch::channel(ledger.revision);
|
let (changed, _) = tokio::sync::watch::channel(ledger.revision);
|
||||||
let session = Arc::new(ExecutionSession {
|
let session = Arc::new(ExecutionSession {
|
||||||
root,
|
root,
|
||||||
|
newly_accepted,
|
||||||
state_path,
|
state_path,
|
||||||
_owner: owner,
|
_owner: owner,
|
||||||
data: Mutex::new(SessionData {
|
data: Mutex::new(SessionData {
|
||||||
ledger,
|
ledger,
|
||||||
|
analytics_capture: None,
|
||||||
|
analytics_output_revision: None,
|
||||||
started: Instant::now(),
|
started: Instant::now(),
|
||||||
initial_elapsed_ms,
|
initial_elapsed_ms,
|
||||||
lease_started: BTreeMap::new(),
|
lease_started: BTreeMap::new(),
|
||||||
@@ -738,6 +786,10 @@ impl ExecutionSession {
|
|||||||
pub(super) fn cancel_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
|
pub(super) fn cancel_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
|
||||||
Arc::clone(&self.cancellation)
|
Arc::clone(&self.cancellation)
|
||||||
}
|
}
|
||||||
|
pub(super) fn was_aborted(&self) -> bool {
|
||||||
|
self.abort_requested
|
||||||
|
.load(std::sync::atomic::Ordering::Acquire)
|
||||||
|
}
|
||||||
pub(super) fn record_delivery_review(&self) -> Result<u32, String> {
|
pub(super) fn record_delivery_review(&self) -> Result<u32, String> {
|
||||||
let mut data = self.lock()?;
|
let mut data = self.lock()?;
|
||||||
if data.ledger.phase.is_terminal() {
|
if data.ledger.phase.is_terminal() {
|
||||||
@@ -765,6 +817,74 @@ impl ExecutionSession {
|
|||||||
self.commit(&mut data, next)?;
|
self.commit(&mut data, next)?;
|
||||||
Ok(json!({"plan":plan,"revision":data.ledger.revision,"acceptancePassed":false}))
|
Ok(json!({"plan":plan,"revision":data.ledger.revision,"acceptancePassed":false}))
|
||||||
}
|
}
|
||||||
|
pub(super) fn set_analytics_capture(
|
||||||
|
&self,
|
||||||
|
capture: Option<(
|
||||||
|
crate::analytics::contract::Context,
|
||||||
|
crate::analytics::store::AnalyticsWriter,
|
||||||
|
)>,
|
||||||
|
) {
|
||||||
|
let Ok(mut data) = self.data.try_lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
data.analytics_capture = capture.and_then(|(mut context, writer)| {
|
||||||
|
// 恢复或账号切换后仍归属于真实受理的原 run。
|
||||||
|
context.route = data.ledger.analytics_run.as_ref()?.context.route.clone();
|
||||||
|
Some((context, writer))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn analytics_capture(
|
||||||
|
&self,
|
||||||
|
) -> Option<(
|
||||||
|
crate::analytics::contract::Context,
|
||||||
|
crate::analytics::store::AnalyticsWriter,
|
||||||
|
)> {
|
||||||
|
self.data.try_lock().ok()?.analytics_capture.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn record_analytics_revision(
|
||||||
|
&self,
|
||||||
|
revision: u64,
|
||||||
|
change_kind: crate::analytics::contract::ChangeKind,
|
||||||
|
files_changed_count: u64,
|
||||||
|
) {
|
||||||
|
use crate::analytics::contract::{RevisionCreated, RevisionSource, Source};
|
||||||
|
if files_changed_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(mut data) = self.data.try_lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if data.ledger.analytics_run.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.analytics_output_revision =
|
||||||
|
Some(data.analytics_output_revision.unwrap_or(0).max(revision));
|
||||||
|
let capture = data.analytics_capture.clone();
|
||||||
|
let project_id = data.ledger.project_id.clone();
|
||||||
|
drop(data);
|
||||||
|
crate::analytics::project::revision(
|
||||||
|
capture,
|
||||||
|
&project_id,
|
||||||
|
Source::Direct,
|
||||||
|
RevisionCreated {
|
||||||
|
revision_id: revision.to_string(),
|
||||||
|
revision_source: RevisionSource::Agent,
|
||||||
|
change_kind,
|
||||||
|
files_changed_count: Some(files_changed_count),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn analytics_output_revision(&self) -> Option<String> {
|
||||||
|
self.data
|
||||||
|
.try_lock()
|
||||||
|
.ok()?
|
||||||
|
.analytics_output_revision
|
||||||
|
.map(|revision| revision.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn snapshot(&self) -> Result<ExecutionLedger, String> {
|
pub(super) fn snapshot(&self) -> Result<ExecutionLedger, String> {
|
||||||
let data = self.lock()?;
|
let data = self.lock()?;
|
||||||
let mut state = data.ledger.clone();
|
let mut state = data.ledger.clone();
|
||||||
|
|||||||
@@ -1,5 +1,74 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn analytics_metadata(user: &str) -> crate::analytics::run::Metadata {
|
||||||
|
use crate::analytics::contract::{Context, Route, RunSource, Source};
|
||||||
|
crate::analytics::run::Metadata::new(
|
||||||
|
Context {
|
||||||
|
route: Route::from_identity(Some(user.into()), Some("https://example.com")),
|
||||||
|
editor_session_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
client_version: "1.0.0".into(),
|
||||||
|
},
|
||||||
|
Source::Direct,
|
||||||
|
RunSource::UserSubmit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_metadata_is_persisted_with_new_ledger_and_replay_keeps_original_identity() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("project");
|
||||||
|
crate::init_local_game_project_at(&root, "analytics-run", "执行身份").unwrap();
|
||||||
|
let original = analytics_metadata("A");
|
||||||
|
let host = temp.path().join("host");
|
||||||
|
let session = open_with_analytics_at(
|
||||||
|
&host,
|
||||||
|
&root,
|
||||||
|
"turn",
|
||||||
|
&hash(b"request"),
|
||||||
|
false,
|
||||||
|
&Default::default(),
|
||||||
|
Some(original.clone()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(session.newly_accepted);
|
||||||
|
assert_eq!(
|
||||||
|
session.snapshot().unwrap().analytics_run,
|
||||||
|
Some(original.clone())
|
||||||
|
);
|
||||||
|
drop(session);
|
||||||
|
let replay = open_with_analytics_at(
|
||||||
|
&host,
|
||||||
|
&root,
|
||||||
|
"turn",
|
||||||
|
&hash(b"request"),
|
||||||
|
false,
|
||||||
|
&Default::default(),
|
||||||
|
Some(analytics_metadata("B")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!replay.newly_accepted);
|
||||||
|
assert_eq!(replay.snapshot().unwrap().analytics_run, Some(original));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_run_without_metadata_is_not_assigned_current_users_identity() {
|
||||||
|
let (temp, session) = fixture(Default::default());
|
||||||
|
let root = session.root.clone();
|
||||||
|
assert!(session.snapshot().unwrap().analytics_run.is_none());
|
||||||
|
drop(session);
|
||||||
|
let replay = open_with_analytics_at(
|
||||||
|
&temp.path().join("host"),
|
||||||
|
&root,
|
||||||
|
"turn-test",
|
||||||
|
&hash(b"request"),
|
||||||
|
false,
|
||||||
|
&Default::default(),
|
||||||
|
Some(analytics_metadata("B")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(replay.snapshot().unwrap().analytics_run.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionSession>) {
|
fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionSession>) {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let root = temp.path().join("project");
|
let root = temp.path().join("project");
|
||||||
@@ -13,6 +82,7 @@ fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionS
|
|||||||
&config,
|
&config,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(session.newly_accepted);
|
||||||
session
|
session
|
||||||
.freeze_contract(json!({"requirements":[{"id":"test"}]}))
|
.freeze_contract(json!({"requirements":[{"id":"test"}]}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -159,6 +229,7 @@ fn reopened_budget_and_deadline_cannot_be_increased_by_configuration() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let state = reopened.snapshot().unwrap();
|
let state = reopened.snapshot().unwrap();
|
||||||
|
assert!(!reopened.newly_accepted);
|
||||||
assert_eq!(state.delivery_reviews, 1);
|
assert_eq!(state.delivery_reviews, 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(
|
(
|
||||||
@@ -278,6 +349,7 @@ fn legacy_budget_is_inherited_without_trusting_project_success_evidence() {
|
|||||||
&Default::default(),
|
&Default::default(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(!session.newly_accepted);
|
||||||
assert!(session.admit(EffectKind::Execute, None).is_err());
|
assert!(session.admit(EffectKind::Execute, None).is_err());
|
||||||
let state = session.snapshot().unwrap();
|
let state = session.snapshot().unwrap();
|
||||||
assert_eq!(state.used_passes, 2);
|
assert_eq!(state.used_passes, 2);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user