diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py
index e7bebbf6b..81267ff1d 100644
--- a/backend/packages/app/src/windup_app/web/api/project.py
+++ b/backend/packages/app/src/windup_app/web/api/project.py
@@ -97,11 +97,28 @@ class ProjectOut(BaseModel):
directional_movement: int
sprite_width: int
sprite_height: int
- game_style: str | None
+ game_style: ArtStyle | str
sprite_sample_url: str | None
create_at: datetime
update_at: datetime
+ @field_validator("game_style", mode="before")
+ @classmethod
+ def _normalize_style(cls, value: object) -> ArtStyle | str:
+ """库里既有枚举码也有存量自由文本。
+
+ 认得出的归一成枚举码,认不出的**原样交出去** —— 压成 ``UNSPECIFIED`` 会让接口
+ 与生成时实际用的画风对不上:库里存着「中世纪厚涂」、提示词里也用着它,而接口说
+ 这个项目没设画风。
+ """
+ if not isinstance(value, str) or not value.strip():
+ return ArtStyle.UNSPECIFIED
+ text = value.strip()
+ try:
+ return ArtStyle(text)
+ except ValueError:
+ return ArtStyle.PIXEL if ArtStyle.from_stored(text) is ArtStyle.PIXEL else text
+
class ProjectListOut(ProjectOut):
"""项目列表项;预览是读取投影,不写回 Project。"""
diff --git a/backend/tests/test_art_style.py b/backend/tests/test_art_style.py
index 17c69df9d..29d4291ef 100644
--- a/backend/tests/test_art_style.py
+++ b/backend/tests/test_art_style.py
@@ -80,10 +80,27 @@ def test_created_project_reports_the_chosen_style(auth_client):
assert created["game_style"] == "pixel"
-def test_unspecified_is_stored_as_null(auth_client):
- """现有前端把这一列原样显示,写字面量会让「不指定」四个字变成 unspecified。"""
+def test_unspecified_is_stored_as_null(auth_client, db_session):
+ """库里存 NULL 而不是字面量;响应侧再归一成枚举交给前端。"""
+ from windup_app.server.project.model import Project
+
created = auth_client.post("/projects", json=_payload()).json()["data"]
- assert created["game_style"] is None
+ assert created["game_style"] == "unspecified"
+ assert db_session.get(Project, created["id"]).game_style is None
+
+
+def test_reading_a_legacy_row_reports_the_branch_it_takes(auth_client, db_session):
+ """存量存的是自由文本,不归一的话前端会把「像素风格」显示成不指定。"""
+ from windup_app.server.project.model import Project
+
+ created = auth_client.post("/projects", json=_payload()).json()["data"]
+ project = db_session.get(Project, created["id"])
+ project.game_style = "像素风格"
+ db_session.commit()
+
+ body = auth_client.get(f"/projects/{created['id']}").json()
+ assert body["code"] == 200
+ assert body["data"]["game_style"] == "pixel"
# -- 改画风 ------------------------------------------------------------------
@@ -218,3 +235,15 @@ def test_no_phrase_uses_a_negation(style):
errors = [i for i in lint(style.prompt_phrase, kind="still") if i.level == "error"]
assert errors == [], f"{style.value}: {[i.message for i in errors]}"
+
+
+def test_reading_a_non_pixel_legacy_row_tells_the_truth(auth_client, db_session):
+ """接口不能说「没设画风」而生成时又在用那段文字 —— 认不出的原样交出去。"""
+ from windup_app.server.project.model import Project
+
+ created = auth_client.post("/projects", json=_payload()).json()["data"]
+ db_session.get(Project, created["id"]).game_style = "中世纪厚涂"
+ db_session.commit()
+
+ body = auth_client.get(f"/projects/{created['id']}").json()
+ assert body["data"]["game_style"] == "中世纪厚涂"
diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts
index beb339bf4..29a5347d0 100644
--- a/frontend/src/entities/index.ts
+++ b/frontend/src/entities/index.ts
@@ -23,13 +23,18 @@ export type {
/* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */
export {
+ ART_STYLE,
+ ART_STYLE_HINT,
+ ART_STYLE_OPTIONS,
CHARACTER_PERSPECTIVE,
+ isArtStyle,
DIRECTIONAL_MOVEMENT,
ProjectHasCharactersError,
ProjectNameConflictError,
} from './project'
export { projectApis } from './project'
export type {
+ ArtStyle,
CharacterPerspective,
CreateProjectInput,
DirectionalMovement,
diff --git a/frontend/src/entities/project/index.test.ts b/frontend/src/entities/project/index.test.ts
index 322ef23d8..38a227ab0 100644
--- a/frontend/src/entities/project/index.test.ts
+++ b/frontend/src/entities/project/index.test.ts
@@ -60,7 +60,7 @@ describe('projectApis', () => {
perspective: 'isometric',
directionalMovement: 'four-way',
spriteSize: { width: 64, height: 96 },
- gameStyle: null,
+ gameStyle: 'unspecified',
sampleImageUrl: 'https://cdn.windup.test/style.png',
previewUrl: 'https://cdn.windup.test/project-preview.png',
createdAt: '2026-08-01T08:00:00Z',
@@ -87,7 +87,7 @@ describe('projectApis', () => {
perspective: 'isometric',
directionalMovement: 'four-way',
spriteSize: { width: 64, height: 96 },
- gameStyle: null,
+ gameStyle: 'unspecified',
sampleImageUrl: 'https://cdn.windup.test/style.png',
})
@@ -100,7 +100,7 @@ describe('projectApis', () => {
directional_movement: 2,
sprite_width: 64,
sprite_height: 96,
- game_style: null,
+ game_style: 'unspecified',
sprite_sample_url: 'https://cdn.windup.test/style.png',
})
})
diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts
index 0d3c06983..1e668a39b 100644
--- a/frontend/src/entities/project/index.ts
+++ b/frontend/src/entities/project/index.ts
@@ -9,7 +9,7 @@ export interface Project {
perspective: CharacterPerspective
directionalMovement: DirectionalMovement
spriteSize: { width: number; height: number }
- gameStyle: string | null
+ gameStyle: ArtStyle
sampleImageUrl: string | null
/** 项目列表已经解析好的卡片预览;详情响应不承诺提供。 */
previewUrl?: string | null
@@ -27,7 +27,7 @@ export interface CreateProjectInput {
perspective: CharacterPerspective
directionalMovement: DirectionalMovement
spriteSize: { width: number; height: number }
- gameStyle?: string | null
+ gameStyle?: ArtStyle
sampleImageUrl?: string | null
}
@@ -75,12 +75,41 @@ export const DIRECTIONAL_MOVEMENT: Record = {
'eight-way': '八向',
}
+/** 后端 game_style;只有 pixel 会改变出帧管线,其余只进提示词。 */
+export type ArtStyle = 'pixel' | 'cartoon' | 'hand_drawn' | 'realistic' | 'unspecified'
+
+export const ART_STYLE: Record = {
+ pixel: '像素',
+ cartoon: '卡通',
+ hand_drawn: '手绘',
+ realistic: '写实',
+ unspecified: '不指定',
+}
+
+/** 选项旁的一句说明;用户选画风时看不到管线,得把差别讲出来。 */
+export const ART_STYLE_HINT: Record = {
+ pixel: '出帧吸附母版像素网格,颜色吸回母版色板',
+ cartoon: '粗描线、平涂',
+ hand_drawn: '有笔触与纸纹',
+ realistic: '无描线,走渐变与体积光',
+ unspecified: '不给模型画风约束',
+}
+
+export const ART_STYLE_OPTIONS: ArtStyle[] = [
+ 'pixel',
+ 'cartoon',
+ 'hand_drawn',
+ 'realistic',
+ 'unspecified',
+]
+
/** Project 对应的一组后端接口。 */
export interface ProjectApis {
list(query?: ProjectPageQuery): Promise>
get(id: Project['id']): Promise
create(input: CreateProjectInput): Promise
rename(id: Project['id'], name: string): Promise
+ setGameStyle(id: Project['id'], gameStyle: ArtStyle): Promise
remove(id: Project['id']): Promise
}
@@ -99,6 +128,15 @@ interface ProjectDto {
update_at: string
}
+export function isArtStyle(value: unknown): value is ArtStyle {
+ return typeof value === 'string' && value in ART_STYLE
+}
+
+/** 后端已经把存量自由文本归一成枚举;这里只兜住 null 与未知取值。 */
+function artStyleFromDto(value: string | null): ArtStyle {
+ return isArtStyle(value) ? value : 'unspecified'
+}
+
const perspectiveFromDto: Record = {
1: 'side',
2: 'top-down',
@@ -154,7 +192,7 @@ function mapProject(dto: ProjectDto): Project {
'directional_movement',
),
spriteSize: { width: dto.sprite_width, height: dto.sprite_height },
- gameStyle: dto.game_style,
+ gameStyle: artStyleFromDto(dto.game_style),
sampleImageUrl: dto.sprite_sample_url,
previewUrl: dto.preview_url ?? dto.sprite_sample_url,
createdAt: dto.create_at,
@@ -240,6 +278,15 @@ export const projectApis: ProjectApis = {
}
},
+ async setGameStyle(id, gameStyle) {
+ return mapProject(
+ await getApiClient().request(`/projects/${encodeURIComponent(id)}`, {
+ method: 'PATCH',
+ json: { game_style: gameStyle },
+ }),
+ )
+ },
+
async remove(id) {
try {
await getApiClient().request(`/projects/${encodeURIComponent(id)}`, {
diff --git a/frontend/src/features/export-package/character-export.test.ts b/frontend/src/features/export-package/character-export.test.ts
index 80401c7b0..04492fe9d 100644
--- a/frontend/src/features/export-package/character-export.test.ts
+++ b/frontend/src/features/export-package/character-export.test.ts
@@ -11,7 +11,7 @@ const project: Project = {
perspective: 'side',
directionalMovement: 'single',
spriteSize: { width: 64, height: 80 },
- gameStyle: null,
+ gameStyle: 'unspecified',
sampleImageUrl: null,
createdAt: '2026-08-01T08:00:00Z',
updatedAt: '2026-08-01T08:00:00Z',
diff --git a/frontend/src/features/export-package/progressive-export.test.ts b/frontend/src/features/export-package/progressive-export.test.ts
index 2412ebd33..2cc6ed868 100644
--- a/frontend/src/features/export-package/progressive-export.test.ts
+++ b/frontend/src/features/export-package/progressive-export.test.ts
@@ -11,7 +11,7 @@ const project: Project = {
perspective: 'side',
directionalMovement: 'single',
spriteSize: { width: 32, height: 40 },
- gameStyle: null,
+ gameStyle: 'unspecified',
sampleImageUrl: null,
createdAt: '2026-08-01T08:00:00Z',
updatedAt: '2026-08-01T08:00:00Z',
diff --git a/frontend/src/features/quick-start-agent/production.ts b/frontend/src/features/quick-start-agent/production.ts
index ddd60a981..2da19d22b 100644
--- a/frontend/src/features/quick-start-agent/production.ts
+++ b/frontend/src/features/quick-start-agent/production.ts
@@ -1,4 +1,5 @@
import {
+ isArtStyle,
createAuthenticatedGenerationApis,
projectApis,
workflowRunApis,
@@ -133,6 +134,7 @@ export function createProductionQuickStartAgentDependencies(
return planner(input)
},
async startCharacterGeneration(input) {
+ const gameStyle = isArtStyle(input.gameStyle) ? input.gameStyle : undefined
generationApis ??= createAuthenticatedGenerationApis()
const controller = createController({
workflowRunApis: runApis,
@@ -141,7 +143,7 @@ export function createProductionQuickStartAgentDependencies(
onAsyncError: reportError,
})
try {
- return await controller.startCharacterGeneration(input)
+ return await controller.startCharacterGeneration({ prompt: input.prompt, gameStyle })
} finally {
controller.dispose()
}
diff --git a/frontend/src/features/quick-start-agent/react.ts b/frontend/src/features/quick-start-agent/react.ts
index f6651759b..e29238004 100644
--- a/frontend/src/features/quick-start-agent/react.ts
+++ b/frontend/src/features/quick-start-agent/react.ts
@@ -145,6 +145,7 @@ export function useQuickStartAgent(options: UseQuickStartAgentOptions) {
async (
prompt: string,
directionalMovement: QuickStartDirectionalMovement = 'single',
+ options?: { gameStyle?: string },
): Promise => {
if (running.current) throw new Error('Planner 正在处理上一条输入')
if (state.status !== 'proposal') throw new Error('提示词提案已失效')
@@ -159,7 +160,7 @@ export function useQuickStartAgent(options: UseQuickStartAgentOptions) {
})
}
try {
- return await ensureAgent().confirmProposal(proposalId, prompt, directionalMovement)
+ return await ensureAgent().confirmProposal(proposalId, prompt, directionalMovement, options)
} catch (cause) {
if (mounted.current) {
logAgentFailure(cause)
diff --git a/frontend/src/features/quick-start-agent/runtime.ts b/frontend/src/features/quick-start-agent/runtime.ts
index ec6eab9c4..939deb3af 100644
--- a/frontend/src/features/quick-start-agent/runtime.ts
+++ b/frontend/src/features/quick-start-agent/runtime.ts
@@ -67,6 +67,11 @@ export interface QuickStartAgentTurnOptions {
signal?: AbortSignal
}
+export interface ConfirmProposalOptions {
+ /** 画风原样透传给宿主;取值范围由宿主定义,本模块不认识业务对象。 */
+ gameStyle?: string
+}
+
export interface QuickStartAgent {
start(input: string, options?: QuickStartAgentTurnOptions): Promise
continue(input: string, options?: QuickStartAgentTurnOptions): Promise
@@ -74,6 +79,7 @@ export interface QuickStartAgent {
proposalId: string,
prompt: string,
directionalMovement?: QuickStartDirectionalMovement,
+ options?: ConfirmProposalOptions,
): Promise
revoke(): void
}
@@ -82,6 +88,7 @@ export interface QuickStartAgent {
export type StartCharacterGenerationAction = (input: {
prompt: string
directionalMovement?: QuickStartDirectionalMovement
+ gameStyle?: string
}) => Promise<{ runId: string }>
export interface CreateQuickStartAgentOptions {
@@ -283,6 +290,7 @@ export function createQuickStartAgent({
proposalId: string,
prompt: string,
directionalMovement: QuickStartDirectionalMovement = 'single',
+ { gameStyle }: ConfirmProposalOptions = {},
): Promise {
assertAuthorized()
if (running) throw new Error('Planner 正在处理上一条输入')
@@ -302,6 +310,7 @@ export function createQuickStartAgent({
const { runId } = await startCharacterGeneration({
prompt: effectivePrompt,
directionalMovement,
+ gameStyle,
})
return { kind: 'generated', runId, ...proposal, optimizedPrompt: effectivePrompt }
} finally {
diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts
index ffb23bb85..7a7a3d293 100644
--- a/frontend/src/features/workflow-controller/controller.test.ts
+++ b/frontend/src/features/workflow-controller/controller.test.ts
@@ -280,7 +280,9 @@ describe('WorkflowController', () => {
controller.startCharacterGeneration({ prompt: ' 银发像素骑士 ' }),
).resolves.toEqual({ runId: 'run-1' })
- expect(prepareProject).toHaveBeenCalledWith('银发像素骑士', 'single')
+ expect(prepareProject).toHaveBeenCalledWith('银发像素骑士', 'single', {
+ gameStyle: undefined,
+ })
expect(workflow.apis.create).toHaveBeenCalledWith({
projectId: 'project-agent',
nodes: [
@@ -327,7 +329,9 @@ describe('WorkflowController', () => {
directionalMovement: 'four-way',
})
- expect(prepareProject).toHaveBeenCalledWith('四向像素骑士', 'four-way')
+ expect(prepareProject).toHaveBeenCalledWith('四向像素骑士', 'four-way', {
+ gameStyle: undefined,
+ })
expect(generation.apis.create).toHaveBeenCalledTimes(1)
expect(vi.mocked(generation.apis.create).mock.calls.map(([input]) => input.direction)).toEqual([
'east',
diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts
index c587b913d..d86732de2 100644
--- a/frontend/src/features/workflow-controller/controller.ts
+++ b/frontend/src/features/workflow-controller/controller.ts
@@ -30,6 +30,7 @@ import type {
DirectionalMovement,
} from '@/entities'
import {
+ type ArtStyle,
getDirectionProfile,
isImageCandidateCount,
ProjectNameConflictError,
@@ -106,14 +107,20 @@ export interface ApplyGenerationResultInput {
generation: Generation
}
+export interface PrepareQuickStartProjectOptions {
+ gameStyle?: ArtStyle
+}
+
export type PrepareQuickStartProject = (
prompt: string,
directionalMovement?: DirectionalMovement,
+ options?: PrepareQuickStartProjectOptions,
) => Promise & Partial>>
export interface StartCharacterGenerationInput {
prompt: string
directionalMovement?: DirectionalMovement
+ gameStyle?: ArtStyle
}
export interface StartCharacterGenerationResult {
@@ -250,7 +257,7 @@ function boundedProjectName(value: string, maxLength: number): string {
export function createAutoPrepareProject(
projectApis: Pick,
): PrepareQuickStartProject {
- return async (prompt, directionalMovement = 'single') => {
+ return async (prompt, directionalMovement = 'single', options) => {
const normalizedPrompt = prompt.trim().replace(/\s+/gu, ' ') || '未命名项目'
let lastConflict: unknown
@@ -265,6 +272,7 @@ export function createAutoPrepareProject(
perspective: 'side',
directionalMovement,
spriteSize: { width: 256, height: 256 },
+ gameStyle: options?.gameStyle,
})
return {
id: project.id,
@@ -395,13 +403,16 @@ export function createWorkflowController({
async function startCharacterGeneration({
prompt,
directionalMovement: selectedDirectionalMovement = 'single',
+ gameStyle,
}: StartCharacterGenerationInput): Promise {
ensureRunning()
if (!prepareProject) {
throw new Error('WorkflowController 未配置 Quick Start 项目准备能力')
}
const normalizedPrompt = nonEmpty(prompt, '角色描述')
- const project = await prepareProject(normalizedPrompt, selectedDirectionalMovement)
+ const project = await prepareProject(normalizedPrompt, selectedDirectionalMovement, {
+ gameStyle,
+ })
generationDirections = getDirectionProfile(
project.directionalMovement ?? selectedDirectionalMovement,
).generationDirections
diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts
index a2f1f5d08..801b0e056 100644
--- a/frontend/src/features/workflow-controller/index.ts
+++ b/frontend/src/features/workflow-controller/index.ts
@@ -6,6 +6,7 @@ export type {
GenerateActionOptions,
GenerateCharacterTemplateOptions,
PrepareQuickStartProject,
+ PrepareQuickStartProjectOptions,
StartCharacterGenerationInput,
StartCharacterGenerationResult,
WorkflowController,
diff --git a/frontend/src/pages/project-create/index.test.tsx b/frontend/src/pages/project-create/index.test.tsx
index 694932d66..8b4aafa93 100644
--- a/frontend/src/pages/project-create/index.test.tsx
+++ b/frontend/src/pages/project-create/index.test.tsx
@@ -173,7 +173,7 @@ describe('ProjectCreatePage', () => {
fireEvent.change(screen.getByLabelText('游戏视角'), { target: { value: 'top-down' } })
fireEvent.change(screen.getByLabelText('朝向'), { target: { value: 'eight-way' } })
fireEvent.click(screen.getByRole('button', { name: '512 × 512' }))
- fireEvent.change(screen.getByLabelText('画风约束'), { target: { value: '低饱和像素绘本' } })
+ fireEvent.change(screen.getByLabelText('画风'), { target: { value: 'pixel' } })
fireEvent.click(screen.getByRole('button', { name: '创建项目' }))
expect(await screen.findByRole('heading', { name: '雾港来信' })).toBeTruthy()
@@ -186,7 +186,7 @@ describe('ProjectCreatePage', () => {
directional_movement: 3,
sprite_width: 512,
sprite_height: 512,
- game_style: '低饱和像素绘本',
+ game_style: 'pixel',
})
// 归属由后端从 JWT 取;请求体再带 user_id 就等于宣称可以替别人建项目。
expect(Object.hasOwn(body, 'user_id')).toBe(false)
@@ -217,11 +217,11 @@ describe('ProjectCreatePage', () => {
await renderProjectCreate()
fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '点灯人 · MVP' } })
- fireEvent.change(screen.getByLabelText('画风约束'), { target: { value: '低饱和像素绘本' } })
+ fireEvent.change(screen.getByLabelText('画风'), { target: { value: 'pixel' } })
fireEvent.click(screen.getByRole('button', { name: '创建项目' }))
expect((await screen.findByRole('alert')).textContent).toBe('项目名称已存在')
- expect((screen.getByLabelText('画风约束') as HTMLTextAreaElement).value).toBe('低饱和像素绘本')
+ expect((screen.getByLabelText('画风') as HTMLSelectElement).value).toBe('pixel')
})
it('连续点击创建只发出一次请求', async () => {
diff --git a/frontend/src/pages/project-create/index.tsx b/frontend/src/pages/project-create/index.tsx
index cebb692c1..d8057603c 100644
--- a/frontend/src/pages/project-create/index.tsx
+++ b/frontend/src/pages/project-create/index.tsx
@@ -2,10 +2,14 @@ import { useRef, useState, type FormEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router'
import {
+ ART_STYLE,
+ ART_STYLE_HINT,
+ ART_STYLE_OPTIONS,
CHARACTER_PERSPECTIVE,
DIRECTIONAL_MOVEMENT,
projectApis,
workflowRunApis,
+ type ArtStyle,
type CharacterPerspective,
type DirectionalMovement,
type Project,
@@ -31,12 +35,6 @@ const SPRITE_MAX = 2048
/** 常用档位只是快捷填充,用户仍可以填这三档之外的任意合法宽高。 */
const SPRITE_PRESETS = [128, 256, 512]
-/**
- * 画风约束的长度上限只存在于前端:后端 `game_style` 是没有长度约束的 Text。
- * 定这个数是为了让它维持在「一句风格描述」的量级,不是契约要求。
- */
-const GAME_STYLE_MAX_LENGTH = 240
-
/** 创建真实项目;首页画布入口与项目中心的新建按钮共用这一页。 */
export function ProjectCreatePage() {
const navigate = useNavigate()
@@ -54,7 +52,7 @@ export function ProjectCreatePage() {
const [directionalMovement, setDirectionalMovement] = useState('single')
const [spriteWidth, setSpriteWidth] = useState('256')
const [spriteHeight, setSpriteHeight] = useState('256')
- const [gameStyle, setGameStyle] = useState('')
+ const [gameStyle, setGameStyle] = useState('unspecified')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [createdProject, setCreatedProject] = useState(null)
@@ -88,7 +86,7 @@ export function ProjectCreatePage() {
perspective,
directionalMovement,
spriteSize: { width, height },
- gameStyle: gameStyle.trim() || null,
+ gameStyle,
})
if (opensWorkflowEditor) setCreatedProject(project)
}
@@ -264,18 +262,24 @@ export function ProjectCreatePage() {
-
{error ? (
diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx
index a9b3633d7..fd1ce1125 100644
--- a/frontend/src/pages/projects/index.test.tsx
+++ b/frontend/src/pages/projects/index.test.tsx
@@ -183,7 +183,7 @@ describe('ProjectsPage', () => {
expect(previewProject.textContent).toContain('08/04')
expect(previewProject.textContent).toContain('横版视角 · 四向')
expect(previewProject.textContent).toContain('64 × 64 px')
- expect(previewProject.textContent).toContain('低饱和像素绘本')
+ expect(previewProject.textContent).toContain('像素')
expect(screen.queryByText('项目名称')).toBeNull()
expect(screen.queryByRole('link', { name: /查看角色/ })).toBeNull()
const gallery = screen.getByRole('heading', { name: '最近项目 · 02' }).closest('section')
diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx
index b97e43112..b3053bbb6 100644
--- a/frontend/src/pages/projects/index.tsx
+++ b/frontend/src/pages/projects/index.tsx
@@ -4,6 +4,7 @@ import { Link } from 'react-router'
import assetLibraryArtwork from '@/assets/workspace/asset-library.png'
import {
+ ART_STYLE,
CHARACTER_PERSPECTIVE,
DIRECTIONAL_MOVEMENT,
projectApis,
@@ -313,7 +314,7 @@ function ProjectGalleryTile({
{project.spriteSize.height} px
- {project.gameStyle || '未设置游戏风格'}
+ {ART_STYLE[project.gameStyle]}
diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx
index d6835ff9b..6d3a505a7 100644
--- a/frontend/src/pages/quick-start/index.test.tsx
+++ b/frontend/src/pages/quick-start/index.test.tsx
@@ -818,6 +818,44 @@ describe('QuickStartPage', () => {
])
})
+ it('keeps the chosen art style across a page refresh', async () => {
+ // 画风选择器在有对话之后就隐藏了;不随草稿存住的话,刷新后它悄悄回到「不指定」,
+ // 用户看不见也改不了,最终建出来的项目画风是错的。
+ const service = serviceFor(null)
+ const agent = agentFor({
+ planner: vi.fn(async () => ({
+ text: '想保留哪个特征?',
+ finishReason: 'stop',
+ toolCalls: [],
+ })),
+ })
+ window.history.replaceState(null, '', '/quick-start')
+ const firstView = renderInBrowserHistory(service, agent)
+
+ fireEvent.change(screen.getByLabelText('画风'), { target: { value: 'pixel' } })
+ fireEvent.change(screen.getByRole('textbox', { name: '创作指令' }), {
+ target: { value: '一个住在云端的机械师。' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: '生成角色' }))
+ expect(await screen.findByText('想保留哪个特征?')).toBeTruthy()
+
+ const draftId = window.history.state?.windupQuickStartAgentDraftId
+ const key = `windup.quick-start.agent-chat.v2:draft:7:${draftId}`
+ expect(window.sessionStorage.getItem(key)).toContain('"gameStyle":"pixel"')
+
+ firstView.unmount()
+ renderInBrowserHistory(service, agent)
+ expect(screen.getByText('想保留哪个特征?')).toBeTruthy()
+
+ // 再发一句触发重新持久化:状态若已被重置成「不指定」,这次就会把它写回去。
+ fireEvent.change(screen.getByRole('textbox', { name: '创作指令' }), {
+ target: { value: '再补一句。' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: '继续' }))
+ await screen.findByText('再补一句。')
+ expect(window.sessionStorage.getItem(key)).toContain('"gameStyle":"pixel"')
+ })
+
it('moves the Agent draft into a run-scoped sidecar when generation starts', async () => {
vi.useFakeTimers()
const createdRun = workflow(setupAndTemplate({ phase: 'generating' }), 'run-created')
@@ -981,6 +1019,7 @@ describe('QuickStartPage', () => {
expect(startCharacterGeneration).toHaveBeenCalledWith({
prompt: '云端工坊的银发机械师,佩戴黄铜护目镜',
directionalMovement: 'eight-way',
+ gameStyle: 'unspecified',
})
})
@@ -1626,6 +1665,7 @@ describe('QuickStartPage', () => {
expect(startCharacterGeneration).toHaveBeenCalledWith({
prompt: '16-bit 日式 RPG 像素风,清晰轮廓,明亮配色',
directionalMovement: 'four-way',
+ gameStyle: 'unspecified',
}),
)
expect(service.start).not.toHaveBeenCalled()
@@ -1645,6 +1685,7 @@ describe('QuickStartPage', () => {
'挥手',
expect.any(AbortSignal),
'eight-way',
+ { gameStyle: 'unspecified' },
),
)
})
diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx
index 1143c9024..8d0b54dd0 100644
--- a/frontend/src/pages/quick-start/index.tsx
+++ b/frontend/src/pages/quick-start/index.tsx
@@ -25,8 +25,12 @@ import Markdown from 'markdown-to-jsx'
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router'
import {
+ ART_STYLE,
+ ART_STYLE_OPTIONS,
DIRECTIONAL_MOVEMENT,
+ isArtStyle,
type ActionFirstFrameWorkflowNode,
+ type ArtStyle,
type CharacterTemplateWorkflowNode,
type DirectionalMovement,
type WorkflowRun,
@@ -165,6 +169,8 @@ type AgentConversationTurn =
type AgentConversationRecord = {
turns: readonly AgentConversationTurn[]
+ /** 入口处选的画风;不随草稿存住的话,刷新后画风选择器已隐藏而值悄悄回到不指定。 */
+ gameStyle?: ArtStyle
}
type AgentConversationStorageName = 'localStorage' | 'sessionStorage'
@@ -194,6 +200,20 @@ function readAgentDraftId(): string | null {
}
}
+function readAgentDraftGameStyle(key: string): ArtStyle {
+ try {
+ const stored = window.sessionStorage.getItem(key)
+ if (!stored) return 'unspecified'
+ const parsed: unknown = JSON.parse(stored)
+ if (typeof parsed !== 'object' || parsed === null || !('gameStyle' in parsed)) {
+ return 'unspecified'
+ }
+ return isArtStyle(parsed.gameStyle) ? parsed.gameStyle : 'unspecified'
+ } catch {
+ return 'unspecified'
+ }
+}
+
function createAgentDraftId(): string {
const draftId = window.crypto.randomUUID()
try {
@@ -525,6 +545,14 @@ function QuickStartInput({
const [directionalMovement, setDirectionalMovement] = useState('single')
const [confirmedPrompt, setConfirmedPrompt] = useState(null)
const [templateFile, setTemplateFile] = useState(null)
+ const [gameStyle, setGameStyle] = useState(() => {
+ const draftId = readAgentDraftId()
+ return draftId
+ ? readAgentDraftGameStyle(agentDraftConversationStorageKey(activeRunUserId, draftId))
+ : 'unspecified'
+ })
+ const gameStyleRef = useRef(gameStyle)
+ gameStyleRef.current = gameStyle
const [submitting, setSubmitting] = useState(false)
const [revealingFirstAgentTurn, setRevealingFirstAgentTurn] = useState(false)
const [entryTransition, setEntryTransition] = useState<'idle' | 'leaving'>('idle')
@@ -591,7 +619,7 @@ function QuickStartInput({
writeAgentConversation(
'sessionStorage',
agentDraftConversationStorageKey(activeRunUserId, draftId),
- { turns },
+ { turns, gameStyle: gameStyleRef.current },
)
},
[activeRunUserId, ensureDraftId],
@@ -814,6 +842,7 @@ function QuickStartInput({
normalizedPrompt,
abortController.signal,
directionalMovement,
+ { gameStyle },
)
const handoffPromise = new Promise((resolve) => {
handoffTimer.current = setTimeout(() => {
@@ -845,7 +874,7 @@ function QuickStartInput({
appendConversationTurn({ role: 'user', content: DIRECTIONAL_MOVEMENT[movement] })
setPromptState('confirmed')
try {
- const result = await agentSession.confirmProposal(confirmedPrompt, movement)
+ const result = await agentSession.confirmProposal(confirmedPrompt, movement, { gameStyle })
if (result.kind === 'generated') await handoffGenerated(result)
} catch {
setPromptState('direction')
@@ -1127,6 +1156,35 @@ function QuickStartInput({
添加母版
) : null}
+ {!hasConversation ? (
+
+ ) : null}