修复素材画布保存区与项目状态展示
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 7s
Project CI / Frontend tests (pull_request) Failing after 5m5s
Project CI / Native shell tests (pull_request) Failing after 6m16s

调整素材保存区响应式布局,固定资源用途并保留精修继承语义。
收口普通用户项目路径展示,补充画布与工作台回归测试。
同步更新技术方案、产品需求文档与项目长期记忆。
This commit is contained in:
2026-08-06 15:14:43 +08:00
parent 0c0e7f6382
commit ad7b8095cd
23 changed files with 327 additions and 81 deletions
@@ -1212,6 +1212,18 @@ export function projectNameFromPath(projectPath: string) {
);
}
export function projectWorkspaceStatusForDisplay(workspaceStatus: string) {
const openedPrefix = '已打开:';
if (!workspaceStatus.startsWith(openedPrefix)) {
return workspaceStatus;
}
const projectPath = workspaceStatus.slice(openedPrefix.length).trim();
if (!/[\\/]/u.test(projectPath)) {
return workspaceStatus;
}
return `${openedPrefix}${projectNameFromPath(projectPath)}`;
}
export function mergeProjectSupervisorConversation(
projectRecords: LocalConversationMessageRecord[],
supervisorRecords: LocalConversationMessageRecord[],
@@ -106,6 +106,13 @@ export type AssetCanvasCommitNotification = {
eventId?: string;
};
export const ASSET_CANVAS_KIND_OPTIONS = [
{ value: 'game-art', label: '普通游戏美术' },
{ value: 'icon-spec', label: '统一视觉规范' },
{ value: 'ui-prototype', label: '游戏界面原型' },
{ value: 'art-spritesheet', label: '核心美术图集' },
] as const;
type RuntimeCanvasLayer = CanvasLayer & { mediaRef: ImageCanvasMediaRef };
type PendingGenerationIdentity = {
@@ -303,6 +310,8 @@ export function AssetCanvasSurface({
scope,
sessionId,
expectedHostRevision,
initialAssetName = '画布素材',
initialAssetKind = 'game-art',
onCancel,
onCommitted,
onSaveAttempt,
@@ -312,11 +321,15 @@ export function AssetCanvasSurface({
scope: ImageCanvasHostScope;
sessionId: string;
expectedHostRevision: string;
initialAssetName?: string;
initialAssetKind?: string;
onCancel?: () => void;
onCommitted?: (input: AssetCanvasCommitNotification) => void;
onSaveAttempt?: (input: AssetCanvasSaveAttempt) => void;
renderImage?: RenderAssetCanvasImage;
}) {
const resolvedInitialAssetName = initialAssetName.trim() || '画布素材';
const resolvedInitialAssetKind = initialAssetKind.trim() || 'game-art';
const stableScope = useMemo<ImageCanvasHostScope>(
() => ({
projectId: scope.projectId,
@@ -339,8 +352,8 @@ export function AssetCanvasSurface({
const [backgroundColor, setBackgroundColor] = useState('#f4f4f5');
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [notice, setNotice] = useState('');
const [assetName, setAssetName] = useState('画布素材');
const [assetKind, setAssetKind] = useState('asset');
const [assetName, setAssetName] = useState(resolvedInitialAssetName);
const [assetKind, setAssetKind] = useState(resolvedInitialAssetKind);
const [exportMediaType, setExportMediaType] = useState<
'image/png' | 'image/jpeg' | 'image/webp'
>('image/png');
@@ -385,6 +398,12 @@ export function AssetCanvasSurface({
const onWalletBalanceMayHaveChanged = useWalletStore(
(state) => state.onWalletBalanceMayHaveChanged,
);
const assetSettingsDisabled =
lifecycle.kind === 'canvas.saving' ||
lifecycle.kind === 'canvas.generating';
const inheritedUnknownAssetKind =
stableScope.intent === 'refine' &&
!ASSET_CANVAS_KIND_OPTIONS.some((option) => option.value === assetKind);
layersRef.current = layers;
viewportRef.current = viewport;
@@ -1425,33 +1444,56 @@ export function AssetCanvasSurface({
className="asset-canvas-surface__savebar"
aria-label="素材保存设置"
>
<input
aria-label="素材名称"
value={assetName}
maxLength={80}
onChange={(event) => setAssetName(event.target.value)}
disabled={lifecycle.kind === 'canvas.generating'}
/>
<input
aria-label="素材类型"
value={assetKind}
maxLength={64}
onChange={(event) => setAssetKind(event.target.value)}
disabled={lifecycle.kind === 'canvas.generating'}
/>
<select
aria-label="导出格式"
value={exportMediaType}
onChange={(event) =>
setExportMediaType(
event.target.value as 'image/png' | 'image/jpeg' | 'image/webp',
)
}
>
<option value="image/png">PNG</option>
<option value="image/jpeg">JPEG</option>
<option value="image/webp">WebP</option>
</select>
<label className="asset-canvas-surface__save-field">
<span></span>
<input
aria-label="素材名称"
value={assetName}
maxLength={80}
onChange={(event) => setAssetName(event.target.value)}
disabled={assetSettingsDisabled}
/>
</label>
<label className="asset-canvas-surface__save-field">
<span></span>
<select
aria-label="资源用途"
value={assetKind}
onChange={(event) => setAssetKind(event.target.value)}
disabled={
assetSettingsDisabled || stableScope.intent === 'refine'
}
>
{inheritedUnknownAssetKind ? (
<option value={assetKind}></option>
) : null}
{ASSET_CANVAS_KIND_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="asset-canvas-surface__save-field">
<span></span>
<select
aria-label="导出格式"
value={exportMediaType}
disabled={assetSettingsDisabled}
onChange={(event) =>
setExportMediaType(
event.target.value as
| 'image/png'
| 'image/jpeg'
| 'image/webp',
)
}
>
<option value="image/png">PNG</option>
<option value="image/jpeg">JPEG</option>
<option value="image/webp">WebP</option>
</select>
</label>
<CanvasChromeButton
label="保存到项目"
icon={<Save size={15} aria-hidden="true" />}
@@ -1459,7 +1501,8 @@ export function AssetCanvasSurface({
onClick={() => void saveAsset()}
disabled={
lifecycle.kind === 'canvas.saving' ||
lifecycle.kind === 'canvas.generating'
lifecycle.kind === 'canvas.generating' ||
assetName.trim().length === 0
}
>
@@ -11,6 +11,7 @@
width: 100%;
height: 100%;
min-height: 0;
container-type: inline-size;
overflow: hidden;
border-radius: inherit;
background: var(--platform-overlay-fill);
@@ -24,6 +25,8 @@
.asset-canvas-surface__toolbar-shell {
z-index: 4;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto;
min-width: 0;
gap: 8px;
padding: 8px 10px 9px;
@@ -40,16 +43,33 @@
.asset-canvas-surface__savebar {
display: grid;
grid-template-columns:
minmax(108px, 1.35fr) minmax(88px, 0.8fr) minmax(70px, auto)
auto;
minmax(0, 1.35fr) minmax(0, 1fr) minmax(86px, 0.55fr)
max-content;
min-width: 0;
align-items: center;
align-items: end;
gap: 7px;
}
.asset-canvas-surface__save-field {
display: grid;
min-width: 0;
gap: 3px;
}
.asset-canvas-surface__save-field > span {
overflow: hidden;
color: var(--platform-text-muted);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-canvas-surface__savebar input,
.asset-canvas-surface__savebar select,
.asset-canvas-surface__generation-form select {
width: 100%;
min-width: 0;
min-height: 34px;
border: 1px solid var(--platform-surface-border);
@@ -87,6 +107,13 @@
box-shadow: var(--platform-nav-active-shadow);
}
.asset-canvas-surface .asset-canvas-surface__save {
min-width: max-content;
min-height: 34px;
align-self: end;
white-space: nowrap;
}
.asset-canvas-surface
.asset-canvas-surface__primary-action:not(:disabled):hover,
.asset-canvas-surface .asset-canvas-surface__save:not(:disabled):hover {
@@ -367,7 +394,11 @@
}
.asset-canvas-surface__savebar {
grid-template-columns: minmax(96px, 1fr) minmax(72px, 0.75fr) 70px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.asset-canvas-surface__save-field:first-child {
grid-column: 1 / -1;
}
.asset-canvas-surface__save {
@@ -395,3 +426,15 @@
padding: 18px;
}
}
@container (max-width: 620px) {
.asset-canvas-surface__savebar {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.asset-canvas-surface__save-field:first-child,
.asset-canvas-surface__save {
grid-column: 1 / -1;
width: 100%;
}
}
@@ -12,7 +12,10 @@ import type {
ChatMessage,
PendingUiConfirmation,
} from '../../app/types';
import { ProjectSupervisorRuntimePanel } from '../agent-runtime';
import {
ProjectSupervisorRuntimePanel,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
import { taskStatusLabels } from '../project-summary/projectSummary';
@@ -127,7 +130,7 @@ export function ProjectSupervisorView({
</button>
</form>
<small className="project-supervisor-workspace-status">
{workspaceStatus}
{projectWorkspaceStatusForDisplay(workspaceStatus)}
</small>
</div>
<aside
@@ -32,6 +32,7 @@ import {
formatAgentRuntimeTaskQueue,
ProjectSupervisorRuntimePanel,
projectSupervisorVisibleConversationText,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import {
formatAgentCardLlmStatus,
@@ -283,15 +284,12 @@ export function ProjectWorkspaceChatPane({
<div className="chat-header-title">
<h1>AI </h1>
<span>{devMode ? '开发模式' : currentProjectTitle}</span>
{!devMode && localProject ? (
<small>{localProject.projectPath}</small>
) : null}
<small>run: {agentRunStatus}</small>
<small>preview: {previewStatus}</small>
{mainProjectSummary ? (
<small aria-label="项目摘要">{mainProjectSummary}</small>
) : null}
<small>{workspaceStatus}</small>
<small>{projectWorkspaceStatusForDisplay(workspaceStatus)}</small>
</div>
<div className="chat-header-actions">
<button type="button" onClick={showChatHelp}>
@@ -35,6 +35,7 @@ import {
projectSupervisorCollaboratingAgentRuntimes,
ProjectSupervisorRuntimeControls,
projectSupervisorVisibleConversationText,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { GameChatImageViewer } from './GameChatImageViewer';
@@ -891,7 +892,7 @@ export function SupervisorChatOnlyView({
? '已投递,正在同步 Agent Runner'
: runtime
? projectSupervisorChatRuntimeStatus(runtime)
: workspaceStatus);
: projectWorkspaceStatusForDisplay(workspaceStatus));
const headerStatus = gameChatMode ? status : previewStatus || status;
const runtimeEvents = useMemo(
() => collectGameChatRuntimeEvents(runtime, runtimeByAgentId),
@@ -90,6 +90,8 @@ type AssetCanvasRoute = {
flowId: string;
sessionId: string;
expectedHostRevision: string;
initialAssetName: string;
initialAssetKind: string;
scope: ImageCanvasHostScope;
host: TauriImageCanvasHostAdapter;
};
@@ -239,6 +241,11 @@ function isRasterImageResource(resource: ProjectResource) {
);
}
function assetNameFromLocalPath(localPath: string) {
const fileName = localPath.split(/[\\/]/u).filter(Boolean).pop() ?? '';
return fileName.replace(/\.[^.]+$/u, '').trim() || '';
}
function isExtendedArtMediaResource(resource: ProjectResource) {
const mediaType = resource.mediaType.toLowerCase();
return (
@@ -1173,6 +1180,9 @@ export default function ProjectDevelopmentView({
pendingResourceFocusRef.current = null;
setHiddenCommittedResourceId(null);
setAssetCanvasNotice('正在打开素材创作画布…');
const sourceAsset = sourceAssetId
? manifest.assets.find((asset) => asset.id === sourceAssetId)
: null;
void invoke<{ revision: number }>('get_local_game_project_revision', {
projectPath,
})
@@ -1197,6 +1207,14 @@ export default function ProjectDevelopmentView({
flowId,
sessionId,
expectedHostRevision: String(status.revision),
initialAssetName:
intent === 'refine' && sourceAsset
? assetNameFromLocalPath(sourceAsset.localPath)
: '画布素材',
initialAssetKind:
intent === 'refine' && sourceAsset
? sourceAsset.kind
: 'game-art',
scope,
host: createTauriImageCanvasHostAdapter({
projectPath,
@@ -1215,7 +1233,7 @@ export default function ProjectDevelopmentView({
}
});
},
[advanceFocusGeneration, manifest.projectId, projectPath],
[advanceFocusGeneration, manifest.assets, manifest.projectId, projectPath],
);
const cancelAssetCanvas = useCallback(() => {
@@ -1557,6 +1575,8 @@ export default function ProjectDevelopmentView({
scope={assetCanvasRoute.scope}
sessionId={assetCanvasRoute.sessionId}
expectedHostRevision={assetCanvasRoute.expectedHostRevision}
initialAssetName={assetCanvasRoute.initialAssetName}
initialAssetKind={assetCanvasRoute.initialAssetKind}
onCancel={cancelAssetCanvas}
onSaveAttempt={handleAssetCanvasSaveAttempt}
onCommitted={handleAssetCommitted}
@@ -15,9 +15,24 @@ import {
projectSupervisorChatRuntimeStatus,
projectSupervisorResponseStreamIdentity,
projectSupervisorVisibleConversationText,
projectWorkspaceStatusForDisplay,
submitProjectSupervisorRuntimeTask,
} from '../src/features/agent-runtime/model';
describe('普通用户工作区状态', () => {
test('用项目名称替代 Unix 和 Windows 绝对路径', () => {
expect(projectWorkspaceStatusForDisplay('已打开:/tmp/authorized-game')).toBe(
'已打开:authorized-game',
);
expect(
projectWorkspaceStatusForDisplay('已打开:C:\\projects\\canvas-game'),
).toBe('已打开:canvas-game');
expect(projectWorkspaceStatusForDisplay('正在读取项目')).toBe(
'正在读取项目',
);
});
});
describe('Game Chat stream identity and source', () => {
test('response stream identity binds run, slot, and revision', () => {
const base: AgentRuntimeResponseStream = {
@@ -1825,7 +1825,7 @@ export function registerAgentRuntimeCommandTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
submitChat('/memory typo');
@@ -5475,7 +5475,7 @@ export function registerDeveloperToolsTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
submitChat('/audit');
@@ -5603,7 +5603,7 @@ export function registerDeveloperToolsTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
invoke.mockClear();
@@ -5686,7 +5686,7 @@ export function registerDeveloperToolsTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
invoke.mockClear();
@@ -5800,7 +5800,7 @@ export function registerDeveloperToolsTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
invoke.mockClear();
@@ -5898,7 +5898,7 @@ export function registerDeveloperToolsTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
submitChat('/audit');
@@ -5960,7 +5960,7 @@ export function registerDeveloperToolsTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
submitChat('/audit');
@@ -6011,7 +6011,7 @@ export function registerDeveloperToolsTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
submitChat('/audit');
@@ -92,6 +92,7 @@ import {
GameChatReleaseApp,
WorkspaceLauncher,
} from '../../src/App';
import { projectNameFromPath } from '../../src/features/agent-runtime/model';
import ProjectDevelopmentView from '../../src/view/project-development';
const testAuthUser: AuthUser = {
@@ -714,7 +715,9 @@ function createProjectSupervisorRuntimeHarness({
async function openMainProject(projectPath: string) {
submitChat(`/project ${projectPath}`);
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(await screen.findByText(`已打开:${projectPath}`)).not.toBeNull();
expect(
await screen.findByText(`已打开:${projectNameFromPath(projectPath)}`),
).not.toBeNull();
}
beforeEach(() => {
@@ -1192,7 +1192,7 @@ export function registerCanvasAssetTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
submitChat(
@@ -1717,7 +1717,7 @@ export function registerCanvasAssetTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
expect(await screen.findByText(/还没有最近一次 Agent run/)).not.toBeNull();
@@ -1821,7 +1821,7 @@ export function registerCanvasAssetTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
submitChat('/canvas bad\u0007canvas');
@@ -2003,7 +2003,7 @@ export function registerCanvasAssetTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
submitChat('/sync-canvas-project canvas-project-1');
@@ -2090,7 +2090,7 @@ export function registerCanvasAssetTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
submitChat('/generate-art 月光厨房弹幕主角');
@@ -241,7 +241,7 @@ export function registerProjectCommandTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
submitChat('/audit');
@@ -2202,7 +2202,7 @@ export function registerProjectCommandTests() {
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull();
expect(screen.getByText('已打开:authorized-game')).not.toBeNull();
});
fireEvent.change(screen.getByLabelText('本地项目目录'), {
@@ -2771,7 +2771,7 @@ export function registerProjectCommandTests() {
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull();
expect(screen.getByText('已打开:authorized-game')).not.toBeNull();
});
fireEvent.change(screen.getByLabelText('本地项目目录'), {
@@ -3245,7 +3245,7 @@ export function registerProjectCommandTests() {
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull();
expect(screen.getByText('已打开:authorized-game')).not.toBeNull();
});
fireEvent.change(screen.getByLabelText('本地项目目录'), {
@@ -3333,7 +3333,7 @@ export function registerProjectCommandTests() {
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull();
expect(screen.getByText('已打开:authorized-game')).not.toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: '静态入口自检' }));
@@ -3448,7 +3448,7 @@ export function registerProjectCommandTests() {
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
fireEvent.click(logPanel.getByRole('button', { name: '命令日志' }));
@@ -416,7 +416,7 @@ export function registerProjectConversationTests() {
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', {
@@ -509,7 +509,7 @@ export function registerProjectConversationTests() {
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(await screen.findByText('受保护历史需求')).not.toBeNull();
expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull();
expect(screen.getByText('已打开:authorized-game')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('read_local_conversation', {
projectPath: '/tmp/authorized-game',
agentId: null,
@@ -787,7 +787,7 @@ export function registerProjectConversationTests() {
expect(
screen.queryByText('项目对话保存失败:conversation append failed once'),
).toBeNull();
expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull();
expect(screen.getByText('已打开:authorized-game')).not.toBeNull();
});
it('confirms before saving slash conversation when policy requires it', async () => {
@@ -955,7 +955,7 @@ export function registerProjectConversationTests() {
await waitFor(() => {
expect(screen.queryByText('conversation.write')).toBeNull();
});
expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull();
expect(screen.getByText('已打开:authorized-game')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'append_local_conversation_message',
expect.anything(),
@@ -1233,7 +1233,7 @@ export function registerProjectConversationTests() {
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已打开:/tmp/authorized-game'),
await screen.findByText('已打开:authorized-game'),
).not.toBeNull();
expect(await screen.findByText('正在拆解创作方向')).not.toBeNull();
@@ -1714,7 +1714,7 @@ export function registerProjectConversationTests() {
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('已打开:/tmp/authorized-game');
await screen.findByText('已打开:authorized-game');
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
const dialog = await screen.findByLabelText('Agent 对话');
expect(
@@ -2109,7 +2109,7 @@ export function registerProjectConversationTests() {
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('已打开:/tmp/authorized-game');
await screen.findByText('已打开:authorized-game');
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
const input = await screen.findByLabelText('Agent 对话内容');
invoke.mockClear();
@@ -2212,7 +2212,8 @@ export function registerProjectConversationTests() {
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('/tmp/authorized-game');
await screen.findByText('已打开:authorized-game');
expect(screen.queryByText('/tmp/authorized-game')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
const agentDialog = await screen.findByLabelText('Agent 对话');
@@ -3126,7 +3127,7 @@ export function registerProjectConversationTests() {
fireEvent.click(
within(createA as HTMLElement).getByRole('button', { name: '确认' }),
);
expect(await screen.findByText('已打开:/tmp/project-a')).not.toBeNull();
expect(await screen.findByText('已打开:project-a')).not.toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'resume_game_creator_agent_runtime_tasks',
@@ -3144,7 +3145,7 @@ export function registerProjectConversationTests() {
fireEvent.click(
within(createB as HTMLElement).getByRole('button', { name: '确认' }),
);
expect(await screen.findByText('已打开:/tmp/project-b')).not.toBeNull();
expect(await screen.findByText('已打开:project-b')).not.toBeNull();
await act(async () => {
resolveProjectAResume?.([]);
@@ -3160,6 +3161,6 @@ export function registerProjectConversationTests() {
'read_game_creator_agent_runtimes',
{ projectPath: '/tmp/project-a' },
);
expect(screen.getByText('已打开:/tmp/project-b')).not.toBeNull();
expect(screen.getByText('已打开:project-b')).not.toBeNull();
});
}
@@ -8063,7 +8063,7 @@ export function registerProjectWorkbenchNavigationTests() {
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('已打开:/tmp/authorized-game');
await screen.findByText('已打开:authorized-game');
const revealButtons = screen.getAllByRole('button', { name: '显示目录' });
fireEvent.click(revealButtons[revealButtons.length - 1]);
@@ -8143,7 +8143,7 @@ export function registerProjectWorkbenchNavigationTests() {
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
});
await screen.findByText('已打开:/tmp/authorized-game');
await screen.findByText('已打开:authorized-game');
invoke.mockClear();
fireEvent.click(screen.getByRole('button', { name: '文件' }));
@@ -9086,7 +9086,7 @@ export function registerProjectAgentStatusTests() {
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('已打开:/tmp/authorized-game');
await screen.findByText('已打开:authorized-game');
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
expect(await screen.findByLabelText('Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
@@ -4,9 +4,9 @@ import type { PreviewShortcutInvoke } from './invoke-mock';
export async function assertProjectAndDesignShortcutFlow(
invoke: PreviewShortcutInvoke,
) {
await screen.findByText('已打开:/tmp/authorized-game');
await screen.findByText('已打开:authorized-game');
expect(screen.getByText('未命名游戏原型')).not.toBeNull();
expect(screen.getByText('/tmp/authorized-game')).not.toBeNull();
expect(screen.queryByText('/tmp/authorized-game')).toBeNull();
expect(screen.getByText('preview: 未启动')).not.toBeNull();
const projectSummary = screen.getByLabelText('项目摘要');
expect(projectSummary.textContent).toContain('任务:已完成 0/');
@@ -132,7 +132,12 @@ function memoryHost(input?: {
let hostRevision = 0;
let eventListener: ((event: LocalAssetCommittedEvent) => void) | null = null;
const updates: ImageCanvasDraftCanvas[] = [];
const commits: Array<{ commitId: string; idempotencyKey: string }> = [];
const commits: Array<{
commitId: string;
idempotencyKey: string;
assetName: string;
assetKind: string;
}> = [];
const generationCalls: Array<{
intentId: string;
generationId: string;
@@ -317,6 +322,8 @@ function memoryHost(input?: {
commits.push({
commitId: commitInput.commitId,
idempotencyKey: commitInput.idempotencyKey,
assetName: commitInput.name,
assetKind: commitInput.assetKind,
});
await input?.commitGate?.promise;
hostRevision += 1;
@@ -372,6 +379,7 @@ function renderSurface(
onCommitted = vi.fn(),
onSaveAttempt = vi.fn(),
onCancel = vi.fn(),
initialAsset?: { name: string; kind: string },
) {
return {
onCommitted,
@@ -384,6 +392,8 @@ function renderSurface(
scope={canvasScope}
sessionId="22222222-2222-4222-8222-222222222222"
expectedHostRevision="0"
initialAssetName={initialAsset?.name}
initialAssetKind={initialAsset?.kind}
onCancel={onCancel}
onCommitted={onCommitted}
onSaveAttempt={onSaveAttempt}
@@ -463,6 +473,78 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(screen.getByRole('button', { name: '取消' })).toBeTruthy();
});
it('保留可编辑名称,并把新建资源用途限制为中文固定选项', async () => {
const memory = memoryHost();
renderSurface(memory.host);
await screen.findByText('画布可编辑');
const assetName = screen.getByRole('textbox', { name: '素材名称' });
const assetKind = screen.getByRole('combobox', { name: '资源用途' });
expect((assetName as HTMLInputElement).value).toBe('画布素材');
expect((assetKind as HTMLSelectElement).value).toBe('game-art');
expect((assetKind as HTMLSelectElement).disabled).toBe(false);
expect(
Array.from((assetKind as HTMLSelectElement).options).map(
(option) => option.text,
),
).toEqual([
'普通游戏美术',
'统一视觉规范',
'游戏界面原型',
'核心美术图集',
]);
fireEvent.change(assetName, { target: { value: '主界面草图' } });
fireEvent.change(assetKind, { target: { value: 'ui-prototype' } });
fireEvent.click(screen.getByRole('button', { name: '保存到项目' }));
await waitFor(() => expect(memory.commits).toHaveLength(1));
expect(memory.commits[0]).toEqual(
expect.objectContaining({
assetName: '主界面草图',
assetKind: 'ui-prototype',
}),
);
});
it('精修时继承源资源名称和用途,并锁定机器语义类型', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const memory = memoryHost({
initialDraft: draftFixture(refineScope),
});
renderSurface(
memory.host,
refineScope,
vi.fn(),
vi.fn(),
vi.fn(),
{ name: '角色立绘', kind: 'illustration' },
);
await screen.findByText('画布可编辑');
const assetName = screen.getByRole('textbox', { name: '素材名称' });
const assetKind = screen.getByRole('combobox', { name: '资源用途' });
expect((assetName as HTMLInputElement).value).toBe('角色立绘');
expect((assetName as HTMLInputElement).disabled).toBe(false);
expect((assetKind as HTMLSelectElement).value).toBe('illustration');
expect((assetKind as HTMLSelectElement).disabled).toBe(true);
expect(screen.getByRole('option', { name: '原资源用途' })).toBeTruthy();
fireEvent.change(assetName, { target: { value: '角色立绘精修' } });
fireEvent.click(screen.getByRole('button', { name: '保存到项目' }));
await waitFor(() => expect(memory.commits).toHaveLength(1));
expect(memory.commits[0]).toEqual(
expect.objectContaining({
assetName: '角色立绘精修',
assetKind: 'illustration',
}),
);
});
it('让生成、失败、保存与取消区域使用内部滚动而不被固定高度裁剪', () => {
const css = readFileSync(
resolve(
@@ -487,9 +569,18 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(css).toMatch(
/\.asset-canvas-surface__savebar\s*\{[^}]*grid-template-columns:/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__toolbar-shell\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)[^}]*grid-template-rows:\s*auto auto/s,
);
expect(css).toMatch(
/\.asset-canvas-surface \.asset-canvas-surface__save\s*\{[^}]*min-width:\s*max-content[^}]*white-space:\s*nowrap/s,
);
expect(css).toMatch(
/@media \(max-width: 760px\)[\s\S]*?\.asset-canvas-surface__save\s*\{[^}]*grid-column:\s*1 \/ -1[^}]*width:\s*100%/s,
);
expect(css).toMatch(
/@container \(max-width: 620px\)[\s\S]*?\.asset-canvas-surface__save\s*\{[^}]*grid-column:\s*1 \/ -1[^}]*width:\s*100%/s,
);
});
it('取消时先把草稿标记为 cancelled,再返回资源总览', async () => {
@@ -21,6 +21,8 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
sourceAssetId: string | null;
};
sessionId: string;
initialAssetName?: string;
initialAssetKind?: string;
onCancel?: () => void;
onSaveAttempt?: (attempt: {
saveAttemptId: string;
@@ -37,7 +39,7 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
ReactModule.createElement(
'span',
null,
`${props.scope.intent}:${props.scope.sourceAssetId ?? 'none'}`,
`${props.scope.intent}:${props.scope.sourceAssetId ?? 'none'}:${props.initialAssetName ?? 'unset'}:${props.initialAssetKind ?? 'unset'}`,
),
ReactModule.createElement(
'button',
@@ -275,7 +277,7 @@ describe('project resource live canvas integration', () => {
fireEvent.click(screen.getByRole('button', { name: '精修资源' }));
expect(
(await screen.findByLabelText('测试素材创作画布')).textContent,
).toContain('refine:source-art');
).toContain('refine:source-art:source-art:art-image');
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
expect(
await screen.findByRole('region', { name: /source-art\.png/ }),
@@ -94,6 +94,7 @@
- 客户端 UI 对齐采用“同视觉、同组件、保留四区布局”,不复制主站整页布局。资源总览、运行视图和 Supervisor 对话继续是 Game Agent 工作台独有语义。
- 客户端正式产品仍只按 `1280×800` 横屏合同交付;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。
- 普通用户界面不默认展示内部错误码、开发者 API 配置或本机绝对路径;确需诊断的信息进入受控详情或开发模式,不与主要动作并列。
- 素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择;精修资源继承源用途且不可改。导出格式继续限定 PNG/JPEG/WebP。工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。
## 4. 工作台状态机
@@ -6,6 +6,8 @@
- 决策:保留 Game Agent 左导航、中央主视窗、右侧 Supervisor 和底部 Agent Dock 四区布局;平台颜色、边框、文本和 surface 继续以 `packages/shared/src/theme.css` 为事实源,画布动作按钮、工具栏、分组与分隔符进入现有 `@genarrative/image-canvas-react`。主站和 Tauri 均直接消费同一 chrome,Tauri 宿主只保留业务接线和布局适配。
- 依赖边界:共享组件只接受 React props、图标节点、短文案和事件,不读取账号、钱包、项目、生成、Tauri 或 HTTP。主站 `EditorIconButton` 只保留 Lucide/特殊浮层薄适配,主站业务 CSS 只保留定位和专属状态;Tauri 禁止导入网站完整 CSS。内部错误码、开发者 API 配置和本机绝对路径不作为普通用户默认视觉内容。
- 分期:阶段一、二更新权威合同、抽取共享 chrome 并让主站消费;阶段三、四已统一中央素材画布、Supervisor 与 Agent Dock;阶段五在同一变更中补齐测试、真实视口测量和发现回归修复。
- 保存字段:`name` 属于用户输出命名,继续可编辑;`assetKind` 属于机器 subtypecreate 只允许 Runtime 权威目录中的 `game-art / icon-spec / ui-prototype / art-spritesheet` 并显示中文用途,refine 继承源 kind 且锁定。未知历史 kind 只按原值保存,不在精修中迁移或改写。工具与保存设置显式分为两行,主保存动作不可压缩。
- 路径展示:业务内部继续持有和校验绝对项目路径,普通工作区状态只投影项目名称;显式目录选择、非空目录确认和开发诊断仍可按权限边界使用真实路径。该变化不修改项目身份或 Tauri command 参数。
- 验证:共享 chrome 与 Tauri Surface 定向测试 `20/20` 通过,覆盖可访问性、pressed/expanded/disabled、工具组/分隔符、生成/失败/保存/取消和非裁剪 CSS;AppSurface 横屏合同 `1/1` 通过,锁定 Supervisor composer、Agent Dock 与 `100dvh` 两行工作台。客户端/全仓 typecheck、定向 ESLint、编码和 diff 检查通过。完整 AppSurface 本次运行是 `352/354` 通过:preview 权限用例单跑通过,属于全量异步顺序波动;仍失败的“画板 API Key”运行配置用例不涉及本次 diff,留在原业务范围处理。应用内浏览器 `1280×800` 实测 window/document/body client 与 scroll 均为 `1280×800`;真实入口没有登录会话时保持登录门禁,不增加测试绕过。
- 关联:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md``packages/image-canvas-react/`
@@ -38,6 +38,14 @@
- 验证:AppSurface CSS 合同检查 `100dvh`、Supervisor composer、Runtime 内滚动、Dock 常驻/可收缩;素材画布测试检查生成 dialog、operation card、状态栏和窄屏保存动作不会被裁剪。真实入口在 `1280×800` 测量 document/body client 与 scroll 一致;登录门禁不可为视觉测试绕过。
- 关联:`apps/ai-game-creator-shell/src/styles.css``apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css``apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts``apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx`
## 素材保存区不要把机器 subtype 当普通文本框(2026-08-06
- 现象:保存区同时显示“画布素材”和裸 `asset` 文本框,普通用户无法判断两者用途;自由修改 kind 会形成无法稳定参与类型布局、Agent 合同和替换兼容性的 subtype。工具栏与保存设置挤在同一行时,主要保存按钮还会被压缩或裁切。
- 原因:把 Host Port 的 `name / assetKind / mediaType` DTO 直接映射成同层输入控件,没有区分用户命名、机器分类和编码格式,也没有为中央区域的真实容器宽度保留主操作列。
- 处理:名称保留编辑;create kind 使用 Runtime 权威四项目录和中文标签,refine 从源 manifest 继承并锁定,未知历史值只透传;格式继续使用有限枚举。工具动作和保存设置显式上下分行,保存列使用 `max-content + nowrap`,窄容器时按钮独占整行。普通工作区状态只显示项目名称,不把绝对路径作为默认辅助文案。
- 验证:Surface 测试断言四项用途、默认值、精修未知 kind 锁定、最终 commit 参数和保存按钮 CSSAppSurface 断言普通界面找不到绝对路径,内部 Tauri 调用仍使用原完整路径。
- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx``apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css``apps/ai-game-creator-shell/src/features/project-workspace/`
## 正式素材提交不能把多文件写入或 Tauri 事件误当成一次原子动作
- 现象:图片已经落到 `assets/` 但 manifest 没有资产,或 manifest 已追加而 project revision/草稿仍是旧值;进程在 emit 前后退出后,用户重试又得到第二份图片、第二个 asset 或重复选中。
@@ -968,3 +968,4 @@ game-project/
- 工作台仍是左导航、中央主视窗、右 Project Supervisor、底部 Agent Dock 四区。中央区使用主面板层级;Supervisor 的消息/Runtime 独立滚动且 composer 常驻;Dock 改为较轻的持续状态 surface 与可收缩卡片。桌面网格按 `100dvh` 将 Dock 固定在第二行,`≤760px` 只保留开发兼容堆叠。
- AppSurface/CSS 合同锁定 `1280×800` 下的工作台无页面级溢出、Supervisor composer、主要保存动作和 Dock 可见;共享/Tauri 定向用例锁定按钮名称、工具组/分隔符、pressed/expanded/disabled 和生成/失败/保存/取消非裁剪。真实 Vite 页面测得 window/document/body client 与 scroll 均为 `1280×800`;无登录会话时按既有安全合同停在登录门禁,不增加或使用测试绕过。
- 本次只调整 UI 和测试/文档,未改变图片生成请求、登录态、泥点计费、钱包刷新、Host Port、Tauri command/event、草稿 CAS、manifest/revision、幂等恢复、Runtime、审批、资源投影或保存事务。
- 2026-08-06 视觉复核进一步锁定保存区语义:素材名称保留用户编辑;create 的资源用途改为四项中文固定选择并默认普通游戏美术;refine 从源 manifest 继承并锁定 kind,避免普通用户直接编辑内部 `assetKind` slug。顶部工具与保存设置显式分为两行,保存按钮不可压缩,窄容器独占整行;Supervisor 和普通聊天工作区状态把绝对路径收口为项目名称。
@@ -966,3 +966,5 @@ confirmation-required
- 阶段四保留左导航、中央主视窗、右侧 Project Supervisor 和底部 Agent Dock 四区 DOM。中央壳使用主面板层级;Supervisor 降为协作面板,其消息和 Runtime 状态内部滚动、composer 固定在栏底;Dock 使用较轻 surface 和可收缩状态卡,不与中央主要操作争抢层级。
- 桌面 `>760px` 统一使用 `100dvh` 的两行工作台网格,第一行是内部滚动的主视窗/Supervisor,第二行是常驻 Dock。正式 `1280×800` 基线下页面级尺寸合同为 window/document/body `1280×800`;无登录会话的真实 Vite 入口按安全合同停在登录页,不绕过门禁,工作台内部由 AppSurface、共享 chrome 和 CSS 合同测试提供可重复证据。
- 阶段五定向测试覆盖共享 chrome 的 pressed、Tauri 实际消费的工具组/分隔符、按钮名称、expanded/disabled、生成确认、失败重试、保存/取消,以及生成 dialog、进度/失败卡、状态栏、Supervisor composer 和 Agent Dock 的非裁剪/可见性合同。生成请求、登录态、泥点计费、Host Port、草稿 CAS、manifest/revision、Runtime、审批和保存事务均未改变。
- 保存设置的产品语义固定为“名称可编辑、用途受控、格式枚举”。create 默认 `game-art`,普通用户只从 `game-art / icon-spec / ui-prototype / art-spritesheet` 四个权威用途选择;界面显示中文名称,不暴露自由 slug 输入。refine 必须继承源 manifest asset 的 `kind` 并锁定,未知历史 kind 原值透传但只显示“原资源用途”,不得借精修改变 subtype。PNG/JPEG/WebP 继续是有限格式选项。
- 工具动作与保存设置必须是显式上下两行,保存栅格把主按钮列固定为 `max-content` 且禁止换行;容器宽度不足时保存按钮独占整行。普通用户工作区状态只显示项目名称,不直接展示本机绝对项目路径;显式目录选择、权限确认或开发诊断不受该展示规则替代。