Files
xsh-assistant-next/pages/index.vue
2024-05-10 13:43:36 +08:00

416 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import OptionBlock from '~/components/aigc/drawing/OptionBlock.vue';
import ResultBlock from '~/components/aigc/drawing/ResultBlock.vue';
import {useLoginState} from '~/composables/useLoginState';
import ModalAuthentication from '~/components/ModalAuthentication.vue';
import {type InferType, number, object, string} from 'yup';
import type {FormSubmitEvent} from '#ui/types';
import RatioSelector from '~/components/aigc/RatioSelector.vue';
import {useFetchWrapped} from '~/composables/useFetchWrapped';
import type {ResultBlockMeta} from '~/components/aigc/drawing';
import {useHistory} from '~/composables/useHistory';
import {del, set} from 'idb-keyval';
import ReferenceFigureSelector from '~/components/aigc/ReferenceFigureSelector.vue';
useSeoMeta({
title: '绘画',
})
const toast = useToast()
const modal = useModal()
const dayjs = useDayjs()
const history = useHistory()
const loginState = useLoginState()
const leftSection = ref<HTMLElement | null>(null)
const leftHandler = ref<HTMLElement | null>(null)
const showSidebar = ref(false)
const generating = ref(false)
const handle_stick_mousedown = (e: MouseEvent, min: number = 240, max: number = 400) => {
const handler = leftHandler.value
if (handler) {
const startX = e.clientX
const startWidth = handler.parentElement?.offsetWidth || 0
const handle_mousemove = (e: MouseEvent) => {
let newWidth = startWidth + e.clientX - startX
if (newWidth < min || newWidth > max) {
newWidth = Math.min(Math.max(newWidth, min), max)
}
handler.parentElement!.style.width = `${newWidth}px`
}
const handle_mouseup = () => {
leftSection.value?.classList.add('transition-all')
leftHandler.value?.lastElementChild?.classList.remove('bg-indigo-300', 'dark:bg-indigo-700', 'w-[3px]')
window.removeEventListener('mousemove', handle_mousemove)
window.removeEventListener('mouseup', handle_mouseup)
}
leftSection.value?.classList.remove('transition-all')
leftHandler.value?.lastElementChild?.classList.add('bg-indigo-300', 'dark:bg-indigo-700', 'w-[3px]')
window.addEventListener('mousemove', handle_mousemove)
window.addEventListener('mouseup', handle_mouseup)
}
}
const defaultRatios = [
{
ratio: '1:1',
value: '768:768',
},
{
ratio: '4:3',
value: '1024:768',
},
{
ratio: '3:4',
value: '768:1024',
},
]
interface StyleItem {
label: string
value: number
avatar?: { src: string }
}
const defaultStyles: StyleItem[] = [
{
label: '通用写实风格',
value: 401,
},
{
label: '日系动漫',
value: 201,
},
{
label: '科幻风格',
value: 114,
},
{
label: '怪兽风格',
value: 202,
},
{
label: '唯美古风',
value: 203,
},
{
label: '复古动漫',
value: 204,
},
{
label: '游戏卡通手绘',
value: 301,
},
{
label: '水墨画',
value: 101,
},
{
label: '概念艺术',
value: 102,
},
{
label: '水彩画',
value: 104,
},
{
label: '像素画',
value: 105,
},
{
label: '厚涂风格',
value: 106,
},
{
label: '插图',
value: 107,
},
{
label: '剪纸风格',
value: 108,
},
{
label: '印象派',
value: 119,
},
{
label: '印象派(莫奈)',
value: 109,
},
{
label: '油画',
value: 103,
},
{
label: '油画(梵高)',
value: 118,
},
{
label: '古典肖像画',
value: 111,
},
{
label: '黑白素描画',
value: 112,
},
{
label: '赛博朋克',
value: 113,
},
{
label: '暗黑风格',
value: 115,
},
{
label: '蒸汽波',
value: 117,
},
{
label: '2.5D',
value: 110,
},
{
label: '3D',
value: 116,
},
]
const img2imgStyles: StyleItem[] = [
{
label: '水彩画',
value: 106,
},
{
label: '2.5D',
value: 110,
},
{
label: '日系动漫',
value: 201,
},
{
label: '美系动漫',
value: 202,
},
{
label: '唯美古风',
value: 203,
},
]
const defaultFormSchema = object({
prompt: string().required('请输入提示词'),
negative_prompt: string(),
resolution: string().required('请选择分辨率'),
styles: object<StyleItem>({
label: string(),
value: number(),
}).required('请选择风格'),
file: string().nullable(),
})
type DefaultFormSchema = InferType<typeof defaultFormSchema>
const defaultFormState = reactive({
prompt: '',
negative_prompt: '',
resolution: '1024:768',
styles: defaultStyles.find(item => item.value === 401),
file: null,
})
watch(() => defaultFormState.file, (newVal) => {
if (newVal) {
defaultFormState.styles = img2imgStyles[0]
} else {
defaultFormState.styles = defaultStyles.find(item => item.value === 401)
}
})
const onDefaultFormSubmit = (event: FormSubmitEvent<DefaultFormSchema>) => {
if (!loginState.is_logged_in) {
modal.open(ModalAuthentication)
return
}
generating.value = true
const styleItem = event.data.styles as StyleItem
if (!event.data.file) delete event.data.file
// generate a uuid
const fid = Math.random().toString(36).substring(2)
const meta: ResultBlockMeta = {
cost: '1000',
modal: '混元大模型',
style: styleItem.label,
ratio: event.data.resolution,
datetime: dayjs().unix(),
type: event.data.file ? '智能图生图' : '智能文生图',
}
history.text2img.unshift({
fid,
meta,
prompt: event.data.prompt,
})
useFetchWrapped<
(HunYuan.Text2Img.req | HunYuan.Img2Img.req) & AuthedRequest,
BaseResponse<HunYuan.resp>
>(event.data.file ? 'App.Assistant_HunYuan.TenImgToImg' : 'App.Assistant_HunYuan.TenTextToImg', {
token: loginState.token as string,
user_id: loginState.user.id,
device_id: 'web',
...event.data,
styles: styleItem.value,
}).then(res => {
if (res.ret !== 200) {
toast.add({
title: '生成失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-circle-x',
})
history.text2img = history.text2img.filter(item => item.fid !== fid)
return
}
history.text2img = history.text2img.map(item => {
if (item.fid === fid) {
set(`${item.fid}`, [`data:image/png;base64,${res.data.request_image}`])
item.meta = {
...item.meta,
id: res.data.data_id as string,
}
}
return item
})
}).catch(err => {
toast.add({
title: '生成失败',
description: err.msg || '网络错误',
color: 'red',
icon: 'i-tabler-circle-x',
})
}).finally(() => {
generating.value = false
})
}
</script>
<template>
<div class="w-full flex relative">
<div ref="leftSection"
class="absolute -translate-x-full md:sticky md:translate-x-0 z-10 md:block h-[calc(100vh-4rem)] bg-neutral-200 dark:bg-neutral-800 transition-all"
style="width: 320px" :class="{'translate-x-0': showSidebar}">
<div ref="leftHandler"
class="absolute inset-0 left-auto hidden xl:flex flex-col justify-center items-center cursor-ew-resize px-1 group"
@dblclick="leftSection?.style.setProperty('width', '320px')"
@mousedown.prevent="handle_stick_mousedown">
<span
class="w-[1px] h-full bg-neutral-300 dark:bg-neutral-700 group-hover:bg-indigo-300 dark:group-hover:bg-indigo-700 group-hover:w-[3px] transition-all group-hover:delay-500 translate-x-1"></span>
</div>
<div
class="absolute bottom-28 -right-12 w-12 h-12 z-10 bg-neutral-100 dark:bg-neutral-900 rounded-r-lg shadow-lg flex md:hidden justify-center items-center">
<UButton color="black" icon="i-tabler-brush" size="lg" square @click="showSidebar = !showSidebar"></UButton>
</div>
<div class="h-full flex flex-col overflow-y-auto">
<UForm :schema="defaultFormSchema" :state="defaultFormState" @submit="onDefaultFormSubmit">
<div class="flex flex-col gap-2 p-4 pb-28">
<OptionBlock comment="Prompts" icon="i-tabler-article" label="提示词">
<UFormGroup name="prompt">
<UTextarea v-model="defaultFormState.prompt" :rows="2" autoresize
placeholder="请输入提示词,每个提示词之间用英文逗号隔开" resize/>
</UFormGroup>
</OptionBlock>
<OptionBlock comment="Negative Prompts" icon="i-tabler-article-off" label="负面提示词">
<UFormGroup name="negative_prompt">
<UTextarea v-model="defaultFormState.negative_prompt" :rows="2" autoresize
placeholder="请输入作品中不要出现的提示词,每个提示词之间用英文逗号隔开"
resize/>
</UFormGroup>
</OptionBlock>
<OptionBlock icon="i-tabler-library-photo" label="参考图片">
<UFormGroup name="input_image">
<ReferenceFigureSelector
:value="defaultFormState.file"
@update="file => {defaultFormState.file = file}"
text="选择参考图片" text-on-select="已选择参考图"/>
</UFormGroup>
</OptionBlock>
<OptionBlock icon="i-tabler-photo-hexagon" label="图片风格">
<UFormGroup name="styles">
<USelectMenu v-model="defaultFormState.styles"
:options="defaultFormState.file ? img2imgStyles : defaultStyles"></USelectMenu>
</UFormGroup>
</OptionBlock>
<OptionBlock icon="i-tabler-article-off" label="图片比例">
<UFormGroup name="resolution">
<RatioSelector v-model="defaultFormState.resolution" :ratios="defaultRatios"/>
</UFormGroup>
</OptionBlock>
</div>
<div class="absolute bottom-0 inset-x-0 flex flex-col items-center gap-2
bg-neutral-200 dark:bg-neutral-800 p-4 border-t border-neutral-400
dark:border-neutral-700">
<UButton type="submit" color="indigo" size="lg" class="font-bold" :loading="generating" block>
{{ generating ? '生成中' : '生成' }}
</UButton>
<p class="text-xs text-neutral-400 dark:text-neutral-500 font-bold">
生成即代表您同意<a href="#" target="_blank"
class="underline underline-offset-2">用户许可协议</a>
</p>
</div>
</UForm>
</div>
</div>
<ClientOnly>
<div class="flex-1 h-screen flex flex-col gap-4 bg-neutral-100 dark:bg-neutral-900 p-4 pb-20 overflow-y-auto">
<div v-if="!loginState.is_logged_in"
class="w-full h-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900">
<Icon name="i-tabler-user-circle" class="text-7xl text-neutral-300 dark:text-neutral-700"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">请登录后使用</p>
<UButton class="mt-2 font-bold" @click="modal.open(ModalAuthentication)" color="black" variant="solid"
size="xs">
登录
</UButton>
</div>
<div v-else-if="history.text2img.length === 0"
class="w-full h-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900">
<Icon name="i-tabler-photo-hexagon" class="text-7xl text-neutral-300 dark:text-neutral-700"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">没有记录</p>
</div>
<ResultBlock v-else v-for="(result, k) in history.text2img" :fid="result.fid" :meta="result.meta"
:prompt="result.prompt" :key="result.fid"
@use-reference="file => {defaultFormState.file = file}">
<template #header-right>
<UPopover overlay>
<UButton color="black" size="xs" icon="i-tabler-trash" variant="ghost"></UButton>
<template #panel="{close}">
<div class="p-4 flex flex-col gap-4">
<h2 class="text-sm">删除后无法恢复,确定删除?</h2>
<div class="flex items-center justify-end gap-2">
<UButton color="gray" size="xs" class="font-bold" @click="close">
取消
</UButton>
<UButton color="red" size="xs" class="font-bold"
@click="() => {
history.text2img.splice(k, 1)
del(result.fid)
close()
}">
仍然删除
</UButton>
</div>
</div>
</template>
</UPopover>
</template>
</ResultBlock>
<div class="flex justify-center items-center gap-1 text-neutral-400 dark:text-neutral-600">
<UIcon name="i-tabler-info-triangle"/>
<p class="text-xs font-bold">所有图片均为 AI 生成服务器不会保存任何图像数据仅保存在浏览器本地</p>
</div>
</div>
</ClientOnly>
</div>
</template>
<style scoped>
</style>