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
22 changes: 22 additions & 0 deletions spx-gui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ Keep import statements in order:
- Enum names and enum members
- Vue component names

### Identifier Resolution

When working with backend unique string identifiers such as `username`, project owner, and project name, distinguish unresolved identifiers from canonical identifiers.

* Route params, query params, manual user input, and JWT-derived identifiers are unresolved unless the current task establishes a stronger guarantee.

* Backend-issued values from HTTP API responses are canonical.

* Prefer explicit unresolved names with an `Input` suffix.

* In models and resolved state, names like `owner`, `name`, and `username` should refer to canonical values.

* Keep normal strict and case-sensitive equality (`===` / `!==`) for consuming identifiers. Do not spread ad hoc case-normalization logic across comparison sites.

* Resolve unresolved identifiers at clear boundaries before consuming them. Typical resolution boundaries include project loading, user loading, and other backend-backed fetches.

* Avoid storing unresolved identifiers on long-lived models as if they were already canonical. Prefer passing unresolved identifiers as load or resolve parameters, then writing canonical values onto the model after resolution.

* Downstream logic should consume canonical values for behavior-sensitive checks such as ownership checks, permission checks, project reuse checks, and local-cache decisions.

* Cache keys and similar identity-scoping data may intentionally use unresolved identifiers when that preserves stable session scoping.

## TypeScript Testing

* Use `describe` to group related tests.
Expand Down
4 changes: 2 additions & 2 deletions spx-gui/src/apis/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ export type ListAssetParams = PaginationParams & {
sortOrder?: 'asc' | 'desc'
}

