feat: desktop tamagotchi (#7)

* rebase origin main

* exclude packages/desktop/out

* tmp commit

* use mobile ui instead of desktop ui

* fix lockfile

* send ipc event to move window

* load zip file

* rename folder

* remove `less`

* add correct prefix for script

* use airi logo instead of electron default logo

* remove duplicate icon

* use import.meta instead of __dirname
This commit is contained in:
LemonNeko
2025-01-03 16:57:59 +08:00
committed by GitHub
parent 1a773e6893
commit 3c8e7788a0
35 changed files with 2964 additions and 356 deletions
+2
View File
@@ -15,7 +15,9 @@
"typecheck": "pnpm -r --filter=./packages/* run build",
"dev": "pnpm packages:dev",
"build": "pnpm packages:build",
"dev:tamagotchi": "pnpm packages:dev:tamagotchi",
"packages:dev": "pnpm -r --filter=./packages/* --parallel run dev",
"packages:dev:tamagotchi": "pnpm -r --filter=./packages/* --parallel run dev:tamagotchi",
"packages:stub": "pnpm -r --filter=./packages/* run stub",
"packages:build": "pnpm -r --filter=./packages/* run build",
"packages:publish": "pnpm -r --filter=./packages/* run package:publish",
+4 -1
View File
@@ -15,7 +15,9 @@
"dev": "vite",
"lint": "eslint .",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
"typecheck": "vue-tsc --noEmit",
"dev:tamagotchi": "vite --mode tamagotchi",
"build:tamagotchi": "vite build --mode tamagotchi"
},
"dependencies": {
"@11labs/client": "^0.0.4",
@@ -54,6 +56,7 @@
"@xsai/shared-chat": "^0.0.22",
"@xsai/stream-text": "^0.0.22",
"defu": "^6.1.4",
"jszip": "^3.10.1",
"nprogress": "^0.2.0",
"ofetch": "^1.4.1",
"onnxruntime-web": "^1.20.1",
@@ -10,6 +10,7 @@ import { useMicVAD } from '../../composables/micvad'
// import { useAudioContext } from '../../stores/audio'
import { useChatStore } from '../../stores/chat'
import { useSettings } from '../../stores/settings'
import BasicTextarea from '../BasicTextarea.vue'
import MobileChatHistory from '../Widgets/MobileChatHistory.vue'
import MobileSettings from '../Widgets/MobileSettings.vue'
@@ -0,0 +1,129 @@
<script setup lang="ts">
// import { useDevicesList } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { DrawerContent, DrawerPortal, DrawerRoot, DrawerTrigger } from 'vaul-vue'
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMicVAD } from '../../composables/micvad'
// import { useAudioContext } from '../../stores/audio'
import { useChatStore } from '../../stores/chat'
import { useSettings } from '../../stores/settings'
import BasicTextarea from '../BasicTextarea.vue'
import TamagotchiChatHistory from '../Widgets/TamagotchiChatHistory.vue'
import TamagotchiSettings from '../Widgets/TamagotchiSettings.vue'
const messageInput = ref('')
const listening = ref(false)
// const { audioInputs } = useDevicesList({ constraints: { audio: true }, requestPermissions: true })
// const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { send, onAfterSend } = useChatStore()
const { t } = useI18n()
async function handleSend() {
if (!messageInput.value.trim()) {
return
}
await send(messageInput.value)
}
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 handleTranscription(_buffer: Float32Array<ArrayBufferLike>) {
// eslint-disable-next-line no-alert
alert('Transcription is not implemented yet')
}
// 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()
}
})
onAfterSend(async () => {
messageInput.value = ''
})
onMounted(() => {
start()
})
</script>
<template>
<div>
<div relative w-full flex gap-1>
<TamagotchiChatHistory absolute left-0 top-0 transform="translate-y-[-100%]" w-full />
<div flex flex-1>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 pink-100"
text="pink-400 hover:pink-600 placeholder:pink-400 placeholder:hover:pink-600"
bg="pink-50 dark:[#3c2632]" max-h="[10lh]" min-h="[1lh]"
w-full resize-none overflow-y-scroll rounded-l-xl p-2 font-medium outline-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
@submit="handleSend"
/>
</div>
<DrawerRoot should-scale-background>
<DrawerTrigger
class="px-4 py-2.5"
border="solid 2 pink-100 "
text="lg pink-400 hover:pink-600 placeholder:pink-400 placeholder:hover:pink-600"
bg="pink-50 dark:[#3c2632]" max-h="[10lh]" min-h="[1lh]" rounded-r-xl
>
<div i-solar:settings-bold-duotone />
</DrawerTrigger>
<DrawerPortal>
<DrawerContent
max-h="[90%]"
fixed bottom-0 left-0 right-0 z-50 mt-24 h-full flex flex-col rounded-t-lg bg="[#fffbff] dark:[#1f1a1d]"
>
<div class="flex flex-1 flex-col rounded-t-lg p-5" bg="[#fffbff] dark:[#1f1a1d]" gap-2>
<TamagotchiSettings />
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</div>
</div>
</template>
@@ -9,15 +9,18 @@ import { useQueue } from '../../composables/queue'
import { useDelayMessageQueue, useEmotionsMessageQueue, useMessageContentQueue } from '../../composables/queues'
import { llmInferenceEndToken } from '../../constants'
import { Voice } from '../../constants/elevenlabs'
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
import { useChatStore } from '../../stores/chat'
import { useLLM } from '../../stores/llm'
import { useSettings } from '../../stores/settings'
import Live2DScene from '../Scenes/Live2D.vue'
import VRMScene from '../Scenes/VRM.vue'
import '../../utils/live2d-zip-loader'
const live2DViewerRef = ref<{ setMotion: (motionName: string) => Promise<void> }>()
const vrmViewerRef = ref<{ setExpression: (expression: string) => void }>()
@@ -183,7 +186,7 @@ onUnmounted(() => {
v-if="stageView === '2d'"
ref="live2DViewerRef"
:mouth-open-size="mouthOpenSize"
model="/assets/live2d/models/hiyori_pro_zh/runtime/hiyori_pro_t11.model3.json"
model="./assets/live2d/models/hiyori_pro_zh.zip"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
/>
<VRMScene
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { nextTick, ref } from 'vue'
import { useMarkdown } from '../../composables/markdown'
import { useChatStore } from '../../stores/chat'
const chatHistoryRef = ref<HTMLDivElement>()
const { messages } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
const { process } = useMarkdown()
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
onBeforeMessageComposed(async () => {
// Scroll down to the new sent message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
})
})
onTokenLiteral(async () => {
// Scroll down to the new responding message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
})
})
</script>
<template>
<div py="1" flex="~ col" rounded="lg" overflow-hidden>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate h-full w-full max-h="30vh" flex="~ col" overflow-scroll>
<div flex-1 /> <!-- spacer -->
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'assistant'" flex mr="12">
<div
flex="~ col"
border="4 solid pink-200"
shadow="md pink-200/50"
min-w-20 rounded-lg px-2 py-1
h="fit"
bg="pink-100"
>
<div>
<span text-xs text="pink-400/90" font-semibold class="inline hidden">Airi</span>
</div>
<div v-if="message.content" class="markdown-content" text="xs pink-400" v-html="process(message.content as string)" />
<div v-else i-eos-icons:three-dots-loading />
</div>
</div>
<div v-else-if="message.role === 'user'" flex="~">
<div
flex="~ col"
border="4 solid cyan-200"
shadow="md cyan-200/50"
px="2"
h="fit" min-w-20 rounded-lg px-2 py-1
bg="cyan-100"
>
<div>
<span text-xs text="cyan-600/90" font-semibold class="hidden">You</span>
</div>
<div v-if="message.content" class="markdown-content" text="xs cyan-600" v-html="process(message.content as string)" />
<div v-else />
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,202 @@
<script setup lang="ts">
import type { Voice } from '../../constants/elevenlabs'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { voiceList } from '../../constants/elevenlabs'
import { useLLM } from '../../stores/llm'
import { useSettings } from '../../stores/settings'
const { t, locale } = useI18n()
const settings = useSettings()
const supportedModels = ref<{ id: string, name?: string }[]>([])
const { models } = useLLM()
const { openAiModel, openAiApiBaseURL, openAiApiKey, elevenlabsVoiceEnglish, elevenlabsVoiceJapanese } = storeToRefs(settings)
function handleModelChange(event: Event) {
const target = event.target as HTMLSelectElement
const found = supportedModels.value.find(m => m.id === target.value)
if (!found) {
openAiModel.value = undefined
return
}
openAiModel.value = found
}
function handleViewChange(event: Event) {
const target = event.target as HTMLSelectElement
settings.stageView = target.value
}
function handleVoiceChange(event: Event) {
const value = (event.target as HTMLSelectElement).value as Voice
switch (locale.value) {
case 'en':
case 'en-US':
elevenlabsVoiceEnglish.value = value
break
case 'zh':
case 'zh-CN':
case 'zh-TW':
case 'zh-HK':
elevenlabsVoiceEnglish.value = value
break
case 'jp':
case 'jp-JP':
elevenlabsVoiceJapanese.value = value
break
}
}
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>
<div>
<h2 text="slate-800/80" font-bold>
Settings
</h2>
<div>
<div
grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg
bg="[#fff6fc]" px-2 py-1 text="pink-400"
>
<div text="xs pink-500">
<span>{{ t('settings.openai-base-url.label') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<input
v-model="settings.openAiApiBaseURL"
type="text"
:placeholder="t('settings.openai-base-url.placeholder_mobile')"
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
</div>
<div text="xs pink-500">
<span>{{ t('settings.openai-api-key.label') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<input
v-model="settings.openAiApiKey"
type="text"
:placeholder="t('settings.openai-api-key.placeholder_mobile')"
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
</div>
<div text="xs pink-500">
<span>{{ t('settings.elevenlabs-api-key.label') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<input
v-model="settings.elevenLabsApiKey"
type="text"
:placeholder="t('settings.elevenlabs-api-key.placeholder_mobile')"
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
</div>
<div text="xs pink-500">
<span>{{ t('settings.language') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<select
v-model="settings.language"
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
<option value="en-US">
English
</option>
<option value="zh-CN">
简体中文
</option>
</select>
</div>
<div text="xs pink-500">
<span>{{ t('settings.models') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<select
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
@change="handleModelChange"
>
<option disabled class="bg-white">
{{ 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 text="xs pink-500">
<span>{{ t('settings.voices') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<select
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
@change="handleVoiceChange"
>
<option disabled class="bg-white">
{{ t('stage.select-a-voice') }}
</option>
<option v-if="['en', 'en-US'].indexOf(locale) !== -1 && elevenlabsVoiceEnglish" :value="elevenlabsVoiceEnglish">
{{ elevenlabsVoiceEnglish }}
</option>
<!-- TODO -->
<option v-if="['zh', 'zh-CN', 'zh-TW', 'zh-HK'].indexOf(locale) !== -1 && elevenlabsVoiceEnglish" :value="elevenlabsVoiceEnglish">
{{ elevenlabsVoiceEnglish }}
</option>
<option v-if="['jp', 'jp-JP'].indexOf(locale) !== -1 && elevenlabsVoiceJapanese" :value="elevenlabsVoiceJapanese">
{{ elevenlabsVoiceJapanese }}
</option>
<option v-for="(m, index) in voiceList[locale]" :key="index" :value="m">
{{ m }}
</option>
</select>
</div>
</div>
</div>
<h2 text="slate-800/80" font-bold>
View
</h2>
<div>
<div
grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg
bg="[#fff6fc]" px-2 py-1 text="pink-400"
>
<div text="xs pink-500">
<span>Viewer</span>
</div>
<select
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right text-xs font-mono outline-none
@change="handleViewChange"
>
<option value="2d">
2D
</option>
<option value="3d">
3D
</option>
</select>
</div>
</div>
</div>
</template>
+7 -6
View File
@@ -18,13 +18,14 @@ export enum Emotion {
export const EMOTION_VALUES = Object.values(Emotion)
export const EmotionHappyMotionName = 'EmotionHappy'
// FIXME: need a editor to remap the motion
export const EmotionHappyMotionName = 'Tap'
export const EmotionSadMotionName = 'EmotionSad'
export const EmotionAngryMotionName = 'EmotionAngry'
export const EmotionAwkwardMotionName = 'EmotionAwkward'
export const EmotionThinkMotionName = 'EmotionThink'
export const EmotionSurpriseMotionName = 'EmotionSurprise'
export const EmotionQuestionMotionName = 'EmotionQuestion'
export const EmotionAngryMotionName = 'Tap@Body'
export const EmotionAwkwardMotionName = 'FlickDown'
export const EmotionThinkMotionName = 'Flick'
export const EmotionSurpriseMotionName = 'Flick'
export const EmotionQuestionMotionName = 'Flick@Body'
export const EMOTION_EmotionMotionName_value = {
[Emotion.Happy]: EmotionHappyMotionName,
+7
View File
@@ -0,0 +1,7 @@
declare interface Window {
electron: {
ipcRenderer: {
send: (channel: string, ...args: any[]) => void
}
}
}
@@ -0,0 +1,33 @@
import JSZip from 'jszip'
import { ZipLoader } from 'pixi-live2d-display/cubism4'
ZipLoader.zipReader = (data: Blob, _url: string) => JSZip.loadAsync(data)
ZipLoader.readText = (jsZip: JSZip, path: string) => {
const file = jsZip.file(path)
if (!file) {
throw new Error(`Cannot find file: ${path}`)
}
return file.async('text')
}
ZipLoader.getFilePaths = (jsZip: JSZip) => {
const paths: string[] = []
jsZip.forEach(relativePath => paths.push(relativePath))
return Promise.resolve(paths)
}
ZipLoader.getFiles = (jsZip: JSZip, paths: string[]) =>
Promise.all(paths.map(
async (path) => {
const fileName = path.slice(path.lastIndexOf('/') + 1)
const blob = await jsZip.file(path)!.async('blob')
return new File([blob], fileName)
},
))
+3
View File
@@ -0,0 +1,3 @@
export function isPlatformTamagotchi() {
return import.meta.env.MODE === 'tamagotchi'
}
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue'
import TamagotchiInteractiveArea from '../src/components/Layouts/TamagotchiInteractiveArea.vue'
import Stage from '../src/components/Widgets/Stage.vue'
const dragDelay = ref(0)
const isDragging = ref(false)
function handleMouseDown() {
dragDelay.value = window.setTimeout(() => {
isDragging.value = true
}, 500)
}
function handleMouseUp() {
clearTimeout(dragDelay.value)
isDragging.value = false
}
function handleMouseLeave() {
isDragging.value = false
}
function handleMouseMove(event: MouseEvent) {
if (isDragging.value) {
window.electron.ipcRenderer.send('move-window', event.movementX, event.movementY)
}
}
</script>
<template>
<div relative max-h="[100vh]" max-w="[100vw]" p="2" flex="~ col" z-2 h-full overflow-hidden @mousedown="handleMouseDown" @mouseup="handleMouseUp" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave">
<div relative h-full w-full items-end gap-2 class="view">
<Stage h-full w-full flex-1 mb="<md:18" />
<TamagotchiInteractiveArea class="interaction-area block" pointer-events-none absolute bottom-0 w-full opacity-0 transition="opacity duration-250" />
</div>
</div>
</template>
<style scoped>
.view {
&:hover {
.interaction-area {
opacity: 1;
pointer-events: auto;
}
}
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>アイリ</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="アイリ" />
<script src="/assets/js/CubismSdkForWeb-5-r.1/Core/live2dcubismcore.min.js"></script>
</head>
<body class="font-sans">
<div id="app"></div>
<script type="module" src="./main.ts"></script>
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
@import './themes.css';
@import './transitions.css';
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
html {
transition: all 0.3s ease-in-out;
}
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: rgb(13, 148, 136);
opacity: 0.75;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
+21
View File
@@ -0,0 +1,21 @@
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import Tres from '@tresjs/core'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import { i18n } from '../src/modules/i18n'
import App from './App.vue'
import '@unocss/reset/tailwind.css'
import 'uno.css'
import './main.css'
const pinia = createPinia()
createApp(App)
.use(MotionPlugin)
.use(autoAnimatePlugin)
.use(pinia)
.use(i18n)
.use(Tres)
.mount('#app')
+13
View File
@@ -0,0 +1,13 @@
:root {
--airi-theme-primary-50: #fff0f2;
--airi-theme-primary-100: #ffe3e6;
--airi-theme-primary-200: #ffcad4;
--airi-theme-primary-300: #ff9fb0;
--airi-theme-primary-400: #ff6988;
--airi-theme-primary-500: #fe456e;
--airi-theme-primary-600: #ec124d;
--airi-theme-primary-700: #c70941;
--airi-theme-primary-800: #a70a3e;
--airi-theme-primary-900: #8e0d3b;
--airi-theme-primary-950: #50011b;
}
+18
View File
@@ -0,0 +1,18 @@
.slide-away-enter-active,
.slide-away-leave-active {
transition:
transform 0.3s ease-in-out,
opacity 0.3s ease-in-out;
}
.slide-away-enter,
.slide-away-leave-to {
transform: translateY(-10px);
opacity: 0;
}
.slide-away-enter-from,
.slide-away-leave {
transform: translateY(10px);
opacity: 0;
}
+274 -248
View File
@@ -1,7 +1,7 @@
import { Buffer } from 'node:buffer'
import { copyFile, cp, mkdir, readFile, writeFile } from 'node:fs/promises'
import { copyFile, cp, mkdir, writeFile } from 'node:fs/promises'
import path, { join, resolve } from 'node:path'
import { env } from 'node:process'
import { cwd, env } from 'node:process'
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import { templateCompilerOptions } from '@tresjs/core'
@@ -21,295 +21,321 @@ import { exists } from './scripts/fs'
import { unzip } from './scripts/unzip'
import { appName } from './src/constants'
export default defineConfig({
optimizeDeps: {
exclude: [
'public/assets/*',
'@framework/live2dcubismframework',
'@framework/math/cubismmatrix44',
'@framework/type/csmvector',
'@framework/math/cubismviewmatrix',
'@framework/cubismdefaultparameterid',
'@framework/cubismmodelsettingjson',
'@framework/effect/cubismbreath',
'@framework/effect/cubismeyeblink',
'@framework/model/cubismusermodel',
'@framework/motion/acubismmotion',
'@framework/motion/cubismmotionqueuemanager',
'@framework/type/csmmap',
'@framework/utils/cubismdebug',
'@framework/model/cubismmoc',
],
},
build: {
rollupOptions: {
external: [
'virtual:pwa-register',
export default defineConfig(({ mode }) => {
return {
optimizeDeps: {
exclude: [
'public/assets/*',
'@framework/live2dcubismframework',
'@framework/math/cubismmatrix44',
'@framework/type/csmvector',
'@framework/math/cubismviewmatrix',
'@framework/cubismdefaultparameterid',
'@framework/cubismmodelsettingjson',
'@framework/effect/cubismbreath',
'@framework/effect/cubismeyeblink',
'@framework/model/cubismusermodel',
'@framework/motion/acubismmotion',
'@framework/motion/cubismmotionqueuemanager',
'@framework/type/csmmap',
'@framework/utils/cubismdebug',
'@framework/model/cubismmoc',
],
},
},
plugins: [
VueMacros({
plugins: {
vue: Vue({
include: [/\.vue$/, /\.md$/],
...templateCompilerOptions,
}),
build: {
rollupOptions: {
external: [
'virtual:pwa-register',
],
},
}),
},
// https://github.com/posva/unplugin-vue-router
VueRouter({
extensions: ['.vue', '.md'],
dts: path.resolve(__dirname, 'src/typed-router.d.ts'),
}),
base: mode === 'tamagotchi' ? './' : '',
root: mode === 'tamagotchi' ? 'tamagotchi' : '',
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
Layouts(),
plugins: [
VueMacros({
plugins: {
vue: Vue({
include: [/\.vue$/, /\.md$/],
...templateCompilerOptions,
}),
},
}),
// https://github.com/antfu/unplugin-vue-components
Components({
// https://github.com/posva/unplugin-vue-router
VueRouter({
extensions: ['.vue', '.md'],
dts: path.resolve(__dirname, 'src/typed-router.d.ts'),
}),
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
Layouts(),
// https://github.com/antfu/unplugin-vue-components
Components({
// allow auto load markdown components under `./src/components/`
extensions: ['vue', 'md'],
// allow auto import and register components used in markdown
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
dts: 'src/components.d.ts',
}),
extensions: ['vue', 'md'],
// allow auto import and register components used in markdown
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
dts: 'src/components.d.ts',
}),
// https://github.com/antfu/unocss
// see uno.config.ts for config
Unocss(),
// https://github.com/antfu/unocss
// see uno.config.ts for config
Unocss(),
// https://github.com/antfu/vite-plugin-pwa
...(env.TARGET_HUGGINGFACE_SPACE
? []
: [VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'apple-touch-icon.png'],
manifest: {
name: appName,
short_name: appName,
theme_color: '#ffffff',
icons: [
{
src: '/web-app-manifest-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/web-app-manifest-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
},
})]),
// https://github.com/antfu/vite-plugin-pwa
...(env.TARGET_HUGGINGFACE_SPACE || mode === 'tamagotchi'
? []
: [VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'apple-touch-icon.png'],
manifest: {
name: appName,
short_name: appName,
theme_color: '#ffffff',
icons: [
{
src: '/web-app-manifest-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/web-app-manifest-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
},
})]),
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
VueI18n({
runtimeOnly: true,
compositionOnly: true,
fullInstall: true,
include: [path.resolve(__dirname, 'locales/**')],
}),
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
VueI18n({
runtimeOnly: true,
compositionOnly: true,
fullInstall: true,
include: [path.resolve(__dirname, 'locales/**')],
}),
// https://github.com/webfansplz/vite-plugin-vue-devtools
VueDevTools(),
// https://github.com/webfansplz/vite-plugin-vue-devtools
VueDevTools(),
{
name: 'live2d-cubism-sdk',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
{
name: 'live2d-cubism-sdk',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
try {
if (!(await exists(resolve(join(cacheDir, 'assets/js/CubismSdkForWeb-5-r.1'))))) {
console.log('Downloading Cubism SDK...')
const stream = await ofetch('https://dist.ayaka.moe/npm/live2d-cubism/CubismSdkForWeb-5-r.1.zip', { responseType: 'arrayBuffer' })
try {
if (!(await exists(resolve(join(cacheDir, 'assets/js/CubismSdkForWeb-5-r.1'))))) {
console.log('Downloading Cubism SDK...')
const stream = await ofetch('https://dist.ayaka.moe/npm/live2d-cubism/CubismSdkForWeb-5-r.1.zip', { responseType: 'arrayBuffer' })
console.log('Unzipping Cubism SDK...')
await mkdir(join(cacheDir, 'assets/js'), { recursive: true })
await unzip(Buffer.from(stream), join(cacheDir, 'assets/js'))
console.log('Unzipping Cubism SDK...')
await mkdir(join(cacheDir, 'assets/js'), { recursive: true })
await unzip(Buffer.from(stream), join(cacheDir, 'assets/js'))
console.log('Cubism SDK downloaded and unzipped.')
console.log('Cubism SDK downloaded and unzipped.')
}
if (!(await exists(resolve(join(publicDir, 'assets/js/CubismSdkForWeb-5-r.1'))))) {
await mkdir(join(publicDir, 'assets/js/CubismSdkForWeb-5-r.1/Core'), { recursive: true }).catch(() => {})
await copyFile(join(cacheDir, 'assets/js/CubismSdkForWeb-5-r.1/Core/live2dcubismcore.min.js'), join(publicDir, 'assets/js/CubismSdkForWeb-5-r.1/Core/live2dcubismcore.min.js'))
}
}
if (!(await exists(resolve(join(publicDir, 'assets/js/CubismSdkForWeb-5-r.1'))))) {
await mkdir(join(publicDir, 'assets/js/CubismSdkForWeb-5-r.1/Core'), { recursive: true }).catch(() => {})
await copyFile(join(cacheDir, 'assets/js/CubismSdkForWeb-5-r.1/Core/live2dcubismcore.min.js'), join(publicDir, 'assets/js/CubismSdkForWeb-5-r.1/Core/live2dcubismcore.min.js'))
catch (err) {
console.error(err)
throw err
}
}
catch (err) {
console.error(err)
throw err
}
},
},
},
{
name: 'live2d-models-hiyori-free',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
{
name: 'live2d-models-hiyori-free',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
try {
if (!(await exists(resolve(join(cacheDir, 'assets/live2d/models/hiyori_free_zh'))))) {
console.log('Downloading Demo Live2D Model - Hiyori Free...')
const stream = await ofetch('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', { responseType: 'arrayBuffer' })
try {
if (!(await exists(resolve(join(cacheDir, 'assets/live2d/models/hiyori_free_zh'))))) {
console.log('Downloading Demo Live2D Model - Hiyori Free...')
const stream = await ofetch('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', { responseType: 'arrayBuffer' })
console.log('Unzipping Demo Live2D Model - Hiyori Free...')
await mkdir(join(cacheDir, 'assets/live2d/models'), { recursive: true })
await unzip(Buffer.from(stream), join(cacheDir, 'assets/live2d/models'))
console.log('Unzipping Demo Live2D Model - Hiyori Free...')
await mkdir(join(cacheDir, 'assets/live2d/models'), { recursive: true })
await unzip(Buffer.from(stream), join(cacheDir, 'assets/live2d/models'))
console.log('Demo Live2D Model - Hiyori Free downloaded and unzipped.')
}
console.log('Demo Live2D Model - Hiyori Free downloaded and unzipped.')
if (!(await exists(resolve(join(publicDir, 'assets/live2d/models/hiyori_free_zh'))))) {
await mkdir(join(publicDir, 'assets/live2d/models'), { recursive: true }).catch(() => { })
await cp(join(cacheDir, 'assets/live2d/models/hiyori_free_zh'), join(publicDir, 'assets/live2d/models/hiyori_free_zh'), { recursive: true })
}
}
if (!(await exists(resolve(join(publicDir, 'assets/live2d/models/hiyori_free_zh'))))) {
await mkdir(join(publicDir, 'assets/live2d/models'), { recursive: true }).catch(() => { })
await cp(join(cacheDir, 'assets/live2d/models/hiyori_free_zh'), join(publicDir, 'assets/live2d/models/hiyori_free_zh'), { recursive: true })
catch (err) {
console.error(err)
throw err
}
}
catch (err) {
console.error(err)
throw err
}
},
},
},
{
name: 'live2d-models-hiyori-pro',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
{
name: 'live2d-models-hiyori-pro',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
try {
if (!(await exists(resolve(join(cacheDir, 'assets/live2d/models/hiyori_pro_zh'))))) {
console.log('Downloading Demo Live2D Model - Hiyori Pro...')
const stream = await ofetch('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', { responseType: 'arrayBuffer' })
try {
if (!(await exists(resolve(join(cacheDir, 'assets/live2d/models/hiyori_pro_zh'))))) {
console.log('Downloading Demo Live2D Model - Hiyori Pro...')
const stream = await ofetch('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', { responseType: 'arrayBuffer' })
console.log('Unzipping Demo Live2D Model - Hiyori Pro...')
await mkdir(join(cacheDir, 'assets/live2d/models'), { recursive: true })
await unzip(Buffer.from(stream), join(cacheDir, 'assets/live2d/models'))
console.log('Unzipping Demo Live2D Model - Hiyori Pro...')
await mkdir(join(cacheDir, 'assets/live2d/models'), { recursive: true })
// await unzip(Buffer.from(stream), join(cacheDir, 'assets/live2d/models'))
await writeFile(join(cacheDir, 'assets/live2d/models/hiyori_pro_zh.zip'), Buffer.from(stream))
console.log('Demo Live2D Model - Hiyori Pro downloaded and unzipped.')
console.log('Demo Live2D Model - Hiyori Pro downloaded.')
}
if (!(await exists(resolve(join(publicDir, 'assets/live2d/models/hiyori_pro_zh.zip'))))) {
await mkdir(join(publicDir, 'assets/live2d/models'), { recursive: true }).catch(() => { })
await cp(join(cacheDir, 'assets/live2d/models/hiyori_pro_zh.zip'), join(publicDir, 'assets/live2d/models/hiyori_pro_zh.zip'), { recursive: true })
}
// TODO: use motion editor to remap emotions
// const hiyoriEmotions = {
// Idle: [
// {
// File: 'motion/hiyori_m01.motion3.json',
// },
// {
// File: 'motion/hiyori_m05.motion3.json',
// },
// ],
// EmotionHappy: [{ File: 'motion/hiyori_m08.motion3.json' }],
// EmotionSad: [{ File: 'motion/hiyori_m10.motion3.json' }],
// EmotionAngry: [{ File: 'motion/hiyori_m09.motion3.json' }],
// EmotionAwkward: [{ File: 'motion/hiyori_m04.motion3.json' }],
// EmotionThink: [{ File: 'motion/hiyori_m03.motion3.json' }],
// EmotionSurprise: [{ File: 'motion/hiyori_m03.motion3.json' }],
// EmotionQuestion: [{ File: 'motion/hiyori_m10.motion3.json' }],
// }
// const read = await readFile(join(publicDir, 'assets/live2d/models/hiyori_pro_zh/runtime/hiyori_pro_t11.model3.json'), 'utf-8')
// const model = JSON.parse(read.toString()) as { FileReferences: { Motions: Record<string, { File: string }[]> } }
// Object.assign(model.FileReferences.Motions, hiyoriEmotions)
// await writeFile(join(publicDir, 'assets/live2d/models/hiyori_pro_zh/runtime/hiyori_pro_t11.model3.json'), JSON.stringify(model, null, 4), 'utf-8')
}
if (!(await exists(resolve(join(publicDir, 'assets/live2d/models/hiyori_pro_zh'))))) {
await mkdir(join(publicDir, 'assets/live2d/models'), { recursive: true }).catch(() => { })
await cp(join(cacheDir, 'assets/live2d/models/hiyori_pro_zh'), join(publicDir, 'assets/live2d/models/hiyori_pro_zh'), { recursive: true })
catch (err) {
console.error(err)
throw err
}
const hiyoriEmotions = {
Idle: [
{
File: 'motion/hiyori_m01.motion3.json',
},
{
File: 'motion/hiyori_m05.motion3.json',
},
],
EmotionHappy: [{ File: 'motion/hiyori_m08.motion3.json' }],
EmotionSad: [{ File: 'motion/hiyori_m10.motion3.json' }],
EmotionAngry: [{ File: 'motion/hiyori_m09.motion3.json' }],
EmotionAwkward: [{ File: 'motion/hiyori_m04.motion3.json' }],
EmotionThink: [{ File: 'motion/hiyori_m03.motion3.json' }],
EmotionSurprise: [{ File: 'motion/hiyori_m03.motion3.json' }],
EmotionQuestion: [{ File: 'motion/hiyori_m10.motion3.json' }],
}
const read = await readFile(join(publicDir, 'assets/live2d/models/hiyori_pro_zh/runtime/hiyori_pro_t11.model3.json'), 'utf-8')
const model = JSON.parse(read.toString()) as { FileReferences: { Motions: Record<string, { File: string }[]> } }
Object.assign(model.FileReferences.Motions, hiyoriEmotions)
await writeFile(join(publicDir, 'assets/live2d/models/hiyori_pro_zh/runtime/hiyori_pro_t11.model3.json'), JSON.stringify(model, null, 4), 'utf-8')
}
catch (err) {
console.error(err)
throw err
}
},
},
},
{
name: 'vrm-models-sample-a',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
{
name: 'vrm-models-sample-a',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
try {
if (!(await exists(resolve(join(cacheDir, 'assets/vrm/models/AvatarSample-A'))))) {
await mkdir(join(cacheDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true })
try {
if (!(await exists(resolve(join(cacheDir, 'assets/vrm/models/AvatarSample-A'))))) {
await mkdir(join(cacheDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true })
console.log('Downloading VRM Model - Avatar Sample A...')
const res = await ofetch('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', { responseType: 'arrayBuffer' })
console.log('Downloading VRM Model - Avatar Sample A...')
const res = await ofetch('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', { responseType: 'arrayBuffer' })
console.log('Saving VRM Model - Avatar Sample A...')
await writeFile(join(cacheDir, 'assets/vrm/models/AvatarSample-A/AvatarSample_A.vrm'), Buffer.from(res))
console.log('Saving VRM Model - Avatar Sample A...')
await writeFile(join(cacheDir, 'assets/vrm/models/AvatarSample-A/AvatarSample_A.vrm'), Buffer.from(res))
console.log('VRM Model - Avatar Sample A downloaded and saved.')
console.log('VRM Model - Avatar Sample A downloaded and saved.')
}
if (!(await exists(resolve(join(publicDir, 'assets/vrm/models/AvatarSample-A'))))) {
await mkdir(join(publicDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true }).catch(() => { })
await cp(join(cacheDir, 'assets/vrm/models/AvatarSample-A'), join(publicDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true })
}
}
if (!(await exists(resolve(join(publicDir, 'assets/vrm/models/AvatarSample-A'))))) {
await mkdir(join(publicDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true }).catch(() => { })
await cp(join(cacheDir, 'assets/vrm/models/AvatarSample-A'), join(publicDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true })
catch (err) {
console.error(err)
throw err
}
}
catch (err) {
console.error(err)
throw err
}
},
},
},
{
name: 'vrm-models-sample-b',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
{
name: 'vrm-models-sample-b',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
try {
if (!(await exists(resolve(join(cacheDir, 'assets/vrm/models/AvatarSample-B'))))) {
await mkdir(join(cacheDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true })
try {
if (!(await exists(resolve(join(cacheDir, 'assets/vrm/models/AvatarSample-B'))))) {
await mkdir(join(cacheDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true })
console.log('Downloading VRM Model - Avatar Sample B...')
const res = await ofetch('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', { responseType: 'arrayBuffer' })
console.log('Downloading VRM Model - Avatar Sample B...')
const res = await ofetch('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', { responseType: 'arrayBuffer' })
console.log('Saving VRM Model - Avatar Sample B...')
await writeFile(join(cacheDir, 'assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'), Buffer.from(res))
console.log('Saving VRM Model - Avatar Sample B...')
await writeFile(join(cacheDir, 'assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'), Buffer.from(res))
}
if (!(await exists(resolve(join(publicDir, 'assets/vrm/models/AvatarSample-B'))))) {
await mkdir(join(publicDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true }).catch(() => { })
await cp(join(cacheDir, 'assets/vrm/models/AvatarSample-B'), join(publicDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true })
}
}
if (!(await exists(resolve(join(publicDir, 'assets/vrm/models/AvatarSample-B'))))) {
await mkdir(join(publicDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true }).catch(() => { })
await cp(join(cacheDir, 'assets/vrm/models/AvatarSample-B'), join(publicDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true })
catch (err) {
console.error(err)
throw err
}
}
catch (err) {
console.error(err)
throw err
}
},
},
},
// HuggingFace Spaces
LFS({
extraGlobs: [
'*.vrm',
'*.cmo3',
'*.png',
'*.jpg',
'*.jpeg',
'*.gif',
'*.webp',
'*.bmp',
],
}),
SpaceCard({
title: 'アイリ VTuber',
emoji: '🧸',
colorFrom: 'pink',
colorTo: 'pink',
sdk: 'static',
pinned: false,
license: 'mit',
models: ['onnx-community/whisper-base'],
short_description: 'アイリ VTuber. LLM powered Live2D/VRM living character.',
}),
],
// HuggingFace Spaces
LFS({
extraGlobs: [
'*.vrm',
'*.cmo3',
'*.png',
'*.jpg',
'*.jpeg',
'*.gif',
'*.webp',
'*.bmp',
],
}),
SpaceCard({
title: 'アイリ VTuber',
emoji: '🧸',
colorFrom: 'pink',
colorTo: 'pink',
sdk: 'static',
pinned: false,
license: 'mit',
models: ['onnx-community/whisper-base'],
short_description: 'アイリ VTuber. LLM powered Live2D/VRM living character.',
}),
{
name: 'write-port-number-to-file-when-dev',
apply: 'serve',
configureServer(server) {
if (mode !== 'tamagotchi')
return
if (!server.httpServer)
return
server.httpServer.once('listening', () => {
const address = server.httpServer!.address()
if (!address || typeof address !== 'object' || !('port' in address))
return
writeFile(join(cwd(), 'stage.dev.json'), JSON.stringify({
address,
}))
})
},
},
],
}
})
+1
View File
@@ -0,0 +1 @@
out/
+38
View File
@@ -0,0 +1,38 @@
# たまごっち アイリ
A desktop application for たまごっち アイリ.
## Project Setup
### Install
```bash
$ pnpm install
```
### Development
```bash
$ cd /packages/stage
$ pnpm dev:tamagotchi
```
Then open another terminal and run:
```bash
$ cd /packages/tamagotchi
$ pnpm dev:tamagotchi
```
### Build
```bash
# For windows
$ pnpm build:win
# For macOS
$ pnpm build:mac
# For Linux
$ pnpm build:linux
```
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+43
View File
@@ -0,0 +1,43 @@
appId: com.github.moeru-ai.airi-tamagotchi
productName: airi
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: tamagotchi
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: github.com/moeru-ai Contributors
category: Entertainment
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
# publish:
# provider: generic
# url: https://example.com/auto-updates
@@ -0,0 +1,20 @@
import { join, resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
resolve: {
alias: {
'@renderer': resolve(join('src', 'renderer', 'src')),
},
},
plugins: [vue()],
},
})
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@proj-airi/stage-tamagotchi",
"version": "1.0.0",
"description": "An Electron application with Vue and TypeScript",
"author": "LemonNekoGH",
"homepage": "https://electron-vite.org",
"main": "./out/main/index.js",
"scripts": {
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev:tamagotchi": "npm run build:tamagotchi && electron .",
"build:tamagotchi": "npm run typecheck && electron-vite build && rm -rf ./out/renderer && cp -r ../stage/tamagotchi/dist/ ./out/renderer",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build:tamagotchi && electron-builder --dir",
"build:win": "npm run build:tamagotchi && electron-builder --win",
"build:mac": "npm run build:tamagotchi && electron-builder --mac",
"build:linux": "npm run build:tamagotchi && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.2",
"@electron-toolkit/eslint-config-ts": "^2.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@rushstack/eslint-patch": "^1.10.3",
"@types/node": "^20.14.8",
"@vitejs/plugin-vue": "^5.0.5",
"electron": "^31.0.2",
"electron-builder": "^24.13.3",
"electron-vite": "^2.3.0",
"typescript": "^5.5.2",
"vite": "^5.3.1",
"vue": "^3.4.30",
"vue-tsc": "^2.0.22"
}
}
+97
View File
@@ -0,0 +1,97 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { platform } from 'node:process'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import icon from '../../build/icon.png?asset'
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 300,
height: 400,
show: false,
autoHideMenuBar: true,
frame: false,
transparent: true,
resizable: false,
hasShadow: false,
alwaysOnTop: true,
...(platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(import.meta.dirname, '..', 'preload', 'index.js'),
sandbox: false,
},
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
mainWindow.show()
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev) {
// try to read port number from stage.dev.json
const devFile = readFileSync(join(import.meta.dirname, '../../../stage/stage.dev.json'), 'utf-8')
const devInfo = JSON.parse(devFile) as { address: { address: string, family: string, port: number } }
mainWindow.loadURL(`http://localhost:${devInfo.address.port}`).catch((e) => {
console.error('Failed to load URL', e)
})
}
else {
mainWindow.loadFile(join(import.meta.dirname, '..', '..', 'out', 'renderer', 'index.html'))
}
ipcMain.on('move-window', (_, dx, dy) => {
const [currentX, currentY] = mainWindow.getPosition()
mainWindow.setPosition(currentX + dx, currentY + dy)
})
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.github.moeru-ai.airi-tamagotchi')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// IPC test
ipcMain.on('quit', () => app.quit())
createWindow()
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (platform !== 'darwin') {
app.quit()
}
})
// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.
+8
View File
@@ -0,0 +1,8 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: unknown
}
}
+25
View File
@@ -0,0 +1,25 @@
import { contextIsolated } from 'node:process'
import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge } from 'electron'
// Custom APIs for renderer
const api = {}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
}
catch (error) {
console.error(error)
}
}
else {
// @ts-expect-error (define in dts)
window.electron = electronAPI
// @ts-expect-error (define in dts)
window.api = api
}
@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>アイリ</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
</head>
<body>
<h1>PLACE HOLDER, YOU SHOULD NOT SEE THIS DURING DEVELOPMENT</h1>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
{
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }],
"files": []
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
},
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@renderer/*": [
"src/renderer/src/*"
]
}
},
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.vue",
"src/preload/*.d.ts"
]
}
+1740 -99
View File
File diff suppressed because it is too large Load Diff