From 4c65f557f53ae50ddabcc34cb8472725bace8745 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Mon, 24 Aug 2026 15:19:49 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(project):=20=E7=94=BB=E9=A3=8E?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8F=AF=E9=80=89=E6=8B=A9=E7=9A=84=E5=8F=97?= =?UTF-8?q?=E6=8E=A7=E6=A1=A3=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #602 --- .../app/src/windup_app/web/api/project.py | 8 ++- backend/tests/test_art_style.py | 23 ++++++-- frontend/src/entities/index.ts | 5 ++ frontend/src/entities/project/index.test.ts | 6 +-- frontend/src/entities/project/index.ts | 53 +++++++++++++++++-- .../export-package/character-export.test.ts | 2 +- .../export-package/progressive-export.test.ts | 2 +- .../features/quick-start-agent/production.ts | 4 +- .../src/features/quick-start-agent/react.ts | 7 ++- .../src/features/quick-start-agent/runtime.ts | 9 +++- .../workflow-controller/controller.test.ts | 4 +- .../workflow-controller/controller.ts | 15 +++++- .../src/features/workflow-controller/index.ts | 1 + .../src/pages/project-create/index.test.tsx | 8 +-- frontend/src/pages/project-create/index.tsx | 36 +++++++------ frontend/src/pages/projects/index.test.tsx | 2 +- frontend/src/pages/projects/index.tsx | 3 +- frontend/src/pages/quick-start/index.test.tsx | 3 ++ frontend/src/pages/quick-start/index.tsx | 25 ++++++++- frontend/src/pages/quick-start/service.ts | 17 ++++-- .../src/pages/workflow-editor/index.test.tsx | 2 +- frontend/src/pages/workflow-editor/index.tsx | 14 ++++- .../src/pages/workflow-editor/runtime.test.ts | 2 +- .../workflow-editor/workflow-editor-view.tsx | 21 +++++++- frontend/src/test/project-assets-backend.ts | 4 +- openapi.json | 20 +------ 26 files changed, 225 insertions(+), 71 deletions(-) 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..06276f5ee 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,17 @@ class ProjectOut(BaseModel): directional_movement: int sprite_width: int sprite_height: int - game_style: str | None + game_style: ArtStyle 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: + """库里既有枚举值也有存量自由文本;不归一的话前端会把「像素风格」显示成不指定。""" + return ArtStyle.from_stored(value if isinstance(value, str) else None) + class ProjectListOut(ProjectOut): """项目列表项;预览是读取投影,不写回 Project。""" diff --git a/backend/tests/test_art_style.py b/backend/tests/test_art_style.py index 17c69df9d..ab6e6dc25 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"] == "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"] - assert created["game_style"] is None + 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" # -- 改画风 ------------------------------------------------------------------ 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..e75342f6a 100644 --- a/frontend/src/features/quick-start-agent/react.ts +++ b/frontend/src/features/quick-start-agent/react.ts @@ -102,7 +102,10 @@ export function useQuickStartAgent(options: UseQuickStartAgentOptions) { ]) const submit = useCallback( - async (input: string): Promise => { + async ( + input: string, + { gameStyle }: { gameStyle?: string } = {}, + ): Promise => { if (running.current) throw new Error('Planner 正在处理上一条输入') running.current = true const controller = new AbortController() @@ -113,7 +116,7 @@ export function useQuickStartAgent(options: UseQuickStartAgentOptions) { const activeAgent = ensureAgent() const runTurn = started.current ? activeAgent.continue : activeAgent.start started.current = true - const result = await runTurn(input, { signal: controller.signal }) + const result = await runTurn(input, { signal: controller.signal, gameStyle }) if (mounted.current) { if (result.kind === 'message') { setState({ diff --git a/frontend/src/features/quick-start-agent/runtime.ts b/frontend/src/features/quick-start-agent/runtime.ts index ec6eab9c4..941171c16 100644 --- a/frontend/src/features/quick-start-agent/runtime.ts +++ b/frontend/src/features/quick-start-agent/runtime.ts @@ -65,6 +65,8 @@ export type QuickStartAgentResult = export interface QuickStartAgentTurnOptions { signal?: AbortSignal + /** 入口处选的画风,原样透传给宿主;取值范围由宿主定义,本模块不认识业务对象。 */ + gameStyle?: string } export interface QuickStartAgent { @@ -82,6 +84,7 @@ export interface QuickStartAgent { export type StartCharacterGenerationAction = (input: { prompt: string directionalMovement?: QuickStartDirectionalMovement + gameStyle?: string }) => Promise<{ runId: string }> export interface CreateQuickStartAgentOptions { @@ -223,6 +226,8 @@ export function createQuickStartAgent({ let running = false let messages: PlannerMessage[] = [...initialMessages] let currentProposal = initialProposal + /** 画风在入口那一轮就选定了,而真正下单在用户确认提案之后,中间要存住。 */ + let pendingGameStyle: string | undefined function assertAuthorized() { if (revoked) throw new Error('生成授权已失效') @@ -231,9 +236,10 @@ export function createQuickStartAgent({ async function runTurn( input: string, - { signal }: QuickStartAgentTurnOptions = {}, + { signal, gameStyle }: QuickStartAgentTurnOptions = {}, ): Promise { assertAuthorized() + if (gameStyle !== undefined) pendingGameStyle = gameStyle if (running) throw new Error('Planner 正在处理上一条输入') const normalizedInput = input.trim() if (!normalizedInput) throw new Error('请先描述想要创建的角色') @@ -302,6 +308,7 @@ export function createQuickStartAgent({ const { runId } = await startCharacterGeneration({ prompt: effectivePrompt, directionalMovement, + gameStyle: pendingGameStyle, }) 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 6703e1f10..ad3c03574 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: [ diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 411468503..041c27af4 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -29,6 +29,7 @@ import type { DirectionalMovement, } from '@/entities' import { + type ArtStyle, getDirectionProfile, isImageCandidateCount, ProjectNameConflictError, @@ -101,14 +102,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 { @@ -245,7 +252,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 @@ -260,6 +267,7 @@ export function createAutoPrepareProject( perspective: 'side', directionalMovement, spriteSize: { width: 256, height: 256 }, + gameStyle: options?.gameStyle, }) return { id: project.id, @@ -390,13 +398,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() {
-