export function listAsset(params?: ListAssetParams) {
return client.get('/assets/list', params) as Promise<ByPage<AssetData>>
export function listAsset(params?: ListAssetParams, signal?: AbortSignal) {
return client.get('/assets/list', params, { signal }) as Promise<ByPage<AssetData>>
}

export function getAsset(id: string) {
Expand Down
15 changes: 9 additions & 6 deletions spx-gui/src/components/agent-copilot/CopilotProvider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ import { ToolRegistry } from './mcp/registry'
import { Collector } from './mcp/collector'
import CopilotUI from './CopilotUI.vue'
import { z } from 'zod'
import { getSignedInUsername } from '@/stores/user'
import { untilLoaded } from '@/utils/query'
import { useSignedInStateQuery } from '@/stores/user'
import { createProjectToolDescription, CreateProjectArgsSchema } from './mcp/definitions'
import { getProject, Visibility } from '@/apis/project'
import { useRouter } from 'vue-router'
Expand All @@ -114,6 +115,7 @@ const visible = ref(false)
const mcpDebuggerVisible = ref(false)
const showEnvPanel = ref(false)
const router = useRouter()
const signedInStateQuery = useSignedInStateQuery()

const toggleEnvPanel = () => {
showEnvPanel.value = !showEnvPanel.value
Expand Down Expand Up @@ -179,18 +181,19 @@ const initBasicTools = async () => {
async function createProject(options: CreateProjectOptions) {
const projectName = options.projectName

// Check if user is signed in
const signedInUsername = getSignedInUsername()
if (signedInUsername == null) {
const signedInState = await untilLoaded(signedInStateQuery)
if (!signedInState.isSignedIn) {
return {
success: false,
message: 'Please sign in to create a project'
}
}

const { username } = signedInState.user

try {
// Check if project already exists
const project = await getProject(signedInUsername, options.projectName)
const project = await getProject(username, options.projectName)
if (project != null) {
return {
success: false,
Expand All @@ -201,7 +204,7 @@ async function createProject(options: CreateProjectOptions) {
// Handle error checking project existence
}

const project = new SpxProject(signedInUsername, projectName)
const project = new SpxProject(username, projectName)
project.setVisibility(Visibility.Private)

try {
Expand Down
2 changes: 1 addition & 1 deletion spx-gui/src/components/agent-copilot/UserMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const props = defineProps<{
content: string
}>()

const { data: signedInUser } = useSignedInUser()
const signedInUser = useSignedInUser()
const avatarUrl = useAvatarUrl(() => signedInUser.value?.avatar)
</script>

Expand Down
129 changes: 98 additions & 31 deletions spx-gui/src/components/asset/gen/backdrop/BackdropGen.vue
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { UIButton } from '@/components/ui'
import { AssetType } from '@/apis/asset'
import type { Backdrop } from '@/models/spx/backdrop'
import { asset2Backdrop } from '@/models/spx/common/asset'
import type { BackdropGen } from '@/models/spx/gen/backdrop-gen'
import { capture, useMessageHandle } from '@/utils/exception'
import { humanizeTimeLeft } from '../common/time-left'
import BackdropSettingInput from './BackdropSettingsInput.vue'
import LayoutWithPreview from '../common/LayoutWithPreview.vue'
import ImagePreview from '../common/ImagePreview.vue'
import ImageSelector from '../common/ImageSelector.vue'
import AssetSuggestions from '../common/AssetSuggestions.vue'
import { useAssetSuggestions } from '../common/use-asset-suggestions'
import BackdropImageItem from './BackdropImageItem.vue'
import BackdropItem from '@/components/asset/library/BackdropItem.vue'

const props = defineProps<{
gen: BackdropGen
descriptionPlaceholder?: string
}>()
const props = withDefaults(
defineProps<{
gen: BackdropGen
descriptionPlaceholder?: string
librarySearchEnabled?: boolean
}>(),
{
descriptionPlaceholder: undefined,
librarySearchEnabled: false
}
)

const emit = defineEmits<{
finished: [Backdrop]
resolved: [Backdrop]
}>()

const canSubmit = computed(() => props.gen.image != null)
Expand All @@ -27,7 +39,7 @@ const handleSubmit = useMessageHandle(
props.gen.recordAdoption().catch((err) => {
capture(err, 'failed to record backdrop asset adoption')
})
emit('finished', backdrop)
emit('resolved', backdrop)
},
{
en: 'Failed to create backdrop',
Expand All @@ -43,6 +55,30 @@ function handleImageSelect(index: number) {
props.gen.setImageIndex(index)
hasPreview.value = true
}

const isLibrarySearchEnabled = computed(
() => props.librarySearchEnabled && props.gen.imagesGenState.status === 'initial'
)

const {
keyword,
suggestions,
isLoading: isSuggestionsLoading,
selected: selectedAsset,
toggle: toggleSelectedAsset
} = useAssetSuggestions(AssetType.Backdrop, () => props.gen.settings.description, isLibrarySearchEnabled)

const handleUseAsset = useMessageHandle(
async () => {
if (selectedAsset.value == null) throw new Error('no asset selected')
const backdrop = await asset2Backdrop(selectedAsset.value)
emit('resolved', backdrop)
},
{
en: 'Failed to use asset',
zh: '使用素材失败'
}
)
</script>

<template>
Expand All @@ -58,40 +94,68 @@ function handleImageSelect(index: number) {
:disabled="handleSubmit.isLoading.value"
:description-placeholder="descriptionPlaceholder"
/>
<ImageSelector
:state="gen.imagesGenState"
:selected="gen.imageIndex"
:disabled="handleSubmit.isLoading.value"
@select="handleImageSelect"
>
<template #loading-item>
<BackdropImageItem loading />
</template>
<template #item="{ file, active, onClick }">
<BackdropImageItem :file="file" :active="active" @click="onClick" />
</template>
<template #tip>
<template v-if="gen.imagesGenState.status === 'running'">
{{ $t({ en: `Generating backdrops... `, zh: `正在生成背景...` }) }}
{{ gen.imagesGenState.timeLeft != null ? $t(humanizeTimeLeft(gen.imagesGenState.timeLeft)) : '' }}
<div class="select-area">
<AssetSuggestions
v-if="isLibrarySearchEnabled"
:type="AssetType.Backdrop"
:loading="isSuggestionsLoading"
:keyword="keyword"
:suggestions="suggestions"
:selected="selectedAsset"
@toggle="toggleSelectedAsset"
>
<template #item="{ asset, selected, onClick }">
<BackdropItem :asset="asset" :selected="selected" @click="onClick" />
</template>
</AssetSuggestions>
<ImageSelector
:state="gen.imagesGenState"
:selected="gen.imageIndex"
:disabled="handleSubmit.isLoading.value"
@select="handleImageSelect"
>
<template #loading-item>
<BackdropImageItem loading />
</template>
<template v-else-if="gen.imagesGenState.status === 'finished'">
{{
$t({
en: 'Select the backdrop you like the most, or generate new ones.',
zh: '选择你最喜欢的一个背景,或者重新生成。'
})
}}
<template #item="{ file, active, onClick }">
<BackdropImageItem :file="file" :active="active" @click="onClick" />
</template>
</template>
</ImageSelector>
<template #tip>
<template v-if="gen.imagesGenState.status === 'running'">
{{ $t({ en: `Generating backdrops... `, zh: `正在生成背景...` }) }}
{{ gen.imagesGenState.timeLeft != null ? $t(humanizeTimeLeft(gen.imagesGenState.timeLeft)) : '' }}
</template>
<template v-else-if="gen.imagesGenState.status === 'finished'">
{{
$t({
en: 'Select the backdrop you like the most, or generate new ones.',
zh: '选择你最喜欢的一个背景,或者重新生成。'
})
}}
</template>
</template>
</ImageSelector>
</div>

<template #preview>
<ImagePreview :file="gen.image" />
</template>
</LayoutWithPreview>
<footer class="footer">
<UIButton
v-if="selectedAsset != null"
v-radar="{
name: 'Use',
desc: 'Click to use the selected library asset'
}"
color="primary"
size="large"
@click="handleUseAsset.fn"
>
{{ $t({ en: 'Use', zh: '采用' }) }}
</UIButton>
<UIButton
v-else
v-radar="{
name: 'Use',
desc: 'Finish and use the generated backdrop in the project'
Expand Down Expand Up @@ -129,6 +193,9 @@ function handleImageSelect(index: number) {
height: 300px;
}
}
.select-area {
height: 170px; // Fixed height to prevent layout shift when suggestions appear/disappear
}
}

.footer {
Expand Down
98 changes: 98 additions & 0 deletions spx-gui/src/components/asset/gen/backdrop/BackdropGenModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue'
import { UIModal, UIModalClose, useConfirmDialog } from '@/components/ui'
import { useI18n } from '@/utils/i18n'
import { useMessageHandle } from '@/utils/exception'
import { AssetType } from '@/apis/asset'
import type { Backdrop } from '@/models/spx/backdrop'
import type { BackdropGen as BackdropGenModel } from '@/models/spx/gen/backdrop-gen'
import type { SpxProject } from '@/models/spx/project'
import { useAssetGen } from '../use-asset-gen'
import BackdropGenComp from './BackdropGen.vue'

const props = withDefaults(
defineProps<{
visible: boolean
project: SpxProject
// Currently no use case for providing an external gen, but we keep this prop
// for consistency with SpriteGenModal and potential future use cases.
gen?: BackdropGenModel
}>(),
{
gen: undefined
}
)

const emit = defineEmits<{
resolved: [Backdrop]
cancelled: []
}>()

const i18n = useI18n()
const confirm = useConfirmDialog()

const typeRef = computed(() => (props.gen != null ? null : AssetType.Backdrop))
const { assetGen: internalGen } = useAssetGen(props.project, typeRef)
const activeGen = computed(() => props.gen ?? internalGen.value)

const handleModalClose = useMessageHandle(
async () => {
if (props.gen == null) {
await confirm({
title: i18n.t({ zh: '退出背景生成?', en: 'Exit backdrop generation?' }),
content: i18n.t({
zh: '当前内容不会被保存,确定要退出吗?',
en: 'Current progress will not be saved. Are you sure to exit?'
}),
confirmText: i18n.t({ en: 'Exit', zh: '退出' })
})
}
emit('cancelled')
},
{ en: 'Failed to exit modal', zh: '退出失败' }
).fn
</script>

<template>
<UIModal
:radar="{ name: 'Backdrop generation modal', desc: 'Modal for backdrop generation' }"
style="width: 1076px; height: 800px"
:visible="visible"
mask-closable
@update:visible="handleModalClose"
>
<header class="header">
<h2 class="title">{{ $t({ zh: '生成背景', en: 'Backdrop Generator' }) }}</h2>
<UIModalClose class="close" @click="handleModalClose" />
</header>

<BackdropGenComp
v-if="activeGen != null"
class="backdrop-gen"
:gen="activeGen"
library-search-enabled
@resolved="emit('resolved', $event)"
/>
</UIModal>
</template>

<style lang="scss" scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 56px;
border-bottom: 1px solid var(--ui-color-grey-400);

.title {
font-size: 16px;
color: var(--ui-color-title);
}
}

.backdrop-gen {
flex: 1 1 0;
min-height: 0;
}
</style>
Loading
Loading