init: init

Signed-off-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
Neko Ayaka
2024-12-02 00:31:36 +08:00
commit d9ae0aae38
32 changed files with 12975 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
open_collective: antfu
github: [antfu]
+43
View File
@@ -0,0 +1,43 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v3
with:
node-version: lts/*
cache: pnpm
- name: Install
run: pnpm install
- name: Lint
run: pnpm run lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v3
with:
node-version: lts/*
cache: pnpm
- name: Install
run: pnpm install
- name: Typecheck
run: pnpm run typecheck
+7
View File
@@ -0,0 +1,7 @@
node_modules
*.log
dist
.output
.nuxt
.env
.idea/
+3
View File
@@ -0,0 +1,3 @@
shamefully-hoist=true
strict-peer-dependencies=false
shell-emulator=true
+4
View File
@@ -0,0 +1,4 @@
{
"installDependencies": true,
"startCommand": "npm run dev"
}
+10
View File
@@ -0,0 +1,10 @@
{
"recommendations": [
"antfu.iconify",
"antfu.unocss",
"antfu.goto-alias",
"csstools.postcss",
"dbaeumer.vscode-eslint",
"vue.volar"
]
}
+45
View File
@@ -0,0 +1,45 @@
{
"files.associations": {
"*.css": "postcss"
},
// Enable the ESlint flat config support
"eslint.experimental.useFlatConfig": true,
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off" },
{ "rule": "*-indent", "severity": "off" },
{ "rule": "*-spacing", "severity": "off" },
{ "rule": "*-spaces", "severity": "off" },
{ "rule": "*-order", "severity": "off" },
{ "rule": "*-dangle", "severity": "off" },
{ "rule": "*-newline", "severity": "off" },
{ "rule": "*quotes", "severity": "off" },
{ "rule": "*semi", "severity": "off" }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml"
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-PRESENT Anthony Fu<https://github.com/antfu>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+80
View File
@@ -0,0 +1,80 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/11247099/140462375-7b7ac4db-35b7-453c-8a05-13d8d20282c4.png" width="600"/>
</p>
<h2 align="center">
<a href="https://github.com/antfu/vitesse">Vitesse</a> for Nuxt 3
</h2><br>
<p align="center">
<br>
<a href="https://vitesse-nuxt3.netlify.app/">🖥 Online Preview</a>
<br><br>
<a href="https://stackblitz.com/github/antfu/vitesse-nuxt3"><img src="https://developer.stackblitz.com/img/open_in_stackblitz.svg" alt=""></a>
</p>
## Features
- 💚 [Nuxt 3](https://nuxt.com/) - SSR, ESR, File-based routing, components auto importing, modules, etc.
- ⚡️ Vite - Instant HMR.
- 🎨 [UnoCSS](https://github.com/unocss/unocss) - The instant on-demand atomic CSS engine.
- 😃 Use icons from any icon sets in Pure CSS, powered by [UnoCSS](https://github.com/unocss/unocss).
- 🔥 The `<script setup>` syntax.
- 🍍 [State Management via Pinia](https://github.com/vuejs/pinia), see [./composables/user.ts](./composables/user.ts).
- 📑 [Layout system](./layouts).
- 📥 APIs auto importing - for Composition API, VueUse and custom composables.
- 🏎 Zero-config cloud functions and deploy.
- 🦾 TypeScript, of course.
- 📲 [PWA](https://github.com/vite-pwa/nuxt) with offline support and auto-update behavior.
## Plugins
### Nuxt Modules
- [VueUse](https://github.com/vueuse/vueuse) - collection of useful composition APIs.
- [ColorMode](https://github.com/nuxt-modules/color-mode) - dark and Light mode with auto detection made easy with Nuxt.
- [UnoCSS](https://github.com/unocss/unocss) - the instant on-demand atomic CSS engine.
- [Pinia](https://github.com/vuejs/pinia) - intuitive, type safe, light and flexible Store for Vue.
- [VitePWA](https://github.com/vite-pwa/nuxt) - zero-config PWA Plugin for Nuxt 3.
- [DevTools](https://github.com/nuxt/devtools) - unleash Nuxt Developer Experience.
## IDE
We recommend using [VS Code](https://code.visualstudio.com/) with [Volar](https://github.com/johnsoncodehk/volar) to get the best experience (You might want to disable [Vetur](https://vuejs.github.io/vetur/) if you have it).
## Variations
- [vitesse](https://github.com/antfu/vitesse) - Opinionated Vite Starter Template
- [vitesse-lite](https://github.com/antfu/vitesse-lite) - Lightweight version of Vitesse
- [vitesse-nuxt-bridge](https://github.com/antfu/vitesse-nuxt-bridge) - Vitesse for Nuxt 2 with Bridge
- [vitesse-webext](https://github.com/antfu/vitesse-webext) - WebExtension Vite starter template
## Try it now!
### Online
<a href="https://stackblitz.com/github/antfu/vitesse-nuxt3"><img src="https://developer.stackblitz.com/img/open_in_stackblitz.svg" alt=""></a>
### GitHub Template
[Create a repo from this template on GitHub](https://github.com/antfu/vitesse-nuxt3/generate).
### Clone to local
If you prefer to do it manually with the cleaner git history
```bash
npx degit antfu/vitesse-nuxt3 my-nuxt3-app
cd my-nuxt3-app
pnpm i # If you don't have pnpm installed, run: npm install -g pnpm
```
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { appName } from '~/constants'
useHead({
title: appName,
})
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<style>
html,
body,
#__nuxt {
height: 100vh;
margin: 0;
padding: 0;
}
html.dark {
background: #222;
color: white;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useElementBounding } from '@vueuse/core'
const containerRef = ref<HTMLDivElement>()
// https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode
const analyser = ref<AnalyserNode>()
const analyserDataBuffer = ref<Uint8Array>()
const { audioContext } = useAudioContext()
const canvasElemRef = ref<HTMLCanvasElement>()
const isDark = useDark()
// https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode/playbackRate
// explain: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Visualizations_with_Web_Audio_API
// reference: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Simple_synth
function fetchAnalyserDataDuringFrames() {
if (!analyser.value || !analyserDataBuffer.value || !canvasElemRef.value)
return
// https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame
requestAnimationFrame(fetchAnalyserDataDuringFrames)
if (analyserDataBuffer.value.length > 60 * 2)
analyserDataBuffer.value = new Uint8Array(analyser.value.frequencyBinCount)
analyser.value.getByteTimeDomainData(analyserDataBuffer.value)
const context = canvasElemRef.value.getContext('2d')!
if (isDark.value)
context.fillStyle = 'rgba(34, 34, 34, 1)'
else
context.fillStyle = 'rgba(255, 255, 255, 1)'
context.fillRect(0, 0, canvasElemRef.value.width, canvasElemRef.value.height)
context.lineWidth = 2
if (isDark.value)
context.strokeStyle = 'rgb(255 255 255)'
else
context.strokeStyle = 'rgb(0 0 0)'
context.beginPath()
const sliceWidth = (canvasElemRef.value.width * 1.0) / analyser.value.frequencyBinCount
let x = 0
for (let i = 0; i < analyser.value.frequencyBinCount; i++) {
const v = analyserDataBuffer.value[i] / 128.0
const y = (v * canvasElemRef.value.height) / 2
if (i === 0)
context.moveTo(x, y)
else
context.lineTo(x, y)
x += sliceWidth
}
context.lineTo(canvasElemRef.value.width, canvasElemRef.value.height / 2)
context.stroke()
}
function initAnalyser() {
analyser.value = audioContext.createAnalyser()
analyserDataBuffer.value = new Uint8Array(analyser.value.frequencyBinCount)
analyser.value.getByteTimeDomainData(analyserDataBuffer.value)
const windowAny = window as any
windowAny.analyserDataBuffer = analyserDataBuffer
fetchAnalyserDataDuringFrames()
}
defineExpose({
analyser: () => analyser.value,
})
onMounted(async () => {
if (!containerRef.value || !canvasElemRef.value)
return
const containerElementBounding = useElementBounding(containerRef.value)
containerElementBounding.update()
initAnalyser()
canvasElemRef.value.width = containerElementBounding.width.value
canvasElemRef.value.height = containerElementBounding.height.value
})
</script>
<template>
<div ref="containerRef" h="[80px]" w-full>
<canvas ref="canvasElemRef" h-full w-full />
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts" generic="T extends any, O extends any">
import type { CSSProperties } from 'vue'
import { nextTick, onMounted, ref } from 'vue'
const events = defineEmits<{
(event: 'submit', message: string): void
}>()
const input = defineModel<string>({
default: '',
})
const textareaRef = ref<HTMLTextAreaElement>()
const textareaStyle = ref<CSSProperties>({
height: 'auto',
overflowY: 'hidden',
})
// javascript - Creating a textarea with auto-resize - Stack Overflow
// https://stackoverflow.com/questions/454202/creating-a-textarea-with-auto-resize
function onInput(e: Event) {
if (!(e.target instanceof HTMLTextAreaElement))
return
e.target.style.height = 'auto'
e.target.style.height = `${e.target.scrollHeight}px`
}
// javascript - How do I detect "shift+enter" and generate a new line in Textarea? - Stack Overflow
// https://stackoverflow.com/questions/6014702/how-do-i-detect-shiftenter-and-generate-a-new-line-in-textarea
function onKeyDown(e: KeyboardEvent) {
if (!(e.target instanceof HTMLTextAreaElement))
return
if (e.code === 'Enter' && e.shiftKey) {
e.preventDefault()
const start = e.target?.selectionStart
const end = e.target?.selectionEnd
input.value = `${input.value.substring(0, start)}\n${input.value.substring(end)}`
// javascript - height of textarea increases when value increased but does not reduce when value is decreased - Stack Overflow
// https://stackoverflow.com/questions/10722058/height-of-textarea-increases-when-value-increased-but-does-not-reduce-when-value
textareaStyle.value.height = '0'
nextTick().then(() => {
if (!textareaRef.value)
return
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = start + 1
textareaStyle.value.height = `${textareaRef.value.scrollHeight}px`
})
}
else if (e.code === 'Enter') { // block enter
e.preventDefault()
events('submit', input.value)
}
}
onMounted(() => {
if (!textareaRef.value)
return
textareaStyle.value.height = `${textareaRef.value.scrollHeight}px`
})
</script>
<template>
<textarea
ref="textareaRef"
v-model="input"
:style="textareaStyle"
@input="onInput"
@keydown="onKeyDown"
/>
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts" generic="T extends any, O extends any">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { Application } from '@pixi/app'
import { extensions } from '@pixi/extensions'
import { Ticker, TickerPlugin } from '@pixi/ticker'
import { Live2DModel } from 'pixi-live2d-display/cubism4'
import { useElementBounding } from '@vueuse/core'
const props = withDefaults(defineProps<{
mouthOpenSize?: number
}>(), {
mouthOpenSize: 0,
})
const containerRef = ref<HTMLDivElement>()
const pixiApp = ref<Application>()
const model = ref<Live2DModel>()
const mouthOpenSize = computed(() => {
return Math.max(0, Math.min(100, props.mouthOpenSize))
})
async function initLive2DPixiStage(parent: HTMLDivElement) {
const containerElementBounding = useElementBounding(parent)
containerElementBounding.update()
// https://guansss.github.io/pixi-live2d-display/#package-importing
Live2DModel.registerTicker(Ticker)
extensions.add(TickerPlugin)
pixiApp.value = new Application({
width: containerElementBounding.width.value,
height: 550,
backgroundAlpha: 0,
})
parent.appendChild(pixiApp.value.view)
model.value = await Live2DModel.from('assets/live2d/models/hiyori_free_zh/runtime/hiyori_free_t08.model3.json')
pixiApp.value.stage.addChild(model.value as any)
model.value.x = containerElementBounding.width.value / 2
model.value.y = 600
model.value.rotation = Math.PI
model.value.skew.x = Math.PI
model.value.scale.set(0.3, 0.3)
model.value.anchor.set(0.5, 0.5)
model.value.on('hit', (hitAreas) => {
if (model.value && hitAreas.includes('body'))
model.value.motion('tap_body')
})
const coreModel = model.value.internalModel.coreModel as any
coreModel.setParameterValueById('ParamMouthOpenY', mouthOpenSize.value)
}
onMounted(async () => {
if (!containerRef.value)
return
await initLive2DPixiStage(containerRef.value)
})
onUnmounted(() => {
pixiApp.value?.destroy()
})
watch(mouthOpenSize, (value) => {
const coreModel = model.value!.internalModel.coreModel as any
coreModel.setParameterValueById('ParamMouthOpenY', value)
})
</script>
<template>
<div ref="containerRef" h="[550px]" w-full />
</template>
+282
View File
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import type { OpenAI } from 'openai'
import { unified } from 'unified'
import RemarkRehype from 'remark-rehype'
import RemarkParse from 'remark-parse'
import RehypeStringify from 'rehype-stringify'
import { ofetch } from 'ofetch'
import Avatar from '../assets/live2d/models/hiyori_free_zh/avatar.png'
import Live2DViewer from '../components/Live2DViewer.vue'
import BasicTextarea from '../components/BasicTextarea.vue'
import { useLLM } from '../stores/llm'
import AudioWaveform from './AudioWaveform.vue'
interface Message {
role: 'system' | 'assistant' | 'user'
content: string
}
const llm = useLLM()
const { audioContext } = useAudioContext()
const openAIAPIKey = useLocalStorage('openai-api-key', '')
const openAIAPIBaseURL = useLocalStorage('openai-api-base-url', 'https://api.openai.com/v1')
const openAIModel = useLocalStorage('openai-model', '')
const mouthOpenSize = ref(0)
const models = ref<OpenAI.Model[]>([])
const input = ref<string>('')
const messages = ref<Message[]>([])
const audioWaveformRef = ref<{ analyser: () => AnalyserNode }>()
const speaking = ref(false)
const speakingLipSyncStarted = ref(false)
const model = computed<string>({
get: () => {
if (!openAIModel.value)
return ''
return (JSON.parse(openAIModel.value) as OpenAI.Model).id
},
set: (value) => {
const found = models.value.find(m => m.id === value)
if (!found) {
openAIModel.value = ''
return
}
openAIModel.value = JSON.stringify(found)
},
})
async function streamSpeech(text: string) {
const res = await ofetch('/api/v1/llm/voice/text-to-speech', {
body: {
text,
},
method: 'POST',
cache: 'no-cache',
responseType: 'arrayBuffer',
})
// Decode the ArrayBuffer into an AudioBuffer
const audioBuffer = await audioContext.decodeAudioData(res)
// Create an AudioBufferSourceNode
const source = audioContext.createBufferSource()
source.buffer = audioBuffer
// Connect the source to the AudioContext's destination (the speakers)
source.connect(audioContext.destination)
// Connect the source to the analyzer
source.connect(audioWaveformRef.value!.analyser())
// Start playing the audio
speaking.value = true
source.start(0)
source.onended = () => {
speaking.value = false
}
}
function getVolumeWithLinearNormalize() {
requestAnimationFrame(getVolumeWithLinearNormalize)
if (!speaking.value)
return
const analyser = audioWaveformRef.value!.analyser()
const dataBuffer = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(dataBuffer)
const volumeVector = []
for (let i = 0; i < 700; i += 80)
volumeVector.push(dataBuffer[i])
const volumeSum = dataBuffer
// The volume changes are so flatten, and the volume is so low, so we need to amplify it
// We can apply a power function to amplify the volume, for example
// v ** 1.2 will amplify the volume by 1.2 times
.map(v => v ** 1.2)
.reduce((acc, cur) => acc + cur, 0)
mouthOpenSize.value = (volumeSum / dataBuffer.length / 100)
}
function getVolumeWithMinMaxNormalize() {
requestAnimationFrame(getVolumeWithLinearNormalize)
if (!speaking.value)
return
const analyser = audioWaveformRef.value!.analyser()
const dataBuffer = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(dataBuffer)
const volumeVector = []
for (let i = 0; i < 700; i += 80)
volumeVector.push(dataBuffer[i])
// The volume changes are so flatten, and the volume is so low, so we need to amplify it
// We can apply a power function to amplify the volume, for example
// v ** 1.2 will amplify the volume by 1.2 times
const amplifiedVolumeVector = dataBuffer.map(v => v ** 1.2)
// Normalize the amplified values using Min-Max scaling
const min = Math.min(...amplifiedVolumeVector)
const max = Math.max(...amplifiedVolumeVector)
const range = max - min
let normalizedVolumeVector
if (range === 0) {
// If range is zero, all values are the same, so normalization is not needed
normalizedVolumeVector = amplifiedVolumeVector.map(() => 0) // or any default value
}
else {
normalizedVolumeVector = amplifiedVolumeVector.map(v => (v - min) / range)
}
// Aggregate the volume values
const volumeSum = normalizedVolumeVector.reduce((acc, cur) => acc + cur, 0)
// Average the volume values
mouthOpenSize.value = volumeSum / dataBuffer.length
}
function onSendMessage(sendingMessage: string) {
if (!speakingLipSyncStarted.value) {
getVolumeWithMinMaxNormalize()
audioContext.resume()
speakingLipSyncStarted.value = true
}
const message: Message = { role: 'assistant', content: '' }
messages.value.push({ role: 'user', content: sendingMessage })
messages.value.push(message)
const index = messages.value.length - 1
llm.stream(model.value, sendingMessage).then(async (res) => {
for await (const textPart of res.textStream)
messages.value[index].content += textPart
await streamSpeech(messages.value[index].content)
})
input.value = ''
}
function fromMarkdownToHTML(markdown: string) {
return unified()
.use(RemarkParse)
.use(RemarkRehype)
.use(RehypeStringify)
.processSync(markdown)
.toString()
}
watch(openAIAPIKey, (value) => {
llm.setupOpenAI({
apiKey: value,
baseURL: openAIAPIBaseURL.value,
})
})
onMounted(async () => {
if (!openAIAPIKey.value)
return
llm.setupOpenAI({
apiKey: openAIAPIKey.value,
baseURL: openAIAPIBaseURL.value,
})
const fetchedModels = await llm.models()
models.value = fetchedModels.data
})
onUnmounted(() => {
speakingLipSyncStarted.value = false
})
</script>
<template>
<div max-h="[100vh]" h-full p="2" flex="~ col">
<div space-x="2" flex="~ row" w-full>
<div flex="~ row" w-full>
<input
v-model="openAIAPIKey"
placeholder="Input your API key"
p="2" bg="zinc-100 dark:zinc-800" w-full rounded-lg outline-none
>
</div>
<div flex="~ row" w-full>
<input
v-model="openAIAPIBaseURL"
placeholder="Input your API base URL"
p="2" bg="zinc-100 dark:zinc-800" w-full rounded-lg outline-none
>
</div>
</div>
<div flex="~ row 1" w-full items-end space-x-2>
<div w-full>
<div>
<span>{{ mouthOpenSize }}</span>
</div>
<Live2DViewer :mouth-open-size="mouthOpenSize" />
<input v-model.number="mouthOpenSize" type="range" max="1" min="0" step="0.01">
<AudioWaveform ref="audioWaveformRef" />
</div>
<div my="2" w-full space-y-2>
<div v-for="(message, index) in messages" :key="index">
<div v-if="message.role === 'assistant'" flex mr="12">
<div h-10 min-h-10 min-w-10 w-10 overflow-hidden rounded-full border="pink solid 3" mr="2">
<img :src="Avatar">
</div>
<div flex="~ col" bg="pink-50/50 dark:pink-900/50" p="2" border="2 solid pink/10" rounded-lg>
<div>
<span font-semibold>Neuro</span>
</div>
<div v-html="fromMarkdownToHTML(message.content)" />
</div>
</div>
<div v-else-if="message.role === 'user'" flex="~ row-reverse" ml="12">
<div border="purple solid 3" ml="2" h-10 min-h-10 min-w-10 w-10 overflow-hidden rounded-full>
<div i-carbon:user-avatar-filled text="purple" h-full w-full p="0" m="0" />
</div>
<div flex="~ col" bg="purple-50/50 dark:purple-900/50" p="2" border="2 solid pink/10" rounded-lg>
<div self-end>
<span font-semibold>You</span>
</div>
<div v-html="fromMarkdownToHTML(message.content)" />
</div>
</div>
</div>
</div>
</div>
<div my="2" space-x="2" flex="~ row" w-full self-end>
<div flex="~ col" w-full space-y="2">
<select
v-model="model"
p="2"
bg="zinc-100 dark:zinc-800" w-full rounded-lg
outline-none
>
<option value="">
Select a model
</option>
<option v-for="m in models" :key="m.id" :value="m.id">
{{ 'name' in m ? `${m.name} (${m.id})` : m.id }}
</option>
</select>
<BasicTextarea
v-model="input"
placeholder="Message"
p="2" bg="zinc-100 dark:zinc-800"
w-full rounded-lg outline-none
@submit="onSendMessage"
/>
</div>
</div>
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
export function useCount() {
const count = useState('count', () => Math.round(Math.random() * 20))
function inc() {
count.value += 1
}
function dec() {
count.value -= 1
}
return {
count,
inc,
dec,
}
}
+34
View File
@@ -0,0 +1,34 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
/**
* Current named of the user.
*/
const savedName = ref('')
const previousNames = ref(new Set<string>())
const usedNames = computed(() => Array.from(previousNames.value))
const otherNames = computed(() => usedNames.value.filter(name => name !== savedName.value))
/**
* Changes the current name of the user and saves the one that was used
* before.
*
* @param name - new name to set
*/
function setNewName(name: string) {
if (savedName.value)
previousNames.value.add(savedName.value)
savedName.value = name
}
return {
setNewName,
otherNames,
savedName,
}
})
if (import.meta.hot)
import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot))
+2
View File
@@ -0,0 +1,2 @@
export const appName = 'Vitesse for Nuxt 3'
export const appDescription = 'Vitesse for Nuxt 3'
+12
View File
@@ -0,0 +1,12 @@
// @ts-check
import antfu from '@antfu/eslint-config'
import nuxt from './.nuxt/eslint.config.mjs'
export default nuxt(
antfu(
{
unocss: true,
formatters: true,
},
),
)
+5
View File
@@ -0,0 +1,5 @@
<template>
<main text="gray-700 dark:gray-200" h-full font-sans>
<slot />
</main>
</template>
Executable
+11
View File
@@ -0,0 +1,11 @@
[build]
publish = "dist"
command = "pnpm run build"
[build.environment]
NODE_VERSION = "16"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
+79
View File
@@ -0,0 +1,79 @@
import { appDescription } from './constants/index'
export default defineNuxtConfig({
modules: [
'@vueuse/nuxt',
'@unocss/nuxt',
'@pinia/nuxt',
'@nuxtjs/color-mode',
'@nuxt/eslint',
],
experimental: {
// when using generate, payload js assets included in sw precache manifest
// but missing on offline, disabling extraction it until fixed
payloadExtraction: false,
renderJsonPayloads: true,
typedPages: true,
},
css: [
'@unocss/reset/tailwind.css',
],
colorMode: {
classSuffix: '',
},
nitro: {
esbuild: {
options: {
target: 'esnext',
},
},
prerender: {
crawlLinks: false,
routes: ['/'],
ignore: ['/hi'],
},
experimental: {
websocket: true,
},
},
app: {
head: {
viewport: 'width=device-width,initial-scale=1',
link: [
{ rel: 'icon', href: '/favicon.ico', sizes: 'any' },
{ rel: 'icon', type: 'image/svg+xml', href: '/nuxt.svg' },
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png' },
],
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: appDescription },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
{ name: 'theme-color', media: '(prefers-color-scheme: light)', content: 'white' },
{ name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#222222' },
],
script: [
{ src: './assets/js/CubismSdkForWeb-5-r.1/Core/live2dcubismcore.min.js' },
],
},
},
devtools: {
enabled: true,
},
features: {
// For UnoCSS
inlineStyles: false,
},
eslint: {
config: {
standalone: false,
},
},
})
+52
View File
@@ -0,0 +1,52 @@
{
"type": "module",
"private": true,
"packageManager": "pnpm@9.1.0",
"scripts": {
"build": "nuxi build",
"dev:pwa": "VITE_PLUGIN_PWA=true nuxi dev",
"dev": "nuxi dev",
"generate": "nuxi generate",
"prepare": "nuxi prepare",
"start": "node .output/server/index.mjs",
"start:generate": "npx serve .output/public",
"lint": "eslint .",
"typecheck": "vue-tsc --noEmit"
},
"devDependencies": {
"@antfu/eslint-config": "^2.17.0",
"@iconify-json/carbon": "^1.1.32",
"@iconify-json/twemoji": "^1.1.15",
"@nuxt/devtools": "^1.3.1",
"@nuxt/eslint": "^0.3.12",
"@nuxtjs/color-mode": "^3.4.1",
"@pinia/nuxt": "^0.5.1",
"@unocss/eslint-config": "^0.60.0",
"@unocss/nuxt": "^0.60.0",
"@vueuse/nuxt": "^10.9.0",
"consola": "^3.2.3",
"eslint": "^8.57.0",
"eslint-plugin-format": "^0.1.1",
"nuxt": "^3.11.2",
"pinia": "^2.1.7",
"typescript": "^5.4.5",
"vue-tsc": "^2.0.17"
},
"dependencies": {
"@ai-sdk/openai": "^0.0.24",
"@pixi/app": "6",
"@pixi/extensions": "6",
"@pixi/interaction": "6",
"@pixi/ticker": "6",
"ai": "^3.1.30",
"elevenlabs": "^0.6.0",
"ofetch": "^1.3.4",
"openai": "^4.49.0",
"pixi-live2d-display": "^0.4.0",
"rehype-stringify": "^10.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"unified": "^11.0.4",
"zod": "^3.23.8"
}
}
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
const router = useRouter()
</script>
<template>
<main p="x4 y10" text="center teal-700 dark:gray-200">
<div text-4xl>
<div i-carbon-warning inline-block />
</div>
<div>Not found</div>
<div>
<button text-sm btn m="3 t8" @click="router.back()">
Back
</button>
</div>
</main>
</template>
+14
View File
@@ -0,0 +1,14 @@
<template>
<div>
<Suspense>
<ClientOnly>
<MainStage />
</ClientOnly>
<template #fallback>
<div italic op50>
<span animate-pulse>Loading...</span>
</div>
</template>
</Suspense>
</div>
</template>
+11838
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import { ElevenLabsClient, stream } from 'elevenlabs'
export default defineEventHandler(async (event) => {
const body = await readBody<{ text: string }>(event)
const client = new ElevenLabsClient({
apiKey: '',
})
const res = await client.generate({
voice: 'Beatrice',
text: body.text,
stream: true,
model_id: 'eleven_multilingual_v2',
voice_settings: {
stability: 0.4,
similarity_boost: 0.5,
},
})
// Set headers for streaming
event.node.res.setHeader('Content-Type', 'audio/mpeg')
event.node.res.setHeader('Transfer-Encoding', 'chunked')
// res is NodeJS.ReadableStream
return sendStream(event, res)
})
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}
+9
View File
@@ -0,0 +1,9 @@
import { defineStore } from 'pinia'
export const useAudioContext = defineStore('AudioContext', () => {
const audioContext = new AudioContext()
return {
audioContext,
}
})
+47
View File
@@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import { streamText } from 'ai'
import { type OpenAIProvider, type OpenAIProviderSettings, createOpenAI } from '@ai-sdk/openai'
import { OpenAI } from 'openai'
import { ref } from 'vue'
export const useLLM = defineStore('llm', () => {
const openAI = ref<OpenAI>()
const openAIProvider = ref<OpenAIProvider>()
function setupOpenAI(options: OpenAIProviderSettings) {
openAI.value = new OpenAI({
...options,
dangerouslyAllowBrowser: true,
})
openAIProvider.value = createOpenAI(options)
}
async function stream(model: string, content: string) {
if (!openAIProvider.value)
throw new Error('OpenAI not initialized')
return await streamText({
model: openAIProvider.value(model),
messages: [
{
role: 'user',
content,
},
],
})
}
async function models() {
if (!openAI.value)
throw new Error('OpenAI not initialized')
return await openAI.value.models.list()
}
return {
setupOpenAI,
openAI,
models,
stream,
}
})
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
+36
View File
@@ -0,0 +1,36 @@
import {
defineConfig,
presetAttributify,
presetIcons,
presetTypography,
presetUno,
presetWebFonts,
transformerDirectives,
transformerVariantGroup,
} from 'unocss'
export default defineConfig({
shortcuts: [
['btn', 'px-4 py-1 rounded inline-block bg-teal-600 text-white cursor-pointer hover:bg-teal-700 disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50'],
['icon-btn', 'inline-block cursor-pointer select-none opacity-75 transition duration-200 ease-in-out hover:opacity-100 hover:text-teal-600'],
],
presets: [
presetUno(),
presetAttributify(),
presetIcons({
scale: 1.2,
}),
presetTypography(),
presetWebFonts({
fonts: {
sans: 'DM Sans',
serif: 'DM Serif Display',
mono: 'DM Mono',
},
}),
],
transformers: [
transformerDirectives(),
transformerVariantGroup(),
],
})