Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Remove an avatar #371

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
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
8 changes: 6 additions & 2 deletions srv/db/characters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,14 @@ export async function createCharacter(

export async function updateCharacter(id: string, userId: string, char: CharacterUpdate) {
const edit = { ...char, updatedAt: now() }
if (edit.avatar === undefined) {
const clearAvatar = edit.avatar === ''
if (!edit.avatar) {
delete edit.avatar
}
await db('character').updateOne({ _id: id, userId, kind: 'character' }, { $set: edit })
await db('character').updateOne(
{ _id: id, userId, kind: 'character' },
{ $set: edit, $unset: clearAvatar ? { avatar: 1 } : {} }
)
return getCharacter(userId, id)
}

Expand Down
87 changes: 61 additions & 26 deletions web/pages/Character/CreateCharacter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Show,
Switch,
} from 'solid-js'
import { Save, X } from 'lucide-solid'
import { Save, Trash, X } from 'lucide-solid'
import Button from '../../shared/Button'
import PageHeader from '../../shared/PageHeader'
import TextInput from '../../shared/TextInput'
Expand Down Expand Up @@ -60,9 +60,8 @@ const CreateCharacter: Component = () => {
const srcId = params.editId || params.duplicateId || ''
const state = characterStore((s) => {
const edit = s.characters.list.find((ch) => ch._id === srcId)
setImage(edit?.avatar)
return {
avatar: s.generate,
generatedAvatar: s.generate,
creating: s.creating,
edit,
list: s.characters.list,
Expand Down Expand Up @@ -92,11 +91,21 @@ const CreateCharacter: Component = () => {
const [schema, setSchema] = createSignal<AppSchema.Persona['kind'] | undefined>()
const [tags, setTags] = createSignal(state.edit?.tags)
const [avatar, setAvatar] = createSignal<File>()
const [clearAvatarFlag, setClearAvatarFlag] = createSignal(false)
const [voice, setVoice] = createSignal<VoiceSettings>({ service: undefined })
const [culture, setCulture] = createSignal(defaultCulture)
const edit = createMemo(() => state.edit)
const nav = useNavigate()

createEffect(
on(
() => edit()?.avatar,
(avatar) => {
setImage(avatar)
}
)
)

createEffect(
on(edit, (edit) => {
if (!edit) return
Expand All @@ -112,6 +121,7 @@ const CreateCharacter: Component = () => {
})

const updateFile = async (files: FileInputResult[]) => {
setClearAvatarFlag(false)
if (!files.length) {
setAvatar()
setImage(state.edit?.avatar)
Expand All @@ -125,7 +135,15 @@ const CreateCharacter: Component = () => {
setImage(data)
}

const clearAvatar = () => {
setClearAvatarFlag(true)
setAvatar(undefined)
setImage(undefined)
characterStore.clearGeneratedAvatar()
}

const generateAvatar = async () => {
setClearAvatarFlag(false)
const { imagePrompt } = getStrictForm(ref, { imagePrompt: 'string' })
if (!user) {
toastStore.error(`Image generation settings missing`)
Expand Down Expand Up @@ -162,17 +180,18 @@ const CreateCharacter: Component = () => {
attributes,
}

const payload = {
const payload: NewCharacter = {
name: body.name,
description: body.description,
culture: body.culture,
tags: tags(),
scenario: body.scenario,
avatar: state.avatar.blob || avatar(),
avatar: state.generatedAvatar.blob || avatar(),
greeting: body.greeting,
sampleChat: body.sampleChat,
persona,
originalAvatar: state.edit?.avatar,
clearAvatar: clearAvatarFlag(),
voice: voice(),
}

Expand Down Expand Up @@ -232,43 +251,59 @@ const CreateCharacter: Component = () => {

<div class="flex w-full gap-2">
<Switch>
<Match when={!state.avatar.loading}>
<Match when={!state.generatedAvatar.loading}>
<div
class="flex items-center"
style={{ cursor: state.avatar.image || image() ? 'pointer' : 'unset' }}
onClick={() => settingStore.showImage(state.avatar.image || image())}
style={{ cursor: state.generatedAvatar.image || image() ? 'pointer' : 'unset' }}
onClick={() => settingStore.showImage(state.generatedAvatar.image || image())}
>
<AvatarIcon
format={{ corners: 'md', size: '2xl' }}
avatarUrl={state.avatar.image || image()}
avatarUrl={state.generatedAvatar.image || image()}
/>
</div>
</Match>
<Match when={state.avatar.loading}>
<Match when={state.generatedAvatar.loading}>
<div class="flex w-[80px] items-center justify-center">
<Loading />
</div>
</Match>
</Switch>
<div class="flex w-full flex-col gap-2">
<FileInput
class="w-full"
fieldName="avatar"
label="Avatar"
helperText='Use the "appearance" attribute in your persona to influence the generated images'
accept="image/png,image/jpeg"
onUpdate={updateFile}
/>
<div class="flex gap-2">
<TextInput
fieldName="imagePrompt"
placeholder='Image prompt: Leave empty to use "looks / "appearance"'
<Show when={!image()}>
<div class="flex w-full flex-col gap-2">
<FileInput
class="w-full"
fieldName="avatar"
label="Avatar"
helperText='Use the "appearance" attribute in your persona to influence the generated images'
accept="image/png,image/jpeg"
onUpdate={updateFile}
/>
<Button class="w-fit" onClick={generateAvatar}>
Generate
<div class="flex gap-2">
<TextInput
fieldName="imagePrompt"
placeholder='Image prompt: Leave empty to use "looks / "appearance"'
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault()
generateAvatar()
}
}}
/>
<Button class="w-fit" onClick={generateAvatar}>
Generate
</Button>
</div>
</div>
</Show>
<Show when={image()}>
<div class="py-5 pl-4">
<Button schema="secondary" onClick={() => clearAvatar()}>
<Trash />
Remove Avatar
</Button>
</div>
</div>
</Show>
</div>

<Select
Expand Down
3 changes: 1 addition & 2 deletions web/shared/AvatarIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,11 @@ const AvatarIcon: Component<Props> = (props) => {
const fmtCorners = createMemo(() => corners[format().corners])

createEffect(async () => {
if (!props.avatarUrl) return
if (props.avatarUrl instanceof File) {
const data = await getImageData(props.avatarUrl)
setAvatar(data!)
} else {
setAvatar(props.avatarUrl)
setAvatar(props.avatarUrl || null)
}
})

Expand Down
5 changes: 5 additions & 0 deletions web/shared/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const TextInput: Component<{
required?: boolean
class?: string
pattern?: string
onKeyDown?: (
ev: KeyboardEvent & { target: Element; currentTarget: HTMLInputElement | HTMLTextAreaElement }
) => void
onKeyUp?: (
ev: KeyboardEvent & { target: Element; currentTarget: HTMLInputElement | HTMLTextAreaElement }
) => void
Expand Down Expand Up @@ -73,6 +76,7 @@ const TextInput: Component<{
placeholder={placeholder()}
value={value()}
class={'form-field focusable-field w-full rounded-xl px-4 py-2 ' + props.class}
onkeydown={(ev) => props.onKeyDown?.(ev)}
onkeyup={(ev) => props.onKeyUp?.(ev)}
onchange={(ev) => props.onChange?.(ev)}
disabled={props.disabled}
Expand All @@ -92,6 +96,7 @@ const TextInput: Component<{
props.class
}
disabled={props.disabled}
onkeydown={(ev) => props.onKeyDown?.(ev)}
onKeyUp={(ev) => props.onKeyUp?.(ev)}
onInput={resize}
/>
Expand Down
4 changes: 3 additions & 1 deletion web/store/character.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type NewCharacter = UpdateCharacter &
export type UpdateCharacter = Partial<
Omit<AppSchema.Character, '_id' | 'kind' | 'userId' | 'createdAt' | 'updatedAt' | 'avatar'> & {
avatar?: File
clearAvatar?: boolean
}
>

Expand Down Expand Up @@ -102,7 +103,7 @@ export const characterStore = createStore<CharacterState>(
toastStore.success(`Successfully updated character`)
yield {
characters: {
list: list.map((ch) => (ch._id === characterId ? { ...ch, ...res.result } : ch)),
list: list.map((ch) => (ch._id === characterId ? res.result : ch)),
loaded: true,
},
}
Expand Down Expand Up @@ -193,6 +194,7 @@ export const characterStore = createStore<CharacterState>(
})

subscribe('image-generated', { image: 'string' }, async (body) => {
if (!characterStore.getState().generate.loading) return
const image = await fetch(getAssetUrl(body.image)).then((res) => res.blob())
const file = new File([image], `avatar.png`, { type: 'image/png' })
characterStore.setState({ generate: { image: body.image, loading: false, blob: file } })
Expand Down
1 change: 1 addition & 0 deletions web/store/data/chars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export async function editCharacter(charId: string, { avatar: file, ...char }: U
appendFormOptional(form, 'sampleChat', char.sampleChat)
appendFormOptional(form, 'voice', JSON.stringify(char.voice))
appendFormOptional(form, 'avatar', file)
appendFormOptional(form, 'clearAvatar', char.clearAvatar)

const res = await api.upload(`/character/${charId}`, form)
return res
Expand Down