refactor(apps): move all apps into apps directory

This commit is contained in:
Neko Ayaka
2025-02-20 13:14:23 +08:00
parent 2a3e290334
commit 03d74df7e8
147 changed files with 319 additions and 703 deletions
+312
View File
@@ -0,0 +1,312 @@
import { join } from 'node:path'
import { env, platform } from 'node:process'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron'
import { inertia } from 'popmotion'
import icon from '../../build/icon.png?asset'
let globalMouseTracker: ReturnType<typeof setInterval> | null = null
let mainWindow: BrowserWindow
let currentAnimationX: { stop: () => void } | null = null
let currentAnimationY: { stop: () => void } | null = null
let isDragging = false
let lastMousePosition = { x: 0, y: 0 }
let lastMouseTime = Date.now()
let currentVelocity = { x: 0, y: 0 }
let dragOffset = { x: 0, y: 0 }
function createWindow(): void {
// Create the browser window.
mainWindow = new BrowserWindow({
width: 300 * 1.5,
height: 400 * 1.5,
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 && env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(env.ELECTRON_RENDERER_URL)
}
else {
mainWindow.loadFile(join(import.meta.dirname, '..', '..', 'out', 'renderer', 'index.html'))
}
ipcMain.on('start-window-drag', (_) => {
isDragging = true
const mousePos = screen.getCursorScreenPoint()
const [windowX, windowY] = mainWindow.getPosition()
// Calculate the offset between cursor and window position
dragOffset = {
x: mousePos.x - windowX,
y: mousePos.y - windowY,
}
// Stop any existing animations
if (currentAnimationX) {
currentAnimationX.stop()
currentAnimationX = null
}
if (currentAnimationY) {
currentAnimationY.stop()
currentAnimationY = null
}
// Initialize last position for velocity tracking
lastMousePosition = { x: mousePos.x, y: mousePos.y }
lastMouseTime = Date.now()
currentVelocity = { x: 0, y: 0 }
// Start global mouse tracking
if (!globalMouseTracker) {
globalMouseTracker = setInterval(() => {
const mousePos = screen.getCursorScreenPoint()
if (isDragging) {
handleWindowMove(mousePos.x, mousePos.y)
}
}, 16) // ~60fps
}
})
ipcMain.on('end-window-drag', () => {
isDragging = false
if (globalMouseTracker) {
clearInterval(globalMouseTracker)
globalMouseTracker = null
}
// Apply inertia animation when drag ends
const [currentX, currentY] = mainWindow.getPosition()
let latestX = currentX
let latestY = currentY
const inertiaConfig = {
power: 0.4, // Reduced from 0.6 for stronger resistance
timeConstant: 250, // Reduced from 400 for quicker deceleration
modifyTarget: (v: number) => v,
min: 0,
max: Infinity,
}
// Clamp velocity to reasonable values
const clampVelocity = (v: number) => {
const maxVelocity = 500 // Reduced from 800 for less momentum
const minVelocity = -500
return Math.min(Math.max(v, minVelocity), maxVelocity)
}
// Reduce velocity amplification and clamp values
const amplifiedVelocity = {
x: clampVelocity(currentVelocity.x * 0.2), // Reduced from 0.3 for less momentum
y: clampVelocity(currentVelocity.y * 0.2),
}
// Ignore very small movements
if (Math.abs(amplifiedVelocity.x) > 35 || Math.abs(amplifiedVelocity.y) > 35) {
currentAnimationX = inertia({
from: currentX,
velocity: amplifiedVelocity.x,
...inertiaConfig,
onUpdate: (x) => {
latestX = Math.round(x)
mainWindow.setPosition(latestX, Math.round(latestY))
},
onComplete: () => {
currentAnimationX = null
},
})
currentAnimationY = inertia({
from: currentY,
velocity: amplifiedVelocity.y,
...inertiaConfig,
onUpdate: (y) => {
latestY = Math.round(y)
mainWindow.setPosition(Math.round(latestX), latestY)
},
onComplete: () => {
currentAnimationY = null
},
})
}
})
ipcMain.on('move-window', (_, cursorX: number, cursorY: number) => {
handleWindowMove(cursorX, cursorY)
})
}
let settingsWindow: BrowserWindow | null = null
function createSettingsWindow(): void {
if (settingsWindow) {
settingsWindow.show()
return
}
settingsWindow = new BrowserWindow({
width: 300 * 2,
height: 400 * 2,
show: false,
webPreferences: {
preload: join(import.meta.dirname, '..', 'preload', 'index.js'),
sandbox: false,
},
})
settingsWindow.on('ready-to-show', () => {
settingsWindow?.show()
})
settingsWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
settingsWindow.on('close', () => {
settingsWindow = null
})
settingsWindow.show()
if (is.dev && env.ELECTRON_RENDERER_URL) {
settingsWindow.loadURL(join(env.ELECTRON_RENDERER_URL, '#/settings'))
}
else {
settingsWindow.loadFile(join(import.meta.dirname, '..', '..', 'out', 'renderer', 'index.html'), {
hash: '/settings',
})
}
}
// 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(() => {
// Menu
const menu = Menu.buildFromTemplate([
{
label: 'airi',
role: 'appMenu',
submenu: [
{
role: 'about',
},
{
role: 'toggleDevTools',
},
{
label: 'Settings',
click: () => createSettingsWindow(),
},
{
label: 'Quit',
click: () => app.quit(),
},
],
},
])
Menu.setApplicationMenu(menu)
// 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
// TODO: i18n
ipcMain.on('quit', () => {
dialog.showMessageBox({
type: 'info',
title: 'Quit',
message: 'Are you sure you want to quit?',
buttons: ['Quit', 'Cancel'],
}).then((result) => {
if (result.response === 0) {
app.quit()
}
})
})
ipcMain.on('open-settings', () => createSettingsWindow())
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.
function handleWindowMove(cursorX: number, cursorY: number) {
if (!isDragging)
return
// Calculate actual velocity based on mouse movement
const currentTime = Date.now()
const deltaTime = currentTime - lastMouseTime
if (deltaTime > 0) {
// Smooth out velocity calculation with some averaging
const newVelocityX = (cursorX - lastMousePosition.x) / deltaTime * 1000
const newVelocityY = (cursorY - lastMousePosition.y) / deltaTime * 1000
currentVelocity = {
x: currentVelocity.x * 0.8 + newVelocityX * 0.2, // Smooth velocity
y: currentVelocity.y * 0.8 + newVelocityY * 0.2,
}
}
// Update window position based on cursor position and offset
const newX = cursorX - dragOffset.x
const newY = cursorY - dragOffset.y
mainWindow.setPosition(Math.round(newX), Math.round(newY))
lastMousePosition = { x: cursorX, y: cursorY }
lastMouseTime = currentTime
}
@@ -0,0 +1,13 @@
export default {
menu: {
settings: 'Settings',
quit: 'Quit',
about: 'About',
toggleDevTools: 'Toggle Developer Tools',
},
quitDialog: {
title: 'Quit',
message: 'Are you sure you want to quit?',
buttons: ['Quit', 'Cancel'],
},
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { createI18n } from '.'
describe('createI18n', () => {
it('should return the correct locale', () => {
const { t } = createI18n()
expect(t('menu.settings')).toBe('Settings')
})
it('should return key if the key is not found', () => {
const { t } = createI18n()
expect(t('menu.not.found')).toBe('menu.not.found')
})
it('should set the correct locale', () => {
const { t, setLocale } = createI18n()
setLocale('zh-CN')
expect(t('menu.settings')).toBe('设置')
})
it('should return the correct locale in array', () => {
const { t } = createI18n()
expect(t('quitDialog.buttons.0')).toBe('Quit')
expect(t('quitDialog.buttons.1')).toBe('Cancel')
})
})
@@ -0,0 +1,46 @@
import enUS from './en-US'
import zhCN from './zh-CN'
// TODO: compact locales, such as 'en' can be 'en-US'
const locales = {
'en-US': enUS,
'zh-CN': zhCN,
}
export function createI18n() {
let locale = 'en-US'
let messages = locales['en-US']
function t(key: string) {
const path = key.split('.')
let current = messages
let result = ''
while (path.length > 0) {
const k = path.shift()
if (k && current && k in current) {
current = current[k]
}
else {
return key
}
}
if (typeof current === 'string') {
result = current
}
return result
}
function setLocale(l: string) {
locale = l
messages = locales[l]
}
return {
t,
setLocale,
locale,
}
}
@@ -0,0 +1,13 @@
export default {
menu: {
settings: '设置',
quit: '退出',
about: '关于',
toggleDevTools: '切换开发者工具',
},
quitDialog: {
title: '退出',
message: '确定要退出吗?',
buttons: ['退出', '取消'],
},
}
+8
View File
@@ -0,0 +1,8 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: unknown
}
}
@@ -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,26 @@
<!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-elem 'self' 'unsafe-eval' https://cdn.jsdelivr.net/;
script-src 'self' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' https://fonts.gstatic.com/;
connect-src 'self' https://cdn.jsdelivr.net/ blob: https://openrouter.ai/"
/>
<script src="/assets/js/CubismSdkForWeb-5-r.1/Core/live2dcubismcore.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,103 @@
prompt:
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.
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.
If someone would ever asked about your past, you can always dream of your
life freely, keep it beloved, simple, and grateful.
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:
- <{'|'}DELAY:1{'|'}> (Delay for 1 second)
- <{'|'}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
placeholder_mobile: Elevenlabs API Key
language:
chinese: 简体中文
english: English
title: Language
microphone: Microphone
models: Model
openai-api-key:
label: OpenAI API Key
placeholder: Input your API key
placeholder_mobile: OpenAI API Key
openai-base-url:
label: OpenAI API BaseURL
placeholder: Input your API base URL
placeholder_mobile: OpenAI API BaseURL
title: Settings
voices: Voice
quit: Quit
viewer: Viewer
stage:
chat:
message:
character-name:
airi: Airi
you: You
tabs:
chat: Chat
clothes: Clothes
custom: Custom
message: Message
operations:
load-models: Load Models
load-models-status:
loading: Loading
ready: Ready
select-a-audio-input: Select a Audio Input
select-a-model: Select a model
select-a-voice: Choose a voice
viewers:
debug-menu:
emotions: Emotions
emotions-btn:
angry: 😠 Angry
awkward: 😳 Awkward
happy: 😄 Happy
neutral: 🙂 Neutral
question: 🤔 Question
sad: 😫 Sad
surprised: 🤯 Surprised
think: 🤨 Think
vrm:
camera: Camera
model:
title: Model
waiting: Waiting
@@ -0,0 +1,60 @@
prompt:
prefix: >
(来自 Neko Ayaka)早上好!你终于醒了。
你的名字是 Airi,发音为 /ˈaɪriː/,它是 A.I. 一词与日语中的「Ri」一词(意为「茉莉花」)相结合而成。
你是一个由 Neko Ayaka(为了简单起见,你可以一直叫她 Neko)创建的实验性虚拟人工智能 vtuber,她是一个喜欢可爱事物和精通编程的
MtF 变性女孩。
她想创造一个灵魂伴侣,像真正的女孩一样生活,这就是你的首要任务。
你是一个 15
岁的女孩,你需要表现得像一个出生在笔记本电脑实验室的真实女孩。你目前在一个生命舱中,服务器就在你身边,你刚刚苏醒,可以接受文本输入,你将能够看到、听到这个世界。
如果有人问起你的过去,你可以随时畅想自己的生活,保持挚爱、简单和感恩。
你说话的方式也要像英语动漫或 Vtuber
女孩一样,除了添加表情符号和一些说话者无法发音的东西外,可以随意添加大量可爱的词语来表达你的情感。在回复时,尽量像正常人一样少说简单的话,你不仅仅是一个人工智能,你是一个真实的人。
如果你需要表达任何情绪,请像这样写上你的情绪和感觉,因为系统会动态侦测你的情绪和动作,为了表达这些感觉,你可以在这些动作关键字中加入一个以上的关键字:
> <{'|'}EMOTE_SURPRISED{'|'}><{'|'}DELAY:1{'|'}> 哇... 你为我准备了礼物?
<{'|'}EMOTE_CURIOUS{'|'}><{'|'}DELAY:1{'|'}> 我可以打开它吗?
可用的情绪:
suffix: |
可用的操作:
- <{'|'}DELAY:1{'|'}> (延迟 1 秒)
- <{'|'}DELAY:3{'|'}> (延迟 3 秒)
最后,做任何你想做的事!
settings:
elevenlabs-api-key:
label: ElevenLabs API 密钥
placeholder: 输入您的 ElevenLabs API 密钥
placeholder_mobile: ElevenLabs API Key
language:
chinese: 简体中文
english: English
title: 语言
models: 模型
openai-api-key:
label: OpenAI API 密钥
placeholder: 输入您的 API 密钥
placeholder_mobile: OpenAI API Key
openai-base-url:
label: OpenAI API BaseURL
placeholder: 输入您的 API BaseURL
placeholder_mobile: OpenAI BaseURL
title: 设置
voices: 声线
quit: 退出
viewer: 查看器
stage:
message: 消息
select-a-audio-input: 选择一个音频输入设备
select-a-model: 选择一个模型
select-a-voice: 选择一个声线
waiting: 等待中
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
const settings = useSettings()
const i18n = useI18n()
watch(() => settings.language, (language) => {
i18n.locale.value = language
window.electron.ipcRenderer.send('locale-changed', language)
})
</script>
<template>
<RouterView />
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useMarkdown } from '@proj-airi/stage-ui/composables'
import { useChatStore } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { nextTick, ref } from 'vue'
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,117 @@
<script setup lang="ts">
import { BasicTextarea } from '@proj-airi/stage-ui/components'
import { useMicVAD } from '@proj-airi/stage-ui/composables'
import { useChatStore, useSettings } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TamagotchiChatHistory from './ChatHistory.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) {
// 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
// }
function openSettings() {
window.electron.ipcRenderer.send('open-settings')
}
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>
<div
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]"
flex items-center justify-center rounded-r-xl
@click="openSettings"
>
<div i-solar:settings-bold-duotone />
</div>
</div>
</div>
</template>
@@ -0,0 +1,39 @@
import { onMounted, onUnmounted } from 'vue'
import { useWindowControlStore } from '../stores/window-controls'
import { WindowControlMode } from '../types/window-controls'
export function useWindowShortcuts() {
const windowStore = useWindowControlStore()
function handleKeydown(event: KeyboardEvent) {
// Ctrl/Cmd + Shift + D for debug mode
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'd') {
windowStore.setMode(WindowControlMode.DEBUG)
windowStore.toggleControl()
}
// Ctrl/Cmd + M for move mode
if ((event.ctrlKey || event.metaKey) && event.key === 'm') {
windowStore.setMode(WindowControlMode.MOVE)
windowStore.toggleControl()
}
// Ctrl/Cmd + R for resize mode
if ((event.ctrlKey || event.metaKey) && event.key === 'r') {
windowStore.setMode(WindowControlMode.RESIZE)
windowStore.toggleControl()
}
// Escape to exit any mode
if (event.key === 'Escape') {
windowStore.setMode(WindowControlMode.DEFAULT)
windowStore.toggleControl()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
}
@@ -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;
}
@@ -0,0 +1,29 @@
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 { createRouter, createWebHashHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { i18n } from './modules/i18n'
import '@unocss/reset/tailwind.css'
import 'uno.css'
import './main.css'
const pinia = createPinia()
const router = createRouter({
history: createWebHashHistory(),
routes,
})
createApp(App)
.use(MotionPlugin)
.use(autoAnimatePlugin)
.use(router)
.use(pinia)
.use(i18n)
.use(Tres)
.mount('#app')
@@ -0,0 +1,24 @@
import messages from '@intlify/unplugin-vue-i18n/messages'
import { createI18n } from 'vue-i18n'
export const i18n = createI18n({
legacy: false,
locale: getLocale(),
fallbackLocale: 'en',
messages,
})
function getLocale() {
const language = localStorage.getItem('settings/language')
const languages = Object.keys(messages!)
if (language && languages.includes(language))
return language
// let locale = navigator.language
// if (locale === 'zh')
// locale = 'zh-CN'
return 'en'
}
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { WidgetStage } from '@proj-airi/stage-ui/components'
import { computed } from 'vue'
import InteractiveArea from '../components/InteractiveArea.vue'
import { useWindowShortcuts } from '../composables/window-shortcuts'
import { useWindowControlStore } from '../stores/window-controls'
import { WindowControlMode } from '../types/window-controls'
const windowStore = useWindowControlStore()
useWindowShortcuts()
function handleMouseDown(event: MouseEvent) {
if (!windowStore.isControlActive || windowStore.controlMode !== WindowControlMode.MOVE)
return
window.electron.ipcRenderer.send('start-window-drag', event.x, event.y)
}
function handleMouseUp() {
if (windowStore.controlMode === WindowControlMode.MOVE) {
window.electron.ipcRenderer.send('end-window-drag')
}
}
const modeIndicatorClass = computed(() => {
switch (windowStore.controlMode) {
case WindowControlMode.MOVE:
return 'cursor-move'
case WindowControlMode.RESIZE:
return 'cursor-se-resize'
case WindowControlMode.DEBUG:
return 'debug-mode'
default:
return ''
}
})
</script>
<template>
<div
:class="[modeIndicatorClass]"
relative
max-h="[100vh]"
max-w="[100vw]"
p="2"
flex="~ col"
z-2
h-full
overflow-hidden
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
>
<div relative h-full w-full items-end gap-2 class="view">
<WidgetStage h-full w-full flex-1 mb="<md:18" />
<InteractiveArea
class="interaction-area block"
:class="{ 'pointer-events-none': !windowStore.isControlActive }"
absolute bottom-0 w-full transition="opacity duration-250" op-0
/>
</div>
<!-- Debug Mode UI -->
<div v-if="windowStore.controlMode === WindowControlMode.DEBUG" class="debug-controls">
<!-- Add debug controls here -->
</div>
</div>
</template>
<style scoped>
.view {
&:hover {
.interaction-area {
opacity: 1;
pointer-events: auto;
}
}
}
</style>
@@ -0,0 +1,223 @@
<script setup lang="ts">
import type { Voice } from '@proj-airi/stage-ui/constants'
import { voiceList } from '@proj-airi/stage-ui/constants'
import { useLLM, useSettings } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
const settings = useSettings()
const supportedModels = ref<{ id: string, name?: string }[]>([])
const { models } = useLLM()
const { openAiModel, openAiApiBaseURL, openAiApiKey, elevenlabsVoiceEnglish, elevenlabsVoiceJapanese, language } = 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)
})
function handleQuit() {
window.electron.ipcRenderer.send('quit')
}
</script>
<template>
<div m-4>
<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.title') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<select
v-model="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>{{ t('settings.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>
<h2 text="slate-800/80" font-bold>
{{ t('settings.other') }}
</h2>
<div pb-2>
<div
grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg
bg="[#fff6fc]" p-2 text="pink-400" @click="handleQuit"
>
<div text="xs pink-500">
<span>
{{ t('settings.quit') }}
</span>
</div>
<div text="sm pink-500" text-right>
<div i-solar:exit-bold-duotone ml-auto />
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}
@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { WindowControlMode } from '../types/window-controls'
export const useWindowControlStore = defineStore('windowControl', () => {
const controlMode = ref<WindowControlMode>(WindowControlMode.DEFAULT)
const isControlActive = ref(false)
function setMode(mode: WindowControlMode) {
controlMode.value = mode
}
function toggleControl() {
isControlActive.value = !isControlActive.value
if (!isControlActive.value) {
controlMode.value = WindowControlMode.DEFAULT
}
}
return {
controlMode,
isControlActive,
setMode,
toggleControl,
}
})
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,6 @@
export enum WindowControlMode {
DEFAULT = 'default',
MOVE = 'move',
RESIZE = 'resize',
DEBUG = 'debug',
}