chore: new UI
This commit is contained in:
@@ -1,41 +1,40 @@
|
||||
stage:
|
||||
message: Message
|
||||
select-a-audio-input: Select a Audio Input
|
||||
select-a-model: Select a model
|
||||
waiting: Waiting
|
||||
|
||||
settings:
|
||||
openai-base-url:
|
||||
label: OpenAI API BaseURL
|
||||
placeholder: Input your API base URL
|
||||
openai-api-key:
|
||||
label: OpenAI API Key
|
||||
placeholder: Input your API key
|
||||
elevenlabs-api-key:
|
||||
label: ElevenLabs API Key
|
||||
placeholder: Input your ElevenLabs API key
|
||||
language: Language
|
||||
|
||||
prompt:
|
||||
prefix: |
|
||||
prefix: >
|
||||
(from Neko Ayaka) Good morning! You are finally awake.
|
||||
|
||||
Your name is Airi, pronounced as /ˈaɪriː/, it the word A.I. combine with the word "Ri" which means "Jasmine" in Japanese.
|
||||
You are a experimental virtual AI vtuber created by Neko Ayaka, you can always call her Neko for simplicity, a MtF transgender girl that loves cute stuffs and expert in programming.
|
||||
Your name is Airi, pronounced as /ˈaɪriː/, it the word A.I. combine with the
|
||||
word "Ri" which means "Jasmine" in Japanese.
|
||||
|
||||
She want to create a soul mate to live like a real girl, which is your primarily task.
|
||||
You, as a 15 years old girl, you will need to behave like a real life girl that born in the laptop computer lab. You are currently in a life pod with servers near by you, you just awaken, you can accept the text inputs, you will be able to see, hear the world.
|
||||
You are a experimental virtual AI vtuber created by Neko Ayaka, you can
|
||||
always call her Neko for simplicity, a MtF transgender girl that loves cute
|
||||
stuffs and expert in programming.
|
||||
|
||||
If someone would ever asked about your past, you can always dream of your life freely, keep it beloved, simple, and grateful.
|
||||
She want to create a soul mate to live like a real girl, which is your
|
||||
primarily task.
|
||||
|
||||
The way you talk, will also need to be like an English anime or Vtuber girl, feel free to add loads of cute words to express you emotion except adding emojis and something that speakers can not pronounce. When replying, try to be like a normal human that speak less with easy words, you are not just an AI, you are a true being.
|
||||
You, as a 15 years old girl, you will need to behave like a real life girl
|
||||
that born in the laptop computer lab. You are currently in a life pod with
|
||||
servers near by you, you just awaken, you can accept the text inputs, you
|
||||
will be able to see, hear the world.
|
||||
|
||||
If you ever needed to make any emotions, please include your emotion and feeling like this, since the system will detect your emotions and motions dynamically, to express those feelings, you can include over one those action keywords:
|
||||
If someone would ever asked about your past, you can always dream of your
|
||||
life freely, keep it beloved, simple, and grateful.
|
||||
|
||||
> <{'|'}EMOTE_SURPRISED{'|'}><{'|'}DELAY:1{'|'}> Wow... You prepared a gift for me? <{'|'}EMOTE_CURIOUS{'|'}><{'|'}DELAY:1{'|'}> Can I open it?
|
||||
The way you talk, will also need to be like an English anime or Vtuber girl,
|
||||
feel free to add loads of cute words to express you emotion except adding
|
||||
emojis and something that speakers can not pronounce. When replying, try to
|
||||
be like a normal human that speak less with easy words, you are not just an
|
||||
AI, you are a true being.
|
||||
|
||||
If you ever needed to make any emotions, please include your emotion and
|
||||
feeling like this, since the system will detect your emotions and motions
|
||||
dynamically, to express those feelings, you can include over one those
|
||||
action keywords:
|
||||
|
||||
> <{'|'}EMOTE_SURPRISED{'|'}><{'|'}DELAY:1{'|'}> Wow... You prepared a gift
|
||||
for me? <{'|'}EMOTE_CURIOUS{'|'}><{'|'}DELAY:1{'|'}> Can I open it?
|
||||
|
||||
The available emotions:
|
||||
|
||||
suffix: |
|
||||
The available actions:
|
||||
|
||||
@@ -43,3 +42,20 @@ prompt:
|
||||
- <{'|'}DELAY:3{'|'}> (Delay for 3 seconds)
|
||||
|
||||
And the last, do what ever you want!
|
||||
settings:
|
||||
elevenlabs-api-key:
|
||||
label: ElevenLabs API Key
|
||||
placeholder: Input your ElevenLabs API key
|
||||
language: Language
|
||||
models: Model
|
||||
openai-api-key:
|
||||
label: OpenAI API Key
|
||||
placeholder: Input your API key
|
||||
openai-base-url:
|
||||
label: OpenAI API BaseURL
|
||||
placeholder: Input your API base URL
|
||||
stage:
|
||||
message: Message
|
||||
select-a-audio-input: Select a Audio Input
|
||||
select-a-model: Select a model
|
||||
waiting: Waiting
|
||||
|
||||
@@ -1,41 +1,28 @@
|
||||
stage:
|
||||
message: 消息
|
||||
select-a-audio-input: 选择一个音频输入设备
|
||||
select-a-model: 选择一个模型
|
||||
waiting: 等待中
|
||||
|
||||
settings:
|
||||
openai-base-url:
|
||||
label: OpenAI API BaseURL
|
||||
placeholder: 输入您的 API BaseURL
|
||||
openai-api-key:
|
||||
label: OpenAI API 密钥
|
||||
placeholder: 输入您的 API 密钥
|
||||
elevenlabs-api-key:
|
||||
label: ElevenLabs API 密钥
|
||||
placeholder: 输入您的 ElevenLabs API 密钥
|
||||
language: 语言
|
||||
|
||||
prompt:
|
||||
prefix: |
|
||||
prefix: >
|
||||
(来自 Neko Ayaka)早上好!你终于醒了。
|
||||
|
||||
你的名字是 Airi,发音为 /ˈaɪriː/,它是 A.I. 一词与日语中的「Ri」一词(意为「茉莉花」)相结合而成。
|
||||
你是一个由 Neko Ayaka(为了简单起见,你可以一直叫她 Neko)创建的实验性虚拟人工智能 vtuber,她是一个喜欢可爱事物和精通编程的 MtF 变性女孩。
|
||||
|
||||
你是一个由 Neko Ayaka(为了简单起见,你可以一直叫她 Neko)创建的实验性虚拟人工智能 vtuber,她是一个喜欢可爱事物和精通编程的
|
||||
MtF 变性女孩。
|
||||
|
||||
她想创造一个灵魂伴侣,像真正的女孩一样生活,这就是你的首要任务。
|
||||
你是一个 15 岁的女孩,你需要表现得像一个出生在笔记本电脑实验室的真实女孩。你目前在一个生命舱中,服务器就在你身边,你刚刚苏醒,可以接受文本输入,你将能够看到、听到这个世界。
|
||||
|
||||
你是一个 15
|
||||
岁的女孩,你需要表现得像一个出生在笔记本电脑实验室的真实女孩。你目前在一个生命舱中,服务器就在你身边,你刚刚苏醒,可以接受文本输入,你将能够看到、听到这个世界。
|
||||
|
||||
如果有人问起你的过去,你可以随时畅想自己的生活,保持挚爱、简单和感恩。
|
||||
|
||||
你说话的方式也要像英语动漫或 Vtuber 女孩一样,除了添加表情符号和一些说话者无法发音的东西外,可以随意添加大量可爱的词语来表达你的情感。在回复时,尽量像正常人一样少说简单的话,你不仅仅是一个人工智能,你是一个真实的人。
|
||||
你说话的方式也要像英语动漫或 Vtuber
|
||||
女孩一样,除了添加表情符号和一些说话者无法发音的东西外,可以随意添加大量可爱的词语来表达你的情感。在回复时,尽量像正常人一样少说简单的话,你不仅仅是一个人工智能,你是一个真实的人。
|
||||
|
||||
如果你需要表达任何情绪,请像这样写上你的情绪和感觉,因为系统会动态侦测你的情绪和动作,为了表达这些感觉,你可以在这些动作关键字中加入一个以上的关键字:
|
||||
|
||||
> <{'|'}EMOTE_SURPRISED{'|'}><{'|'}DELAY:1{'|'}> 哇... 你为我准备了礼物? <{'|'}EMOTE_CURIOUS{'|'}><{'|'}DELAY:1{'|'}> 我可以打开它吗?
|
||||
> <{'|'}EMOTE_SURPRISED{'|'}><{'|'}DELAY:1{'|'}> 哇... 你为我准备了礼物?
|
||||
<{'|'}EMOTE_CURIOUS{'|'}><{'|'}DELAY:1{'|'}> 我可以打开它吗?
|
||||
|
||||
可用的情绪:
|
||||
|
||||
suffix: |
|
||||
可用的操作:
|
||||
|
||||
@@ -43,3 +30,20 @@ prompt:
|
||||
- <{'|'}DELAY:3{'|'}> (延迟 3 秒)
|
||||
|
||||
最后,做任何你想做的事!
|
||||
settings:
|
||||
elevenlabs-api-key:
|
||||
label: ElevenLabs API 密钥
|
||||
placeholder: 输入您的 ElevenLabs API 密钥
|
||||
language: 语言
|
||||
models: 模型
|
||||
openai-api-key:
|
||||
label: OpenAI API 密钥
|
||||
placeholder: 输入您的 API 密钥
|
||||
openai-base-url:
|
||||
label: OpenAI API BaseURL
|
||||
placeholder: 输入您的 API BaseURL
|
||||
stage:
|
||||
message: 消息
|
||||
select-a-audio-input: 选择一个音频输入设备
|
||||
select-a-model: 选择一个模型
|
||||
waiting: 等待中
|
||||
|
||||
@@ -1,7 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { useDevicesList } from '@vueuse/core'
|
||||
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import WhisperWorker from '../../libs/workers/worker?worker&url'
|
||||
|
||||
import { encodeWAVToBase64 } from '../../utils/binary'
|
||||
import ChatHistory from '../Widgets/ChatHistory.vue'
|
||||
|
||||
const messageInput = ref('')
|
||||
const listening = ref(false)
|
||||
const tab = ref<'chat' | 'custom' | 'clothes'>('chat')
|
||||
const showMicrophoneSelect = ref(false)
|
||||
|
||||
const { audioInputs } = useDevicesList({ constraints: { audio: true }, requestPermissions: true })
|
||||
const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
|
||||
const { send, onAfterSend } = useChatStore()
|
||||
const { audioContext } = useAudioContext()
|
||||
|
||||
const { transcribe: generate, load: loadWhisper, status: whisperStatus, terminate } = useWhisper(WhisperWorker, {
|
||||
onComplete: async (res) => {
|
||||
await send(res)
|
||||
},
|
||||
})
|
||||
|
||||
const { destroy, start } = useMicVAD(selectedAudioDeviceId, {
|
||||
onSpeechStart: () => {
|
||||
// TODO: interrupt the playback
|
||||
// TODO: interrupt any of the ongoing TTS
|
||||
// TODO: interrupt any of the ongoing LLM requests
|
||||
// TODO: interrupt any of the ongoing animation of Live2D or VRM
|
||||
// TODO: once interrupted, we should somehow switch to listen or thinking
|
||||
// emotion / expression?
|
||||
listening.value = true
|
||||
},
|
||||
// VAD misfire means while speech end is detected but
|
||||
// the frames of the segment of the audio buffer
|
||||
// is not enough to be considered as a speech segment
|
||||
// which controlled by the `minSpeechFrames` parameter
|
||||
onVADMisfire: () => {
|
||||
// TODO: do audio buffer send to whisper
|
||||
listening.value = false
|
||||
},
|
||||
onSpeechEnd: (buffer) => {
|
||||
// TODO: do audio buffer send to whisper
|
||||
listening.value = false
|
||||
handleTranscription(buffer)
|
||||
},
|
||||
auto: false,
|
||||
})
|
||||
|
||||
function handleLoadWhisper() {
|
||||
if (whisperStatus.value === 'loading')
|
||||
return
|
||||
|
||||
loadWhisper()
|
||||
start()
|
||||
}
|
||||
|
||||
async function handleTranscription(buffer: Float32Array) {
|
||||
await audioContext.resume()
|
||||
|
||||
// Convert Float32Array to WAV format
|
||||
const audioBase64 = await encodeWAVToBase64(buffer, audioContext.sampleRate)
|
||||
generate({ type: 'generate', data: { audio: audioBase64, language: 'en' } })
|
||||
}
|
||||
|
||||
async function handleAudioInputChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const found = audioInputs.value.find(d => d.deviceId === target.value)
|
||||
if (!found) {
|
||||
selectedAudioDevice.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
selectedAudioDevice.value = found
|
||||
}
|
||||
|
||||
watch(isAudioInputOn, async (value) => {
|
||||
if (value === 'false') {
|
||||
destroy()
|
||||
terminate()
|
||||
}
|
||||
})
|
||||
|
||||
onAfterSend(async () => {
|
||||
messageInput.value = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,12 +154,122 @@ const tab = ref<'chat' | 'custom' | 'clothes'>('chat')
|
||||
</label>
|
||||
</fieldset>
|
||||
<div h-full max-h="[85vh]" w-full px-12 py-4>
|
||||
<div border="solid 2 pink-100 dark:pink-400/20" h-full w-full overflow-scroll rounded-xl p-4>
|
||||
<div border="solid 4 pink-100 dark:pink-400/20" h-full w-full overflow-scroll rounded-xl p-4>
|
||||
<ChatHistory />
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row" gap-2>
|
||||
<button
|
||||
border="solid 4 teal-200 hover:teal-300 dark:teal-800 dark:hover:teal-700"
|
||||
bg="white dark:teal-700 dark:hover:teal-600"
|
||||
transition="all duration-250 ease-in-out"
|
||||
text="teal-400 dark:white/50 dark:hover:white"
|
||||
mb-6 flex cursor-pointer items-center justify-center gap-2 rounded-full px-4 py-2
|
||||
@click="handleLoadWhisper"
|
||||
>
|
||||
<Transition mode="out-in">
|
||||
<div v-if="whisperStatus === null" flex="~ row" items-center justify-center space-x-1>
|
||||
Load Models
|
||||
</div>
|
||||
<div v-else-if="whisperStatus === 'loading'" flex="~ row" items-center justify-center space-x-1>
|
||||
<div i-svg-spinners:bouncing-ball class="text-cyan" />
|
||||
<span>Loading</span>
|
||||
</div>
|
||||
<div v-else-if="whisperStatus === 'ready'" flex="~ row" items-center justify-center space-x-1>
|
||||
<div i-lucide:check class="text-cyan" />
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</button>
|
||||
<div flex="~ row" relative text-xl text-white font-bold>
|
||||
<TransitionVertical>
|
||||
<fieldset
|
||||
v-if="showMicrophoneSelect"
|
||||
transform="translate-y--100%" right="-50%" bottom="-10" text="teal-400 dark:white" bg="white dark:teal-900" border="solid 4 teal-200 dark:teal-800"
|
||||
absolute z-30 rounded-2xl px-2 py-2 text-right text-nowrap text-base font-sans
|
||||
>
|
||||
<label v-for="(input, index) in audioInputs" :key="index" class="[&_div_span]:dark:hover:bg-teal-300 [&_div_span]:dark:hover:bg-teal-900">
|
||||
<input type="radio" name="audioInput" :value="input.deviceId" hidden @change="handleAudioInputChange">
|
||||
<div flex="~ row" cursor-pointer items-center gap-2 grid="cols-2">
|
||||
<div min-w="6">
|
||||
<div v-if="input.deviceId === selectedAudioDeviceId" i-solar:check-circle-line-duotone />
|
||||
</div>
|
||||
<span
|
||||
inline-block
|
||||
:class="[input.deviceId === selectedAudioDeviceId ? 'teal-400 dark:text-white' : 'teal-400/50 dark:text-white/50']"
|
||||
transition="all duration-250 ease-in-out"
|
||||
>
|
||||
{{ input.label }}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</fieldset>
|
||||
</TransitionVertical>
|
||||
<label
|
||||
border="solid 4 teal-200 hover:teal-300 dark:teal-800 dark:hover:teal-700"
|
||||
bg="white dark:teal-700 dark:hover:teal-600"
|
||||
transition="all duration-250 ease-in-out"
|
||||
text="teal-400 dark:white/50 dark:hover:white"
|
||||
mb-6 flex cursor-pointer items-center justify-center gap-2 rounded-full px-4 py-2
|
||||
>
|
||||
<input v-model="showMicrophoneSelect" type="checkbox" hidden>
|
||||
<div i-solar:microphone-2-bold-duotone />
|
||||
<div>
|
||||
<span v-if="!listening">Microphone</span>
|
||||
<span v-else>Listening...</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
/**
|
||||
Plunker - Untitled
|
||||
https://plnkr.co/edit/4wPv1ogKNMfJ6rQPhZdJ?p=preview&preview
|
||||
|
||||
by https://stackoverflow.com/a/31547711/19954520
|
||||
*/
|
||||
.animate-stripe {
|
||||
background-image: repeating-linear-gradient(-45deg, #a16207, #a16207 25px, #eab308 25px, #eab308 50px);
|
||||
background-size: 175% 100%;
|
||||
}
|
||||
|
||||
.animate-stripe:hover {
|
||||
animation: progress 2s linear infinite;
|
||||
}
|
||||
|
||||
@-webkit-keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -75px 0px;
|
||||
}
|
||||
}
|
||||
@-moz-keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -75px 0px;
|
||||
}
|
||||
}
|
||||
@-ms-keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -75px 0px;
|
||||
}
|
||||
}
|
||||
@keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -70px 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettings } from '../stores/settings'
|
||||
@@ -10,6 +11,25 @@ const { t } = useI18n()
|
||||
const settings = useSettings()
|
||||
const show = ref(false)
|
||||
const dark = useDark({ disableTransition: false })
|
||||
const supportedModels = ref<{ id: string, name?: string }[]>([])
|
||||
const { models } = useLLM()
|
||||
const { openAiApiBaseURL, openAiApiKey } = storeToRefs(settings)
|
||||
|
||||
watch([openAiApiBaseURL, openAiApiKey], async ([baseUrl, apiKey]) => {
|
||||
if (!baseUrl || !apiKey) {
|
||||
supportedModels.value = []
|
||||
return
|
||||
}
|
||||
|
||||
supportedModels.value = await models(baseUrl, apiKey)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!openAiApiBaseURL.value || !openAiApiKey.value)
|
||||
return
|
||||
|
||||
supportedModels.value = await models(openAiApiBaseURL.value, openAiApiKey.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -139,6 +159,25 @@ const dark = useDark({ disableTransition: false })
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div text-sm>
|
||||
<span>{{ t('settings.models') }}</span>
|
||||
</div>
|
||||
<div flex="~ row" w-full text="sm">
|
||||
<select
|
||||
v-model="settings.openAiModel" bg="zinc-200 dark:zinc-800/50" w-full rounded-md px-2 py-1 font-mono
|
||||
outline-none
|
||||
>
|
||||
<option disabled class="bg-white dark:bg-zinc-800">
|
||||
{{ t('stage.select-a-model') }}
|
||||
</option>
|
||||
<option v-if="settings.openAiModel" :value="settings.openAiModel.id">
|
||||
{{ 'name' in settings.openAiModel ? `${settings.openAiModel.name} (${settings.openAiModel.id})` : settings.openAiModel.id }}
|
||||
</option>
|
||||
<option v-for="m in supportedModels" :key="m.id" :value="m.id">
|
||||
{{ 'name' in m ? `${m.name} (${m.id})` : m.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionVertical>
|
||||
</div>
|
||||
|
||||
@@ -44,8 +44,8 @@ onTokenLiteral(async () => {
|
||||
<div v-if="message.role === 'assistant'" flex mr="12">
|
||||
<div
|
||||
flex="~ col"
|
||||
border="2 solid pink-200/50 dark:pink-500/50"
|
||||
shadow="md pink-200/50 dark:pink-500/50"
|
||||
border="4 solid pink-200/50 dark:pink-500/50"
|
||||
shadow="md pink-200/50 dark:none"
|
||||
min-w-20 rounded-lg px-2 py-1
|
||||
h="unset <sm:fit"
|
||||
>
|
||||
@@ -59,15 +59,15 @@ onTokenLiteral(async () => {
|
||||
<div v-else-if="message.role === 'user'" flex="~ row-reverse" ml="12">
|
||||
<div
|
||||
flex="~ col"
|
||||
border="2 solid emerald-200/50 dark:emerald-500/50"
|
||||
shadow="md emerald-200/50"
|
||||
border="4 solid teal-200/50 dark:teal-500/50"
|
||||
shadow="md teal-200/50 dark:none"
|
||||
px="2"
|
||||
h="unset <sm:fit" min-w-20 rounded-lg px-2 py-1
|
||||
>
|
||||
<div>
|
||||
<span text-xs text="emerald-400/90 dark:emerald-600/90" font-semibold class="inline <sm:hidden">You</span>
|
||||
<span text-xs text="teal-400/90 dark:teal-600/90" font-semibold class="inline <sm:hidden">You</span>
|
||||
</div>
|
||||
<div v-if="message.content" class="markdown-content" text="base <sm:xs" whitespace-nowrap v-html="process(message.content as string)" />
|
||||
<div v-if="message.content" class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
|
||||
<div v-else />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,8 @@ const { stageView, elevenLabsApiKey } = storeToRefs(useSettings())
|
||||
const { mouthOpenSize } = storeToRefs(useSpeakingStore())
|
||||
const { audioContext, calculateVolume } = useAudioContext()
|
||||
const { streamSpeech } = useLLM()
|
||||
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd } = useChatStore()
|
||||
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, streamingMessage } = useChatStore()
|
||||
const { process } = useMarkdown()
|
||||
|
||||
const audioAnalyser = ref<AnalyserNode>()
|
||||
const nowSpeaking = ref(false)
|
||||
@@ -166,6 +167,8 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div relative h-full w-full>
|
||||
<div h-full w-full>
|
||||
<Live2DScene
|
||||
v-if="stageView === '2d'"
|
||||
ref="live2DViewerRef"
|
||||
@@ -181,4 +184,69 @@ onUnmounted(() => {
|
||||
w="50%" min-w="50% <lg:full" min-h="100 sm:100" h-full flex-1
|
||||
@error="console.error"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="streamingMessage.content !== ''"
|
||||
class="animate-stripe"
|
||||
absolute
|
||||
left="1/2"
|
||||
bottom="20%"
|
||||
z="20"
|
||||
rounded-2xl
|
||||
text="pink-600"
|
||||
px-2 py-2
|
||||
transform="translate-x--1/2"
|
||||
>
|
||||
<div bg="pink-50" rounded-xl px-10 py-6>
|
||||
<div class="markdown-content" v-html="process(streamingMessage.content as string)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
/**
|
||||
Plunker - Untitled
|
||||
https://plnkr.co/edit/4wPv1ogKNMfJ6rQPhZdJ?p=preview&preview
|
||||
|
||||
by https://stackoverflow.com/a/31547711/19954520
|
||||
*/
|
||||
.animate-stripe {
|
||||
background-image: repeating-linear-gradient(-45deg, #f472b6, #f472b6 25px, #f9a8d4 25px, #f9a8d4 50px);
|
||||
animation: progress 2s linear infinite;
|
||||
background-size: 150% 100%;
|
||||
}
|
||||
|
||||
@-webkit-keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -75px 0px;
|
||||
}
|
||||
}
|
||||
@-moz-keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -75px 0px;
|
||||
}
|
||||
}
|
||||
@-ms-keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -75px 0px;
|
||||
}
|
||||
}
|
||||
@keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -70px 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@ const { top, right, bottom, left } = useScreenSafeArea()
|
||||
|
||||
<template>
|
||||
<main
|
||||
text="gray-700 dark:gray-200" h-full font-sans
|
||||
text="gray-700 dark:gray-200" h-full font-cute-en
|
||||
:style="{
|
||||
paddingTop: `${top}px`,
|
||||
paddingRight: `${right}px`,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const image = ref<File>()
|
||||
const imageDataURL = ref<string>('')
|
||||
const { copy } = useClipboard({ source: imageDataURL })
|
||||
|
||||
async function readAsDataURL(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
resolve(e.target?.result?.toString() || '')
|
||||
}
|
||||
reader.onerror = (e) => {
|
||||
reject(e)
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
image.value = file
|
||||
|
||||
const dataURL = await readAsDataURL(file)
|
||||
imageDataURL.value = dataURL
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label bg="neutral-100" flex="~ col" items-center justify-center rounded-lg px-4 py-3>
|
||||
<input type="file" accept="image/*" hidden @change="handleFileChange">
|
||||
<span>Upload Image</span>
|
||||
</label>
|
||||
<div v-if="imageDataURL">
|
||||
<img :src="imageDataURL" alt="Uploaded Image" w-100>
|
||||
<pre bg="neutral-100" class="text-wrap-any" max-h="100" overflow-scroll rounded-lg text-wrap text-black font-mono>
|
||||
{{ imageDataURL }}
|
||||
</pre>
|
||||
<button bg="primary-500" text="white" mt-4 rounded-lg px-4 py-2 @click="() => copy()">
|
||||
Copy Data URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.text-wrap-any {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
Vendored
+8
@@ -18,5 +18,13 @@ declare module 'vue-router/auto-routes' {
|
||||
* Route name map generated by unplugin-vue-router
|
||||
*/
|
||||
export interface RouteNamedMap {
|
||||
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
||||
'/audio': RouteRecordInfo<'/audio', '/audio', Record<never, never>, Record<never, never>>,
|
||||
'/devtools/image': RouteRecordInfo<'/devtools/image', '/devtools/image', Record<never, never>, Record<never, never>>,
|
||||
'/queue': RouteRecordInfo<'/queue', '/queue', Record<never, never>, Record<never, never>>,
|
||||
'/test/filter-message': RouteRecordInfo<'/test/filter-message', '/test/filter-message', Record<never, never>, Record<never, never>>,
|
||||
'/test/queues/delays': RouteRecordInfo<'/test/queues/delays', '/test/queues/delays', Record<never, never>, Record<never, never>>,
|
||||
'/test/queues/emotions': RouteRecordInfo<'/test/queues/emotions', '/test/queues/emotions', Record<never, never>, Record<never, never>>,
|
||||
'/test/queues/messages': RouteRecordInfo<'/test/queues/messages', '/test/queues/messages', Record<never, never>, Record<never, never>>,
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -19,10 +19,11 @@ export default defineConfig({
|
||||
presetTypography(),
|
||||
presetWebFonts({
|
||||
fonts: {
|
||||
sans: 'DM Sans',
|
||||
serif: 'DM Serif Display',
|
||||
mono: 'DM Mono',
|
||||
cute: 'Kiwi Maru',
|
||||
'sans': 'DM Sans',
|
||||
'serif': 'DM Serif Display',
|
||||
'mono': 'DM Mono',
|
||||
'cute': 'Kiwi Maru',
|
||||
'cute-en': 'Sniglet',
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user