Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion backend/packages/app/src/windup_app/web/api/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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。"""
Expand Down
35 changes: 32 additions & 3 deletions backend/tests/test_art_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


# -- 改画风 ------------------------------------------------------------------
Expand Down Expand Up @@ -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"] == "中世纪厚涂"
5 changes: 5 additions & 0 deletions frontend/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/entities/project/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
})

Expand All @@ -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',
})
})
Expand Down
53 changes: 50 additions & 3 deletions frontend/src/entities/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,7 +27,7 @@ export interface CreateProjectInput {
perspective: CharacterPerspective
directionalMovement: DirectionalMovement
spriteSize: { width: number; height: number }
gameStyle?: string | null
gameStyle?: ArtStyle
sampleImageUrl?: string | null
}

Expand Down Expand Up @@ -75,12 +75,41 @@ export const DIRECTIONAL_MOVEMENT: Record<DirectionalMovement, string> = {
'eight-way': '八向',
}

/** 后端 game_style;只有 pixel 会改变出帧管线,其余只进提示词。 */
export type ArtStyle = 'pixel' | 'cartoon' | 'hand_drawn' | 'realistic' | 'unspecified'

export const ART_STYLE: Record<ArtStyle, string> = {
pixel: '像素',
cartoon: '卡通',
hand_drawn: '手绘',
realistic: '写实',
unspecified: '不指定',
}

/** 选项旁的一句说明;用户选画风时看不到管线,得把差别讲出来。 */
export const ART_STYLE_HINT: Record<ArtStyle, string> = {
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<Paged<Project>>
get(id: Project['id']): Promise<Project>
create(input: CreateProjectInput): Promise<Project>
rename(id: Project['id'], name: string): Promise<Project>
setGameStyle(id: Project['id'], gameStyle: ArtStyle): Promise<Project>
remove(id: Project['id']): Promise<void>
}

Expand All @@ -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<number, CharacterPerspective> = {
1: 'side',
2: 'top-down',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -240,6 +278,15 @@ export const projectApis: ProjectApis = {
}
},

async setGameStyle(id, gameStyle) {
return mapProject(
await getApiClient().request<ProjectDto>(`/projects/${encodeURIComponent(id)}`, {
method: 'PATCH',
json: { game_style: gameStyle },
}),
)
},

async remove(id) {
try {
await getApiClient().request<null>(`/projects/${encodeURIComponent(id)}`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/features/quick-start-agent/production.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
isArtStyle,
createAuthenticatedGenerationApis,
projectApis,
workflowRunApis,
Expand Down Expand Up @@ -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,
Expand All @@ -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()
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/features/quick-start-agent/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export function useQuickStartAgent(options: UseQuickStartAgentOptions) {
async (
prompt: string,
directionalMovement: QuickStartDirectionalMovement = 'single',
options?: { gameStyle?: string },
): Promise<QuickStartAgentResult> => {
if (running.current) throw new Error('Planner 正在处理上一条输入')
if (state.status !== 'proposal') throw new Error('提示词提案已失效')
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/features/quick-start-agent/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,19 @@ export interface QuickStartAgentTurnOptions {
signal?: AbortSignal
}

export interface ConfirmProposalOptions {
/** 画风原样透传给宿主;取值范围由宿主定义,本模块不认识业务对象。 */
gameStyle?: string
}

export interface QuickStartAgent {
start(input: string, options?: QuickStartAgentTurnOptions): Promise<QuickStartAgentResult>
continue(input: string, options?: QuickStartAgentTurnOptions): Promise<QuickStartAgentResult>
confirmProposal(
proposalId: string,
prompt: string,
directionalMovement?: QuickStartDirectionalMovement,
options?: ConfirmProposalOptions,
): Promise<QuickStartAgentResult>
revoke(): void
}
Expand All @@ -82,6 +88,7 @@ export interface QuickStartAgent {
export type StartCharacterGenerationAction = (input: {
prompt: string
directionalMovement?: QuickStartDirectionalMovement
gameStyle?: string
}) => Promise<{ runId: string }>

export interface CreateQuickStartAgentOptions {
Expand Down Expand Up @@ -283,6 +290,7 @@ export function createQuickStartAgent({
proposalId: string,
prompt: string,
directionalMovement: QuickStartDirectionalMovement = 'single',
{ gameStyle }: ConfirmProposalOptions = {},
): Promise<QuickStartAgentResult> {
assertAuthorized()
if (running) throw new Error('Planner 正在处理上一条输入')
Expand All @@ -302,6 +310,7 @@ export function createQuickStartAgent({
const { runId } = await startCharacterGeneration({
prompt: effectivePrompt,
directionalMovement,
gameStyle,
})
return { kind: 'generated', runId, ...proposal, optimizedPrompt: effectivePrompt }
} finally {
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/features/workflow-controller/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading