Merge branch 'master' into feat/five_min_design
Project CI / Repository checks (pull_request) Failing after 5m26s
Project CI / Frontend tests (pull_request) Failing after 5m49s
Project CI / Backend tests (pull_request) Successful in 4m13s
Project CI / Native shell tests (pull_request) Failing after 3m40s

This commit is contained in:
2026-08-25 10:56:57 +08:00
17 changed files with 724 additions and 108 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.2",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
@@ -1734,12 +1734,12 @@ if (
}
if (
tauriConfig.version !== '0.1.2' ||
packageConfig.version !== '0.1.2' ||
cargoPackageVersion !== '0.1.2'
tauriConfig.version !== '0.1.4' ||
packageConfig.version !== '0.1.4' ||
cargoPackageVersion !== '0.1.4'
) {
throw new Error(
'AI game creator standard release must remain version 0.1.2 while game-chat uses its dedicated version',
'AI game creator standard release must remain version 0.1.4 while game-chat uses its dedicated version',
);
}
+1 -1
View File
@@ -1695,7 +1695,7 @@ dependencies = [
[[package]]
name = "genarrative-ai-game-creator-shell"
version = "0.1.2"
version = "0.1.4"
dependencies = [
"agent-runtime-core",
"axum",
@@ -1,6 +1,6 @@
[package]
name = "genarrative-ai-game-creator-shell"
version = "0.1.2"
version = "0.1.4"
edition = "2021"
publish = false
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Genarrative AI Game Creator",
"version": "0.1.2",
"version": "0.1.4",
"identifier": "world.genarrative.ai-game-creator",
"build": {
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
+5 -1
View File
@@ -1243,6 +1243,9 @@ export function App({
const initialProjectOpenedRef = useRef(false);
const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null);
const queueRunLocalShortcutRef = useRef<() => void>(() => undefined);
const executeRunLocalRef = useRef<(announceToChat: boolean) => void>(
() => undefined,
);
function requestRuntimeConfigOpen() {
if (projectSupervisorOnly && !supervisorChatOnly && !gameChatOnly) {
@@ -3283,6 +3286,7 @@ export function App({
]);
}
queueRunLocalShortcutRef.current = queueRunLocalShortcut;
executeRunLocalRef.current = executeRunLocal;
useEffect(() => {
const nextProjectPath = localProject?.projectPath;
@@ -3300,7 +3304,7 @@ export function App({
}
handledPlayRequestRef.current = requestKey;
onPlayRequestHandled?.(playRequest.requestId);
queueRunLocalShortcutRef.current();
void executeRunLocalRef.current(true);
}, [
localProject?.projectPath,
onPlayRequestHandled,
@@ -34,7 +34,7 @@ export function WorkspaceLauncherShell({
ProjectSupervisor,
}: WorkspaceLauncherShellProps) {
const accountWallet = useAccountWallet(currentUser.id);
const [status, setStatus] = useState('请选择项目');
const [status, setStatus] = useState('');
const recentProjects = useRecentProjects(setStatus);
const { recentProjectRows, rememberRecentWorkspace } = recentProjects;
const [launcherView, setLauncherView] = useState<LauncherView>(initialView);
@@ -273,7 +273,6 @@ export function WorkspaceLauncherShell({
{launcherView === 'home' ? (
<HomeView
hasPromo={launcherNotifications.length > 0}
status={status}
onStatusChange={setStatus}
recentProjectRows={recentProjectRows}
onCreateDraftAutomatically={createHomeDraftAutomatically}
@@ -1,4 +1,4 @@
import { Send } from 'lucide-react';
import { ArrowUp } from 'lucide-react';
import type {
ComponentProps,
Dispatch,
@@ -251,14 +251,7 @@ export function ProjectSupervisorView({
type="submit"
disabled={runtimePanelProps.controlBusy || needsUserInput}
>
<Send size={16} aria-hidden="true" />
<span>
{needsUserInput
? '等待回答'
: runtimePanelProps.controlBusy
? '思考中'
: '发送'}
</span>
<ArrowUp size={16} aria-hidden="true" />
</button>
</form>
<small className="project-supervisor-workspace-status">
+372 -5
View File
@@ -5739,6 +5739,24 @@ iframe.preview-frame {
white-space: nowrap;
}
.game-resource-outline button .game-resource-outline-label {
position: relative;
overflow: visible;
}
.game-resource-outline-unread {
position: absolute;
top: -2px;
right: -6px;
width: 6px;
height: 6px;
border: 1px solid var(--platform-nav-fill);
border-radius: 50%;
background: #e5484d;
box-shadow: 0 0 0 1px rgb(229 72 77 / 18%);
pointer-events: none;
}
.game-resource-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
@@ -6879,6 +6897,7 @@ iframe.preview-frame {
align-content: start;
gap: 8px;
min-width: 0;
min-height: 156px;
padding: 12px;
border: 1px solid #eadbd4;
border-radius: 12px;
@@ -7022,12 +7041,260 @@ iframe.preview-frame {
font-size: 10px;
}
.game-workbench-chat-avatar {
width: 48px;
height: 48px;
.game-workbench-chat .supervisor-chat-only-runtime-controls {
max-width: none;
}
.game-workbench-chat
.supervisor-chat-only-runtime-controls
.pending-command {
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
background: var(--platform-warm-bg);
}
.game-workbench-chat
.supervisor-chat-only-runtime-controls
.pending-command
> span {
gap: 4px;
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 800;
}
.game-workbench-chat
.supervisor-chat-only-runtime-controls
.pending-command
> span
small {
color: var(--platform-text-soft);
font-weight: 500;
line-height: 1.4;
}
.game-workbench-chat .pending-command-actions button {
min-width: 52px;
height: 34px;
flex: 0 0 auto;
object-fit: contain;
filter: drop-shadow(0 5px 9px rgb(182 98 63 / 16%));
border: 1px solid var(--platform-surface-border);
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
font-size: 11px;
font-weight: 800;
}
.game-workbench-chat .pending-command-actions button:last-child {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.agent-runtime-status .project-runtime-pending-command > span {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 800;
}
.agent-runtime-status .project-runtime-pending-command > span small {
display: block;
margin-top: 4px;
color: var(--platform-text-soft);
font-weight: 500;
line-height: 1.4;
}
.agent-runtime-status .project-runtime-pending-command .pending-command-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.agent-runtime-status
.project-runtime-pending-command
.pending-command-actions
button:last-child {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.game-workbench-chat
.agent-runtime-status
.project-runtime-pending-command {
grid-template-columns: minmax(0, 1fr) 136px;
}
.game-workbench-chat
.agent-runtime-status
.project-runtime-pending-command
.pending-command-actions {
display: grid;
grid-template-columns: repeat(2, 64px);
gap: 8px;
width: 136px;
flex: 0 0 136px;
}
.game-workbench-chat
.agent-runtime-status
.project-runtime-pending-command
.pending-command-actions
button {
width: 64px;
min-width: 64px;
max-width: 64px;
padding: 0;
white-space: nowrap;
}
/* Runtime 确认卡在部分项目状态下位于状态包装层外,工作台样式直接绑定卡片自身,避免回落旧的蓝色按钮样式。 */
.game-workbench-chat .project-runtime-pending-command {
display: grid;
grid-template-columns: minmax(0, 1fr) 136px;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
background: var(--platform-warm-bg);
}
.game-workbench-chat .project-supervisor-surface > .project-runtime-pending-command,
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command {
display: grid;
grid-template-columns: minmax(0, 1fr) 136px;
align-items: center;
gap: 12px;
width: 100%;
margin: 0;
padding: 10px 12px;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
background: var(--platform-warm-bg);
}
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command > span {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 800;
}
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command > span small {
display: block;
margin-top: 4px;
color: var(--platform-text-soft);
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command .pending-command-actions {
display: grid;
grid-template-columns: repeat(2, 64px);
gap: 8px;
width: 136px;
flex: 0 0 136px;
}
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command .pending-command-actions button {
width: 64px;
min-width: 64px;
max-width: 64px;
height: 34px;
padding: 0;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command .pending-command-actions button:last-child {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
/* 该卡片的两个操作按钮禁止被全局 button 规则压缩,确保“拒绝 / 确认”完整可见。 */
.project-runtime-pending-command .pending-command-actions button {
display: inline-flex !important;
width: 64px !important;
min-width: 64px !important;
max-width: 64px !important;
flex: 0 0 64px !important;
align-items: center;
justify-content: center;
padding: 0 !important;
overflow: visible;
white-space: nowrap;
}
.game-workbench-chat .project-runtime-pending-command > span {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 800;
}
.game-workbench-chat .project-runtime-pending-command > span small {
display: block;
margin-top: 4px;
color: var(--platform-text-soft);
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
.game-workbench-chat .project-runtime-pending-command .pending-command-actions {
display: grid;
grid-template-columns: repeat(2, 64px);
gap: 8px;
width: 136px;
flex: 0 0 136px;
}
.game-workbench-chat
.project-runtime-pending-command
.pending-command-actions
button {
width: 64px;
min-width: 64px;
max-width: 64px;
height: 34px;
padding: 0;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.game-workbench-chat
.project-runtime-pending-command
.pending-command-actions
button:last-child {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.game-workbench-chat .project-supervisor-surface {
@@ -7281,6 +7548,100 @@ iframe.preview-frame {
white-space: nowrap;
}
/* 右侧对话采用简洁的 Codex 式消息流与编辑框,减少卡片嵌套和视觉噪音。 */
.game-workbench-chat .project-supervisor-message-list {
position: absolute;
top: 0;
right: 0;
bottom: 104px;
left: 0;
gap: 34px;
height: auto;
padding: 18px 20px 24px;
scroll-padding-bottom: 24px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: var(--platform-input-fill);
}
.game-workbench-chat .project-supervisor-message-list .message {
display: block;
width: 100%;
max-width: 100%;
padding: 4px 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--platform-text-base);
font-size: 14px;
line-height: 1.85;
}
.game-workbench-chat .project-supervisor-message-list .message + .message {
margin-top: 0;
}
.game-workbench-chat .project-supervisor-message-list .message--user {
width: fit-content;
max-width: min(82%, 640px);
margin-left: auto;
padding: 11px 16px;
border: 1px solid var(--platform-button-primary-border);
border-radius: 14px 14px 4px;
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
.game-workbench-chat .project-supervisor-composer {
display: block;
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 5;
padding: 0;
pointer-events: none;
}
.game-workbench-chat .project-supervisor-composer textarea {
display: block;
width: 100%;
min-height: 88px;
max-height: 180px;
padding: 14px 48px 38px 15px;
border: 1px solid var(--platform-surface-border);
border-radius: 14px;
background: var(--platform-input-fill);
color: var(--platform-text-strong);
font-size: 13px;
line-height: 1.6;
resize: vertical;
pointer-events: auto;
}
.game-workbench-chat .project-supervisor-composer button {
position: absolute;
right: 10px;
bottom: 10px;
display: grid;
width: 30px;
min-width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
border: 0;
border-radius: 9px;
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
place-items: center;
pointer-events: auto;
}
.game-workbench-chat .project-supervisor-composer button svg {
width: 14px;
height: 14px;
}
.game-agent-dock {
position: relative;
z-index: 30;
@@ -7691,6 +8052,7 @@ iframe.preview-frame {
.game-workbench-view-actions {
justify-content: flex-end;
}
}
@media (max-width: 760px) {
@@ -7721,6 +8083,11 @@ iframe.preview-frame {
flex-direction: column;
}
.game-workbench-view-actions .game-workbench-play-button {
position: static;
transform: none;
}
.game-workbench-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -93,7 +93,6 @@ function projectTypeLabel(projectKind: HomeProjectRow['projectKind']) {
type HomeViewProps = {
hasPromo: boolean;
status: string;
onStatusChange: (status: string) => void;
recentProjectRows: readonly HomeProjectRow[];
onCreateDraftAutomatically: (
@@ -107,7 +106,6 @@ type HomeViewProps = {
export default function HomeView({
hasPromo,
status,
onStatusChange,
recentProjectRows,
onCreateDraftAutomatically,
@@ -250,11 +248,6 @@ export default function HomeView({
</div>
</RichInputArea>
</form>
{status ? (
<span className="w-[min(814px,calc(100vw-122px))] text-left text-[12px] text-(--platform-text-soft) max-[760px]:w-[min(100%,calc(100vw-76px))]">
{status}
</span>
) : null}
</section>
<section
@@ -266,9 +259,6 @@ export default function HomeView({
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
</h2>
<p className="mb-0 mt-1 text-[12px] text-(--platform-text-soft)">
</p>
</div>
<div className="launcher-project-list-actions">
<button
@@ -45,7 +45,6 @@ import type {
GameIterationVersion,
ProjectResourceCanvasLayoutMode,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import PRODUCT_LOGO from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
import { canonicalAssetBaseName } from '../../features/asset-canvas/assetCanvasNaming';
import {
type AssetCanvasCommitNotification,
@@ -841,6 +840,15 @@ export default function ProjectDevelopmentView({
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
const [activeResourceCategory, setActiveResourceCategory] =
useState<ResourceCategory | null>(null);
const [unreadResourceCategoryState, setUnreadResourceCategoryState] =
useState<{
scopeKey: string;
categories: Set<ResourceCategory>;
}>(() => ({ scopeKey: '', categories: new Set() }));
const resourceCategorySnapshotRef = useRef<{
scopeKey: string;
resourceIdsByCategory: Map<ResourceCategory, Set<string>>;
} | null>(null);
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(
null,
);
@@ -880,8 +888,10 @@ export default function ProjectDevelopmentView({
const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState<
string | null
>(null);
const [resourcePreviewVersionByResourceId, setResourcePreviewVersionByResourceId] =
useState<Map<string, string>>(() => new Map());
const [
resourcePreviewVersionByResourceId,
setResourcePreviewVersionByResourceId,
] = useState<Map<string, string>>(() => new Map());
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
const [approvalNotice, setApprovalNotice] = useState('');
@@ -1388,6 +1398,24 @@ export default function ProjectDevelopmentView({
),
[resources],
);
const resourceIdsByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
category,
new Set(
(projectResourcesByCategory.get(category) ?? []).map(
(resource) => resource.id,
),
),
]),
),
[projectResourcesByCategory],
);
const resourceCategoryScopeKey = useMemo(
() => JSON.stringify([projectPath, manifest.projectId]),
[manifest.projectId, projectPath],
);
const resourcePageCategories = useMemo(
() => (resources.length > 0 ? categoryOrder : []),
[resources.length],
@@ -1401,6 +1429,63 @@ export default function ProjectDevelopmentView({
) ??
resourcePageCategories[0] ??
null;
const viewedResourceCategory =
mode === 'resources' ? activePageCategory : null;
useEffect(() => {
const previousSnapshot = resourceCategorySnapshotRef.current;
if (
!previousSnapshot ||
previousSnapshot.scopeKey !== resourceCategoryScopeKey
) {
resourceCategorySnapshotRef.current = {
scopeKey: resourceCategoryScopeKey,
resourceIdsByCategory,
};
setUnreadResourceCategoryState({
scopeKey: resourceCategoryScopeKey,
categories: new Set(),
});
return;
}
const categoriesWithNewResources = categoryOrder.filter((category) => {
if (category === viewedResourceCategory) {
return false;
}
const previousIds =
previousSnapshot.resourceIdsByCategory.get(category) ?? new Set();
return Array.from(resourceIdsByCategory.get(category) ?? []).some(
(resourceId) => !previousIds.has(resourceId),
);
});
resourceCategorySnapshotRef.current = {
scopeKey: resourceCategoryScopeKey,
resourceIdsByCategory,
};
setUnreadResourceCategoryState((current) => {
const categories =
current.scopeKey === resourceCategoryScopeKey
? new Set(current.categories)
: new Set<ResourceCategory>();
let changed = current.scopeKey !== resourceCategoryScopeKey;
if (viewedResourceCategory && categories.delete(viewedResourceCategory)) {
changed = true;
}
categoriesWithNewResources.forEach((category) => {
if (!categories.has(category)) {
categories.add(category);
changed = true;
}
});
return changed
? { scopeKey: resourceCategoryScopeKey, categories }
: current;
});
}, [resourceCategoryScopeKey, resourceIdsByCategory, viewedResourceCategory]);
const unreadResourceCategories =
unreadResourceCategoryState.scopeKey === resourceCategoryScopeKey
? unreadResourceCategoryState.categories
: new Set<ResourceCategory>();
resourceCanvasPageWheelRef.current.activeCategory = activePageCategory;
const nextPageCategory =
activePageCategory && resourcePageCategories.length > 1
@@ -1515,8 +1600,7 @@ export default function ProjectDevelopmentView({
: { x: 48, y: 48, scale: 1 };
const setResourceCanvasViewport = useCallback(
(candidate: CanvasViewport) => {
const activeCategory =
resourceCanvasPageWheelRef.current.activeCategory;
const activeCategory = resourceCanvasPageWheelRef.current.activeCategory;
if (!activeCategory) {
return;
}
@@ -1800,8 +1884,7 @@ export default function ProjectDevelopmentView({
const advanceResourceCanvasPage = useCallback(
(stepOffset: number) => {
const currentCategory =
resourceCanvasPageWheelRef.current.activeCategory;
const currentCategory = resourceCanvasPageWheelRef.current.activeCategory;
const normalizedStepOffset = Math.trunc(stepOffset);
if (
!currentCategory ||
@@ -1849,8 +1932,7 @@ export default function ProjectDevelopmentView({
return;
}
const now = performance.now();
const waitMs =
pageWheelSwitchIntervalMs - (now - state.lastSwitchAt);
const waitMs = pageWheelSwitchIntervalMs - (now - state.lastSwitchAt);
if (waitMs > 0) {
if (state.pendingTimerId === null) {
state.pendingTimerId = window.setTimeout(() => {
@@ -1925,8 +2007,7 @@ export default function ProjectDevelopmentView({
}
pageWheelState.accumulatedDeltaY += normalizedDeltaY;
if (
Math.abs(pageWheelState.accumulatedDeltaY) >=
pageWheelDeltaThreshold
Math.abs(pageWheelState.accumulatedDeltaY) >= pageWheelDeltaThreshold
) {
const stepCount = Math.max(
1,
@@ -2063,11 +2144,7 @@ export default function ProjectDevelopmentView({
]);
useLayoutEffect(() => {
if (
mode !== 'resources' ||
!activePageCategory ||
!activePageLayoutReady
) {
if (mode !== 'resources' || !activePageCategory || !activePageLayoutReady) {
return undefined;
}
const canvas = resourceCanvasRef.current;
@@ -2752,9 +2829,7 @@ export default function ProjectDevelopmentView({
}
drag.changed = true;
const coordinate = (start: number, delta: number) =>
normalizeResourceCanvasDragCoordinate(
start + delta / drag.startScale,
);
normalizeResourceCanvasDragCoordinate(start + delta / drag.startScale);
const preview = {
resourceId: drag.resourceId,
x: coordinate(drag.startX, deltaX),
@@ -3944,27 +4019,37 @@ export default function ProjectDevelopmentView({
data-unavailable={!runAvailable || uiEditorRoute || undefined}
className={mode === 'run' ? 'is-active' : ''}
disabled={uiEditorRoute !== null}
onClick={showRunView}
onClick={() => {
if (!runAvailable) {
return;
}
showRunView();
onPlay?.();
}}
>
</button>
</div>
<div className="game-workbench-view-actions">
<button
type="button"
className="game-workbench-play-button"
disabled={!runAvailable || !onPlay || uiEditorRoute !== null}
onClick={() => {
showRunView();
onPlay?.();
}}
title={
runAvailable ? '运行当前游戏' : '首个可运行原型完成后才能播放'
}
>
<Play size={15} aria-hidden="true" />
</button>
{mode === 'run' ? (
<button
type="button"
className="game-workbench-play-button"
disabled={!runAvailable || !onPlay || uiEditorRoute !== null}
onClick={() => {
showRunView();
onPlay?.();
}}
title={
runAvailable
? '运行当前游戏'
: '首个可运行原型完成后才能播放'
}
>
<Play size={15} aria-hidden="true" />
</button>
) : null}
{mode === 'resources' &&
!assetCanvasRoute &&
!resourceEditorRoute &&
@@ -4506,10 +4591,15 @@ export default function ProjectDevelopmentView({
>
{resourcePageCategories.map((category) => {
const Icon = categoryIcons[category];
const hasUnreadResources =
unreadResourceCategories.has(category);
return (
<button
key={category}
type="button"
aria-label={`${categoryLabels[category]}${
hasUnreadResources ? ',有新资源' : ''
}`}
aria-current={
category === activePageCategory
? 'page'
@@ -4525,7 +4615,15 @@ export default function ProjectDevelopmentView({
}
>
<Icon size={14} aria-hidden="true" />
<strong>{categoryLabels[category]}</strong>
<strong className="game-resource-outline-label">
{categoryLabels[category]}
{hasUnreadResources ? (
<span
className="game-resource-outline-unread"
aria-hidden="true"
/>
) : null}
</strong>
</button>
);
})}
@@ -4787,29 +4885,13 @@ export default function ProjectDevelopmentView({
<dd>{selectedResource.mediaType}</dd>
</div>
</dl>
) : (
<p></p>
)}
) : null}
</section>
<section aria-label="数值微调面板">
<header>
<SlidersHorizontal size={16} aria-hidden="true" />
</header>
<label>
<input type="number" disabled placeholder="未载入" />
</label>
<label>
<input type="number" disabled placeholder="未载入" />
</label>
<input
type="text"
disabled
aria-label="新增微调数值项"
placeholder="用自然语言新增微调项(尚未接入)"
/>
</section>
</div>
</section>
@@ -4819,11 +4901,6 @@ export default function ProjectDevelopmentView({
{!uiEditorRoute ? (
<aside className="game-workbench-chat" aria-label="陶泥儿 Agent 对话">
<header>
<img
className="game-workbench-chat-avatar"
src={PRODUCT_LOGO}
alt="陶泥儿"
/>
<div className="game-workbench-chat-title">
<strong></strong>
<small>
@@ -1335,7 +1335,7 @@ export function registerHomeProjectCreationTests() {
expect(screen.getAllByText('今天有什么设计需要帮你整理')).toHaveLength(1);
// 做方案走立项策划链路,建项按钮在这一档改叫「进入立项策划」。
fireEvent.click(screen.getByRole('button', { name: '进入立项策划' }));
expect(screen.getByText('请输入方案需求或上传资料')).not.toBeNull();
expect(screen.queryByText('请输入方案需求或上传资料')).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'create_automatic_local_game_project',
);
@@ -1510,12 +1510,13 @@ export function registerHomeProjectCreationTests() {
});
expect((createButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByLabelText('陶泥儿首页对话')).toBeNull();
expect(screen.getByLabelText('最近项目').textContent).toContain(
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
'选择一个项目继续创作',
);
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
'正在创建工作区',
);
expect(screen.queryByText('正在创建工作区')).toBeNull();
await act(async () => {
rejectAutomaticProject?.(new Error('自动创建测试结束'));
@@ -1524,7 +1525,7 @@ export function registerHomeProjectCreationTests() {
await waitFor(() => {
expect((createButton as HTMLButtonElement).disabled).toBe(false);
});
expect(screen.getAllByText('自动创建测试结束')).not.toHaveLength(0);
expect(screen.queryByText('自动创建测试结束')).toBeNull();
});
it.each([
@@ -1387,6 +1387,174 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.queryByRole('button', { name: /放大.*内容/ })).toBeNull();
});
it('marks newly added resources on inactive section tabs until the user opens them', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-unread',
'资源未读标识测试',
);
manifest.assets = [
{
id: 'unread-art-initial',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/initial.png',
source: { kind: 'generated' },
},
];
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-resource-unread',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, viewProps),
);
const outline = await screen.findByLabelText('资源栏目大纲');
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
expect(
within(outline)
.getByRole('button', { name: '美术资源' })
.getAttribute('aria-current'),
).toBe('page');
const manifestWithCurrentArt = {
...manifest,
assets: [
...manifest.assets,
{
id: 'unread-art-current',
kind: 'character' as const,
mediaType: 'image/png',
localPath: 'assets/current.png',
source: { kind: 'generated' as const },
},
],
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: manifestWithCurrentArt,
}),
);
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
const manifestWithAudio = {
...manifestWithCurrentArt,
assets: [
...manifestWithCurrentArt.assets,
{
id: 'unread-audio-new',
kind: 'background-music' as const,
mediaType: 'audio/mpeg',
localPath: 'assets/new.mp3',
source: { kind: 'generated' as const },
},
],
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: manifestWithAudio,
}),
);
const unreadAudioTab = await within(outline).findByRole('button', {
name: '音乐音效,有新资源',
});
expect(
unreadAudioTab.querySelector('.game-resource-outline-unread'),
).not.toBeNull();
expect(
within(outline).queryByRole('button', { name: '美术资源,有新资源' }),
).toBeNull();
fireEvent.click(unreadAudioTab);
await waitFor(() => {
expect(
within(outline)
.getByRole('button', { name: '音乐音效' })
.getAttribute('aria-current'),
).toBe('page');
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
});
const nextProjectManifest = createGameCreationAppManifest(
'workbench-resource-unread-next',
'下一个资源项目',
);
nextProjectManifest.assets = [
{
id: 'next-project-code',
kind: 'game-code',
mediaType: 'text/javascript',
localPath: 'game/game.js',
source: { kind: 'generated' },
},
];
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
projectName: nextProjectManifest.name,
projectPath: '/tmp/workbench-resource-unread-next',
manifest: nextProjectManifest,
}),
);
await waitFor(() => {
expect(
within(screen.getByLabelText('资源栏目大纲')).queryByRole('button', {
name: /有新资源/,
}),
).toBeNull();
});
});
it('keeps the empty resource overview identical in both modes', async () => {
const manifest = createGameCreationAppManifest(
'workbench-empty-section-overview',
@@ -4814,7 +4982,7 @@ export function registerUserSurfaceBoundaryTests() {
}
export function registerProjectSupervisorSurfaceTests() {
it('routes a top workbench play request into the existing game.run_local confirmation', async () => {
it('runs a top workbench play request without a second confirmation', async () => {
const projectPath = '/tmp/top-play-request';
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
@@ -4832,6 +5000,19 @@ export function registerProjectSupervisorSurfaceTests() {
recentRunStopReason: null,
};
}
if (command === 'start_local_game_preview') {
return {
url: 'http://127.0.0.1:43127/',
port: 43127,
root: projectPath,
};
}
if (command === 'get_local_game_manifest') {
return createGameCreationAppManifest(
'top-play-request',
'顶部播放请求',
);
}
return supervisorHarness.invoke(command, args);
},
);
@@ -4851,14 +5032,17 @@ export function registerProjectSupervisorSurfaceTests() {
);
expect(
await screen.findByText('game.run_local', {}, { timeout: 3_000 }),
await screen.findByText(
/运行通过,已载入客户端运行视图/,
{},
{ timeout: 3_000 },
),
).not.toBeNull();
expect(handled).toHaveBeenCalledWith(7);
expect(screen.getByText(`启动 ${projectPath}/game/`)).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'start_local_game_preview',
expect.anything(),
);
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
projectPath,
});
expect(screen.queryByText('game.run_local')).toBeNull();
});
it('starts a direct product preview without the legacy Canvas-only static smoke', async () => {
@@ -4899,9 +5083,6 @@ export function registerProjectSupervisorSurfaceTests() {
}),
);
expect(await screen.findByText('game.run_local')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(
'运行通过,已载入客户端运行视图:http://127.0.0.1:43126/',
@@ -116,8 +116,10 @@
- 资源总览的“资源依赖 / 资源类型”视图切换使用连通的分段按钮组,相邻选项共享边界并保持唯一选中语义。每个分段都必须有清晰的键盘焦点指示,焦点环不得被分段容器的圆角或 `overflow` 裁切。
- 右侧 Supervisor 对话中,用户消息使用右对齐、最大宽度受限的主题暖色气泡,assistant 消息保持左对齐;消息换行不得产生水平溢出,执行过程卡继续占满消息区可用宽度。气泡正文在 light / dark 平台主题下均须满足 WCAG AA 普通文本 `4.5:1` 对比度。
- 客户端正式产品仍只按最小 `1280×720` 横屏合同交付,并保留 `1280×800` 默认窗口与既有基线验收;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。
- 工作台顶部播放按钮在桌面视窗中水平居中;资源管理视窗触发播放时直接切换到运行视窗并启动本地预览,不再弹出 `game.run_local` 二次确认。
- 普通用户界面不默认展示内部错误码或本机绝对路径,也不展示 External Editor Base URL 或 Developer API Key;官方服务地址由客户端构建固定,远端编辑自动使用当前陶泥儿登录态。仅独立 standalone game-chat release 或显式高级自定义模式在隔离配置页维护 External v1 URL/Key,确需诊断的信息进入受控详情或开发模式。
- 创建模式素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择。图片精修继承源名称和用途,不显示创建模式保存设置;候选图片只从选中图片的“设为最终图”提交。精修顶栏只保留返回、导入、定位当前最终图、撤销和重做,删除进入图片上下文工具栏,通用 AI 生成只保留给创建模式。图片输出统一使用 PNG;创建模式工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。
- 首页创作输入区与“最近项目”之间不展示共享项目状态文本,“最近项目”标题下也不追加解释性副标题;默认、成功、进行中或失败状态均不得在该位置形成文字行,项目管理页继续保留自己的状态反馈。
### 3.8 现有 Godot 项目
@@ -319,6 +321,7 @@ type UpdateProjectResourceCanvasLayoutResult =
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变当前栏目的可视范围,不回写或裁切持久坐标,也不因资源 extent 或 resize 把已平移的 viewport 拉回内容边界。当前客户端继续以 `1280×800` 横屏合同验收。
- 任一栏目出现资源后,资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本` 五栏目分页画布;每个栏目按 `projectId + mode + category` 保留独立 viewport。普通 wheel 切换栏目,`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。
- 首次载入项目中的既有资源不显示未读标识。当前会话内,非当前栏目出现稳定 ID 的新资源时,在对应栏目名称右上角显示红点;当前栏目新增资源不显示红点,用户通过点击、滚轮或程序跳转进入该栏目后立即清除。未读状态只属于当前前端会话,并按 `projectPath + projectId` 隔离,切换项目时清空,不写入 manifest、布局 sidecar 或后端。
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready``failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。当前 scope 内资源自动协调写入使用单写者 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
- 自动协调 CAS 冲突时直接载入返回的最新布局;仍需协调时可以基于权威 revision 最多追加 `2` 次重试,持续跨窗口写入时不得无限自旋。当前提示只说明“布局已在其他窗口更新”,不得要求用户重新拖动。
@@ -578,6 +578,7 @@ game-project/
- Tauri 命令固定为 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout`。写命令先只读确认有效 manifest,再通过持久 `.layout.lock` 入口获取句柄级跨窗口系统锁,并在锁内复核 projectId、重新读取当前 sidecar。Unix 使用 `flock`,Windows 使用不共享文件句柄;释放只通过句柄 Drop / 进程退出完成,不使用 mtime stale 回收,也不删除锁文件。其余写入继续复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。
- 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;自动协调写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。资源卡是普通可点击按钮,不绑定卡片级 Pointer 拖动处理器;指针移动不修改 CSS 坐标、不调用 SVG preview、不提交手动布局 CAS。
- 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。
- 五栏目页签的未读红点由 `ProjectDevelopmentView` 在会话内比较各栏目稳定资源 ID 派生:首次快照只建立基线,只有非当前栏目后续出现新 ID 才加入未读集合;所有栏目切换路径统一以当前栏目变化清除对应未读,项目 scope 变化时重建基线并清空。该状态以 `projectPath + projectId` 隔离,不进入 manifest、布局 sidecar、localStorage、sessionStorage 或后端契约。
- type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序。manifest 资产的 subtype 使用 `asset.kind`,任务产物、导入附件和 Agent 文本成果使用稳定的来源 fallback;subtype 必须进入资源协调签名,不能因 MIME 相同而退化成按名称混排。
- 自动协调保存失败时保留当前会话布局;CAS 冲突载入对方最新布局,需要继续协调时最多追加两次重试,持续跨窗口竞争时停止自旋。用户提示只说明“布局已在其他窗口更新”,不要求重新拖动。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
- 本布局持久化切片不包含资源关系线、资源替换、聚焦态持久化、分区高度、分区内容倍率、整个画布平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线、当前会话内中央聚焦、分区独立高度与内容倍率已在后续独立前端切片接入,都不改变本段 sidecar 合同;其余 P1 能力继续独立实施。
+1 -1
View File
@@ -174,7 +174,7 @@ export function collectNpmWorkspaceErrors(rootDir) {
);
}
const expectedWorkspaceVersion =
workspacePath === 'apps/ai-game-creator-shell' ? '0.1.2' : '0.1.0';
workspacePath === 'apps/ai-game-creator-shell' ? '0.1.4' : '0.1.0';
if (manifest.version !== expectedWorkspaceVersion) {
errors.push(
`${manifestPath}: workspace version must be ${expectedWorkspaceVersion}`,
+1 -1
View File
@@ -79,7 +79,7 @@ function createValidFixture() {
name: workspaceNames[workspacePath],
private: true,
version:
workspacePath === 'apps/ai-game-creator-shell' ? '0.1.2' : '0.1.0',
workspacePath === 'apps/ai-game-creator-shell' ? '0.1.4' : '0.1.0',
dependencies: localDependencies[workspacePath],
};
writeJson(rootDir, `${workspacePath}/package.json`, manifest);