refactor(stage-tamagotchi): rewrite it in rust (#90)

* chore: create tauri project, let window transparent

* chore: fix i18n

* chore: remove cocoa

* feat: open settings window

* feat: window controls shortcuts

* feat: system tray

* chore: install dependencies
This commit is contained in:
LemonNeko
2025-03-24 03:03:14 +08:00
committed by GitHub
parent 06a021b9de
commit 758e30a123
82 changed files with 11325 additions and 7 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
"i18n-ally.localesPaths": [
"apps/stage-web/locales",
"apps/stage-tamagotchi/src/renderer/locales",
"apps/stage-tamagotchi/src/main/locales"
"apps/stage-tamagotchi/src/main/locales",
"apps/stage-tamagotchi-tauri/locales"
],
"i18n-ally.sortKeys": true,
"i18n-ally.enabledFrameworks": [
@@ -0,0 +1 @@
nodejs 22.12.0
+51
View File
@@ -0,0 +1,51 @@
# たまごっち アイリ
A desktop application for たまごっち アイリ.
## Project Setup
### Install
```bash
$ pnpm install
```
### Development
```bash
$ cd /apps/stage
$ pnpm dev:tamagotchi
```
Then open another terminal and run:
```bash
$ cd /apps/tamagotchi
$ pnpm dev:tamagotchi
```
### Build
```bash
# For windows
$ pnpm build:win
# For macOS
$ pnpm build:mac
# For Linux
$ pnpm build:linux
```
## TODO List
- [ ] Window control: Move, resize, hide, show
- [ ] Motions follow the cursor
- [ ] System tray
- [ ] tauri i18n
- [ ] Multi-DPI
- [ ] CI/CD
- [ ] Multi monitor
- [ ] Send message via macOS Spotlight
- [ ] Steam
- [ ] CSP settings
@@ -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.

After

Width:  |  Height:  |  Size: 13 KiB

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

+26
View File
@@ -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>
+381
View File
@@ -0,0 +1,381 @@
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:
language:
chinese: 简体中文
english: English
title: Language
live2d:
change-model:
from-file: Load from File
from-file-select: Select
from-url: Load from URL
from-url-confirm: Load
from-url-placeholder: Enter Live2D model URL
title: Change Model
edit-motion-map:
title: Edit motion map
map-motions:
play: Play Motion
title: Map Motions
title: Live2D Settings
microphone: Microphone
models: Model
pages:
models:
description: Live2D, VRM, etc.
title: Models
modules:
consciousness:
description: Personality, desired model, etc.
sections:
section:
provider-model-selection:
collapse: Collapse
custom_model_placeholder: Enter custom model name...
description: Select the suitable LLM provider for consciousness
error: Error loading models
expand: Expand
loading: Loading available models...
manual_model_name: Model Name
manual_model_placeholder: Enter the model name to use with this provider
no_models: No models available
no_models_description: No models were found for this provider
no_search_results: No matching models
no_search_results_description: No models match '{query}'. Try a different search term.
not_supported: Model listing not supported
not_supported_description: This provider doesn't support model listing functionality
search_placeholder: Search models...
search_results: Found {count} of {total} models
show_less: Show less
show_more: Show more
subtitle: Select a model from the provider
title: Model
title: Consciousness
description: Thinking, vision, speech synthesis, gaming, etc.
gaming-factorio:
description: Playing Factorio!
title: Factorio
gaming-minecraft:
description: Playing Minecraft!
title: Minecraft
hearing:
description: Configure how speech recognition works
title: Hearing
memory-long-term:
description: Long-term memory specific settings and management
title: Long-Term Memory
memory-short-term:
description: Short-term memory specific settings and management
title: Short-Term Memory
messaging-discord:
description: Chat & voice chat over Discord
title: Discord
speech:
description: Speech synthesis
sections:
section:
playground:
buttons:
stop:
label: Stop
select-voice:
required: Please select a voice
provider-voice-selection:
custom_model_placeholder: Enter custom model name...
custom_voice_placeholder: Enter custom voice ID...
description: Select the suitable speech provider
no_models: No models available
no_models_description: No models were found for this provider
no_voices: No voices available
no_voices_description: No voices were found for this provider
search_models_placeholder: Search models...
search_models_results: Found {count} of {total} models
search_voices_placeholder: Search voices...
search_voices_results: Found {count} of {total} voices
show_less: Show less
show_more: Show more
title: Provider
voice-settings:
input-ssml:
placeholder: Enter SSML text...
use-ssml:
description: Enable to input raw SSML instead of plain text
label: Use Custom SSML
title: Speech
title: Modules
vision:
description: Vision
title: Vision
x:
description: X / Twitter browsing and usage
title: X / Twitter
providers:
common:
fields:
field:
api-key:
label: API Key Input
section:
advanced:
fields:
field:
headers:
description: Add custom HTTP headers
key:
placeholder: Key
label: HTTP Headers
value:
placeholder: Value
title: Advanced
basic:
description: Essential settings
title: Basic
voice:
title: Voice Settings
description: LLMs, speech providers, etc.
provider:
cloudflare-workers-ai:
description: cloudflare.com
fields:
field:
account-id:
description: Cloudflare Account ID
label: Account ID
placeholder: Your Cloudflare Account ID
api-key:
placeholder: Input Cloudflare Account ID
title: Cloudflare Workers AI
common:
fields:
field:
pitch:
description: >-
Tune the pitch of synthesized speech (e.g., sharper or
coarser)
label: Pitch
speed:
description: Adjust the speed of speech
label: Speed
volume:
description: Adjust the volume of speech
label: Volume
deepseek:
description: deepseek.com
title: DeepSeek
elevenlabs:
description: elevenlabs.io
fields:
field:
simularity-boost:
description: Voice similarity adherence
label: Similarity Boost
speaker-boost:
description: Enhance speaker similarity
label: Speaker Boost
speed:
description: Speech generation speed
label: Speed
stability:
description: Voice stability and randomness
label: Stability
style:
description: Voice style exaggeration
label: Style
playground:
buttons:
button:
test-voice:
generating: Generating...
label: Test Voice
fields:
field:
input:
placeholder: Enter text to test the voice...
language:
description: Select voice language
label: Language
voice:
description: Select preferred voice
label: Voice
title: Voice Playground
validation:
error-missing-api-key: Please enter an API key to test the voice.
title: ElevenLabs
fireworks:
description: fireworks.ai
title: Fireworks.ai
microsoft-speech:
fields:
field:
region:
description: Speech Service region
label: Region
mistral:
description: mistral.ai
title: Mistral
moonshot:
description: moonshot.ai
title: Moonshot AI
novita:
description: novita.ai
title: Novita
ollama:
description: ollama.ai
title: Ollama
openai:
description: openai.com
title: OpenAI
openrouter:
description: openrouter.ai
title: OpenRouter
together:
description: together.ai
title: Together.ai
vllm:
description: vllm.ai
title: vLLM
xai:
description: x.ai
title: xAI
title: Providers
themes:
description: Customize your stage!
sections:
section:
custom-color:
fields:
field:
primary-color:
label: Primary color
reset:
label: Reset to Default
rgb-on:
title: I Want It Dynamic!
title: Custom Colors
theme-presets:
preset:
chinese:
description: >-
Traditional Chinese colors, derived from ancient textiles,
porcelain and paintings
title: Chinese Traditional Colors
japanese:
description: Traditional Japanese color palette
title: Japanese Colors
monet:
description: Impressionist palette inspired by Claude Monet's works
title: Monet Colors
morandi:
description: Soft, muted tones inspired by Giorgio Morandi's paintings
title: Morandi Colors
nordic:
description: Scandinavian minimalist color scheme
title: Nordic Colors
title: Theme Presets
title: Themes
shortcuts:
title: Shortcuts
description: Configure the shortcuts for the stage
sections:
section:
window-controls:
title: Window Controls
press-keys-hint:
label: Press Keys...Or Escape to cancel
fields:
field:
toggle-resize:
label: Toggle Resize
toggle-move:
label: Toggle Move
toggle-ignore-mouse-event: # TODO: Add this feature
label: Toggle Ignore Mouse Event
sections:
section:
general:
title: General
dark-mode: Dark mode
stage-transitions: Stage Transitions
title: Settings
voices: Voice
stage:
chat:
message:
character-name:
airi: Airi
you: You
core-system: Core System
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,343 @@
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:
language:
chinese: 简体中文
english: English
title: 语言
live2d:
change-model:
from-file: 从文件加载
from-file-select: 选择
from-url: 从 URL 加载
from-url-confirm: 加载
from-url-placeholder: 输入 Live2D 模型 URL
title: 更换模型
map-motions:
play: 播放动作
title: 映射动作
title: Live2D 设置
microphone: 麦克风
models: 模型
pages:
models:
description: 切换角色的 Live2DVRM 模型
title: 角色模型
modules:
consciousness:
description: 人格设定、使用的模型等配置
sections:
section:
provider-model-selection:
collapse: 折叠
custom_model_placeholder: 输入自订模型名字
description: 为意识选择合适的 LLM 提供商
error: 获取出错啦
expand: 展开
loading: 获取模型中...
manual_model_name: 模型名字
manual_model_placeholder: 输入与该提供商一起使用的模型名字
no_models: 没有任何可用的模型
no_models_description: 该提供商的模型列表没有返回任何的数据
no_search_results: 没有匹配的模型
no_search_results_description: 没有模型匹配 '{query}' 关键字,请尝试一下别的关键词呢
not_supported: 无法列出模型
not_supported_description: 此提供商不支持列出模型的功能
search_placeholder: 搜索模型...
search_results: 找到 {count} / {total} 个模型
show_less: 显示更多
show_more: 收起
subtitle: 选择一个模型
title: 模型
title: 意识
description: 思维,视觉,言语综合,游戏等
gaming-factorio:
description: 一起玩 Factorio
title: 异星工厂 Factorio
gaming-minecraft:
description: 一起 Minecraft
title: 我的世界 Minecraft
hearing:
description: 语音识别
title: 听觉
memory-long-term:
description: 长期记忆
title: 长期记忆
memory-short-term:
description: 短期记忆
title: 短期记忆
messaging-discord:
description: Discord 发消息和语音聊天
title: Discord
speech:
description: 语音合成
sections:
section:
playground:
buttons:
stop:
label: 停止
select-voice:
required: 请选择声线
provider-voice-selection:
custom_model_placeholder: 输入指定模型名字...
custom_voice_placeholder: 输入指定声线 ID...
description: 选择合适的语音提供商
no_models: 没有任何可用的模型
no_models_description: 该提供商的模型列表没有返回任何的数据
no_voices: 没有任何可用的声线
no_voices_description: 该提供商的声线列表没有返回任何的数据
pause: Pause
play_sample: Play
search_models_placeholder: 搜索模型...
search_models_results: 找到 {count} / {total} 个模型
search_voices_placeholder: 搜索声线...
search_voices_results: 找到 {count} / {total} 个声线
show_less: 显示更少
show_more: 显示更多
title: 选择语音合成提供商
voice-settings:
input-ssml:
placeholder: 请输入 SSML 文本
use-ssml:
description: 启用后可以输入原始 SSML 而不是纯文本
label: 选用自定义 SSML
title: 发声
title: 机体模块
vision:
description: 视觉
title: 视觉
x:
description: X / Twitter 的浏览和使用
title: X / Twitter
providers:
common:
fields:
field:
api-key:
label: API 密钥
section:
advanced:
fields:
field:
headers:
description: 添加自定义 HTTP 标头
key:
placeholder: 请求头名字
label: HTTP 请求头
value:
placeholder: 请求头数值
title: 高级配置
basic:
description: 基本设置
title: 基础配置
voice:
title: 声音配置
description: LLM,语音合成,语音识别提供商等
provider:
cloudflare-workers-ai:
description: Cloudflare.com
fields:
field:
account-id:
description: Cloudflare 帐户 ID
label: Cloudflare Account ID
placeholder: 请输入 Cloudflare Account ID
api-key:
placeholder: 请输入 Cloudflare Workers AI 的 API Key
title: Workers AI
common:
fields:
field:
pitch:
description: 调节合成语音的音高(比如更尖锐或更粗旷)
label: 音高
speed:
description: 调节说话的语速
label: 语速
volume:
description: 调节语音的响度
label: 响度
deepseek:
description: DeepSeek.com
title: 深度求索 DeepSeek
elevenlabs:
description: Elevenlabs.io
fields:
field:
simularity-boost:
description: 声音相似性依从性
label: 相似度修正
speaker-boost:
description: 增强说话者的相似性
label: 扬声器增强
speed:
description: 语音生成速度
label: 语速
stability:
description: 语音稳定性和随机性
label: 稳定性
style:
description: 语音风格夸张
label: 风格
playground:
buttons:
button:
test-voice:
generating: 生成中...
label: 测试声音
fields:
field:
input:
placeholder: 输入用于测试发音的文本
language:
description: 选择语音语言
label: 语言
voice:
description: 选择首选语音
label: 声线
title: 实验平台
validation:
error-missing-api-key: 需要填写 API Key 才能用哦!
title: ElevenLabs (11labs)
fireworks:
description: Fireworks.ai
title: Fireworks.ai
microsoft-speech:
fields:
field:
region:
description: 服务 Endpoint 地区(比如亚太 eastasia 区域)
label: Endpoint 地区
mistral:
description: mistral.ai
title: Mistral
moonshot:
description: Moonshot.ai
title: 月之暗面
novita:
description: novita.ai
title: Novita
ollama:
description: ollama.ai
title: Ollama
openai:
description: OpenAi.com
title: OpenAI
openrouter:
description: OpenRouter.ai
title: OpenRouter
together:
description: 一起
title: Together.ai
vllm:
description: vllm.ai
title: vLLM
xai:
description: X.AI
title: xAI
title: 提供商
themes:
description: 自定义你的舞台外观!
sections:
section:
custom-color:
fields:
field:
primary-color:
label: 主题色
reset:
label: 重设为默认主题色
rgb-on:
title: RGB ON
title: 自定义主题色
theme-presets:
preset:
chinese:
title: 中国传统颜色
japanese:
title: 日本颜色
monet:
title: 莫奈颜色
morandi:
title: Morandi 颜色
nordic:
title: 北欧颜色
title: 主题预设
title: 舞台主题
shortcuts:
title: 快捷键
description: 配置舞台快捷键
sections:
section:
window-controls:
title: 窗口控制
press-keys-hint:
label: 按下快捷键...或按下 Esc 取消
fields:
field:
toggle-resize:
label: 切换大小
toggle-move:
label: 切换移动
toggle-ignore-mouse-event: # TODO: Add this feature
label: 切换鼠标穿透
sections:
section:
general:
title: 通用
dark-mode: 暗黑模式
stage-transitions: 动画效果
title: 设置
voices: 声线
stage:
message: 消息
select-a-audio-input: 选择一个音频输入设备
select-a-model: 选择一个模型
select-a-voice: 选择一个声线
waiting: 等待中
chat:
message:
character-name:
airi: Airi
you:
core-system: 核心系统
tabs:
chat: 聊天
clothes: 换装
custom: 自定义
operations:
load-models: 加载推理模型
load-models-status:
loading: 加载中
ready: 已就绪
+129
View File
@@ -0,0 +1,129 @@
{
"name": "@proj-airi/stage-tamagotchi-tauri",
"version": "0.3.6",
"private": true,
"description": "Airi tamagotchi app written in Tauri",
"author": "LemonNekoGH",
"scripts": {
"typecheck": "vue-tsc --noEmit --composite false",
"start": "electron-vite preview",
"dev": "vite",
"build": "vite build",
"build:unpack": "tauri build --no-bundle",
"build:win": "tauri build --target x86_64-pc-windows-msvc",
"build:mac": "tauri build --target aarch64-apple-darwin",
"build:linux": "tauri build --target x86_64-unknown-linux-gnu"
},
"dependencies": {
"@11labs/client": "^0.0.8",
"@formkit/auto-animate": "^0.8.2",
"@huggingface/transformers": "^3.4.0",
"@pixi/app": "6",
"@pixi/constants": "6",
"@pixi/core": "6",
"@pixi/display": "6",
"@pixi/extensions": "6",
"@pixi/interaction": "6",
"@pixi/loaders": "6",
"@pixi/math": "6",
"@pixi/runner": "6",
"@pixi/settings": "6",
"@pixi/sprite": "6",
"@pixi/ticker": "6",
"@pixi/utils": "6",
"@pixiv/three-vrm": "^3.3.6",
"@pixiv/three-vrm-animation": "^3.3.6",
"@pixiv/three-vrm-core": "^3.3.6",
"@proj-airi/stage-ui": "workspace:^",
"@ricky0123/vad-web": "^0.0.22",
"@tauri-apps/api": "^2.4.0",
"@tauri-apps/plugin-os": ">=2",
"@tresjs/cientos": "^4.2.0",
"@tresjs/core": "^4.3.3",
"@unhead/vue": "^2.0.0-rc.13",
"@vueuse/core": "^13.0.0",
"@vueuse/head": "^2.0.0",
"@vueuse/shared": "^13.0.0",
"@xsai-ext/providers-cloud": "catalog:",
"@xsai-ext/providers-local": "catalog:",
"@xsai-ext/shared-providers": "catalog:",
"@xsai/generate-speech": "catalog:",
"@xsai/generate-text": "catalog:",
"@xsai/model": "catalog:",
"@xsai/shared-chat": "catalog:",
"@xsai/stream-text": "catalog:",
"@xsai/utils-chat": "catalog:",
"culori": "^4.0.1",
"defu": "^6.1.4",
"drizzle-kit": "^0.30.5",
"drizzle-orm": "^0.40.1",
"jszip": "^3.10.1",
"localforage": "^1.10.0",
"nprogress": "^0.2.0",
"ofetch": "^1.4.1",
"onnxruntime-web": "^1.21.0",
"pinia": "^3.0.1",
"pixi-filters": "^4.2.0",
"pixi-live2d-display": "^0.4.0",
"popmotion": "^11.0.5",
"radix-vue": "^1.9.17",
"rehype-stringify": "^10.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"shiki": "^3.2.1",
"three": "^0.174.0",
"unified": "^11.0.5",
"valibot": "1.0.0-beta.9",
"vaul-vue": "^0.4.1",
"vue": "^3.5.13",
"vue-demi": "^0.14.10",
"vue-i18n": "^11.1.2",
"vue-router": "^4.5.0",
"xsschema": "catalog:",
"yauzl": "^3.2.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@iconify-json/carbon": "^1.2.8",
"@iconify-json/eos-icons": "^1.2.2",
"@iconify-json/lucide": "^1.2.31",
"@iconify-json/mingcute": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.29",
"@iconify-json/solar": "^1.2.2",
"@iconify-json/svg-spinners": "^1.2.2",
"@iconify-json/vscode-icons": "^1.2.16",
"@iconify/utils": "^2.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.5",
"@proj-airi/drizzle-duckdb-wasm": "workspace:^",
"@proj-airi/elevenlabs": "workspace:^",
"@proj-airi/lobe-icons": "^1.0.4",
"@proj-airi/provider-transformers": "workspace:^",
"@proj-airi/ui-transitions": "workspace:^",
"@proj-airi/unplugin-fetch": "^0.1.4",
"@proj-airi/unplugin-live2d-sdk": "^0.1.2",
"@shikijs/markdown-it": "^3.2.1",
"@tauri-apps/cli": "^2.3.1",
"@types/culori": "^2.1.1",
"@types/markdown-it-link-attributes": "^3.0.5",
"@types/nprogress": "^0.2.3",
"@types/three": "^0.174.0",
"@types/yauzl": "^2.10.3",
"@unocss/reset": "^66.1.0-beta.6",
"@vitejs/plugin-vue": "^5.2.3",
"@vue-macros/volar": "^3.0.0-beta.5",
"@vueuse/motion": "^3.0.3",
"less": "^4.2.2",
"markdown-it-link-attributes": "^4.0.1",
"unocss-preset-scrollbar": "^3.2.0",
"unplugin-auto-import": "^19.1.1",
"unplugin-vue-components": "^28.4.1",
"unplugin-vue-macros": "^2.14.5",
"unplugin-vue-markdown": "^28.3.1",
"unplugin-vue-router": "^0.12.0",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-vue-devtools": "^7.7.2",
"vite-plugin-vue-layouts": "^0.11.0",
"vue-tsc": "^3.0.0-alpha.2"
}
}
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = [ "you" ]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = [
"staticlib",
"cdylib",
"rlib"
]
[build-dependencies]
tauri-build = { version = "2.0.5", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = [ "derive" ] }
log = "0.4"
tauri = { version = "2.3.1", features = [
'macos-private-api',
'tray-icon',
'image-png'
] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-os = "2"
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,14 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"os:default",
"core:window:default",
"core:window:allow-start-dragging"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

@@ -0,0 +1,16 @@
use tauri::{WebviewUrl, WebviewWindowBuilder, Manager};
use std::path::Path;
#[tauri::command]
pub fn open_settings_window(app: tauri::AppHandle) {
if let Some(window) = app.get_webview_window("settings") {
let _ = window.show();
return;
}
let _ = WebviewWindowBuilder::new(&app, "settings", WebviewUrl::App(Path::new("#/settings").to_path_buf()))
.title("settings")
.inner_size(600.0, 800.0)
.build()
.unwrap();
}
@@ -0,0 +1,82 @@
use tauri::{TitleBarStyle, WebviewUrl, WebviewWindowBuilder, ActivationPolicy, Manager};
use tauri::tray::{TrayIconBuilder};
use tauri::menu::{Menu, MenuItem};
use std::path::Path;
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.setup(|app| {
let _ = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("airi")
.title_bar_style(TitleBarStyle::Transparent)
.decorations(false)
.inner_size(450.0, 600.0)
.shadow(false)
.transparent(true)
.always_on_top(true)
.build()
.unwrap();
#[cfg(target_os = "macos")]
app.set_activation_policy(ActivationPolicy::Accessory); // hide dock icon
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
// TODO: i18n
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let settings_item = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?;
let hide_item = MenuItem::with_id(app, "hide", "Hide", true, None::<&str>)?;
let show_item = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&settings_item, &hide_item, &show_item, &quit_item])?;
let _ = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone()) // TODO: use custom icon
.menu(&menu)
.on_menu_event(|app, event| match event.id().as_ref() {
"quit" => {
app.exit(0);
}
"settings" => {
if let Some(window) = app.get_webview_window("settings") {
let _ = window.show();
return;
}
let _ = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App(Path::new("#/settings").to_path_buf()))
.title("settings")
.inner_size(600.0, 800.0)
.build()
.unwrap();
}
"hide" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
}
}
_ => {}
})
.show_menu_on_left_click(true)
.build(app)
.unwrap();
Ok(())
})
.invoke_handler(tauri::generate_handler![commands::open_settings_window])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
@@ -0,0 +1,29 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "airi",
"version": "0.1.0",
"identifier": "com.github.moeru-ai.airi-tamagotchi",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "pnpm run dev",
"beforeBuildCommand": "pnpm run build"
},
"app": {
"security": {
"csp": null
},
"macOSPrivateApi": true
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
import { useWindowControlStore } from './stores/window-controls'
const { language, themeColorsHue, themeColorsHueDynamic } = storeToRefs(useSettings())
const i18n = useI18n()
const windowControlStore = useWindowControlStore()
useEventListener(window, 'resize', () => {
windowControlStore.size.width = window.innerWidth
windowControlStore.size.height = window.innerHeight
})
watch(language, (language) => {
i18n.locale.value = language
// window.electron.ipcRenderer.send('locale-changed', language)
})
// FIXME: store settings to file
onMounted(() => {
// window.electron.ipcRenderer.send('locale-changed', language.value)
// window.electron.ipcRenderer.send('window-size-changed', windowControlStore.size.width, windowControlStore.size.height)
// window.electron.ipcRenderer.send('window-position-changed', windowControlStore.position.x, windowControlStore.position.y)
})
watch(themeColorsHue, () => {
document.documentElement.style.setProperty('--theme-colors-hue', themeColorsHue.value.toString())
}, { immediate: true })
watch(themeColorsHueDynamic, () => {
document.documentElement.classList.toggle('dynamic-hue', themeColorsHueDynamic.value)
}, { immediate: true })
</script>
<template>
<RouterView />
</template>
<style>
/* We need this to properly animate the CSS variable */
@property --theme-colors-hue {
syntax: '<number>';
initial-value: 0;
inherits: true;
}
@keyframes hue-anim {
from {
--theme-colors-hue: 0;
}
to {
--theme-colors-hue: 360;
}
}
.dynamic-hue {
animation: hue-anim 10s linear infinite;
}
</style>
@@ -0,0 +1,97 @@
<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'
import { useI18n } from 'vue-i18n'
const chatHistoryRef = ref<HTMLDivElement>()
const { t } = useI18n()
const { messages, sending } = 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 max-h="30vh" flex="~ col"
scrollbar="~ w-2 track-color-transparent thumb-color-primary-300 rounded" h-full w-full overflow-scroll
>
<div flex-1 /> <!-- spacer -->
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'error'" flex mr="12">
<div
flex="~ col" border="4 solid violet-200/50 dark:violet-500/50" shadow="md violet-200/50 dark:none"
min-w-20 rounded-lg px-2 py-1 h="unset <sm:fit" bg="<md:violet-500/25"
>
<div flex="~ row" gap-2>
<div flex-1>
<span text-xs text="violet-400/90 dark:violet-600/90" font-semibold class="inline <sm:hidden">{{
t('stage.chat.message.character-name.core-system') }}</span>
</div>
<div i-solar:danger-triangle-bold-duotone text-violet-500 />
</div>
<div v-if="sending" i-eos-icons:three-dots-loading />
<div
v-else class="markdown-content text-violet-500" text="base <sm:xs"
v-html="process(message.content as string)"
/>
</div>
</div>
<div v-if="message.role === 'assistant'" flex mr="12">
<div
flex="~ col" border="4 solid primary-200" shadow="md primary-200/50" min-w-20 rounded-lg px-2 py-1 h="fit"
bg="primary-100"
>
<div>
<span text-xs text="primary-400/90" font-semibold class="inline hidden">Airi</span>
</div>
<div
v-if="message.content" class="markdown-content" text="xs primary-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,137 @@
<script setup lang="ts">
import type { ChatProvider } from '@xsai-ext/shared-providers'
import { BasicTextarea } from '@proj-airi/stage-ui/components'
import { useMicVAD } from '@proj-airi/stage-ui/composables'
import { useChatStore, useConsciousnessStore, useProvidersStore, useSettings } from '@proj-airi/stage-ui/stores'
import { invoke } from '@tauri-apps/api/core'
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 { messages } = storeToRefs(useChatStore())
const { t } = useI18n()
const providersStore = useProvidersStore()
const { activeModel, activeProvider } = storeToRefs(useConsciousnessStore())
async function handleSend() {
if (!messageInput.value.trim()) {
return
}
try {
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
await send(messageInput.value, {
model: activeModel.value,
chatProvider: providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
providerConfig,
})
}
catch (error) {
messages.value.pop()
messages.value.push({
role: 'error',
content: (error as Error).message,
})
}
}
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() {
invoke('open_settings_window')
}
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 transform="translate-y-[-100%]" absolute left-0 top-0 w-full />
<div flex flex-1>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 primary-100"
text="primary-400 hover:primary-600 placeholder:primary-400 placeholder:hover:primary-600"
bg="primary-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 primary-100 "
text="lg primary-400 hover:primary-600 placeholder:primary-400 placeholder:hover:primary-600"
bg="primary-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,259 @@
<script setup lang="ts">
import { Collapsable } from '@proj-airi/stage-ui/components'
import { Emotion, EmotionNeutralMotionName } from '@proj-airi/stage-ui/constants'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import JSZip from 'jszip'
import localforage from 'localforage'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const modelFile = useFileDialog({
accept: 'application/zip',
})
const settings = useSettings()
const { live2dModelFile, live2dMotionMap, live2dLoadSource, loadingLive2dModel, availableLive2dMotions, live2dModelUrl } = storeToRefs(settings)
const localModelUrl = ref(live2dModelUrl.value)
modelFile.onChange((files) => {
if (files && files.length > 0) {
live2dMotionMap.value = {}
live2dModelFile.value = files[0]
live2dLoadSource.value = 'file'
loadingLive2dModel.value = true
}
})
watch(() => settings.loadingLive2dModel, (value) => {
if (value) {
return
}
if (live2dLoadSource.value !== 'file') {
return
}
availableLive2dMotions.value.forEach((motion) => {
if (motion.motionName in Emotion) {
live2dMotionMap.value[motion.fileName] = motion.motionName
}
else {
live2dMotionMap.value[motion.fileName] = EmotionNeutralMotionName
}
})
})
async function patchMotionMap(source: File, motionMap: Record<string, string>): Promise<File> {
if (!Object.keys(motionMap).length)
return source
const jsZip = new JSZip()
const zip = await jsZip.loadAsync(source)
const fileName = Object.keys(zip.files).find(key => key.endsWith('model3.json'))
if (!fileName) {
throw new Error('model3.json not found')
}
const model3Json = await zip.file(fileName)!.async('string')
const model3JsonObject = JSON.parse(model3Json)
const motions: Record<string, { File: string }[]> = {}
Object.entries(motionMap).forEach(([key, value]) => {
if (motions[value]) {
motions[value].push({ File: key })
return
}
motions[value] = [{ File: key }]
})
model3JsonObject.FileReferences.Motions = motions
zip.file(fileName, JSON.stringify(model3JsonObject, null, 2))
const zipBlob = await zip.generateAsync({ type: 'blob' })
return new File([zipBlob], source.name, {
type: source.type,
lastModified: source.lastModified,
})
}
async function saveMotionMap() {
const fileFromIndexedDB = await localforage.getItem<File>('live2dModel')
if (!fileFromIndexedDB) {
return
}
const patchedFile = await patchMotionMap(fileFromIndexedDB, live2dMotionMap.value)
live2dModelFile.value = patchedFile
live2dLoadSource.value = 'file'
loadingLive2dModel.value = true
}
const exportObjectUrl = useObjectUrl(live2dModelFile)
</script>
<template>
<div>
<Collapsable w-full :default="true">
<template #trigger="slotProps">
<button
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
w-full flex items-center gap-1.5 rounded-lg px-4 py-3 outline-none
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
@click="slotProps.setVisible(!slotProps.visible)"
>
<div flex="~ row 1" items-center gap-1.5>
<div
i-solar:magic-stick-3-bold-duotone class="provider-icon size-6"
transition="filter duration-250 ease-in-out"
/>
<div>
{{ t('settings.live2d.change-model.title') }}
</div>
</div>
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
<div i-solar:alt-arrow-down-bold-duotone />
</div>
</button>
</template>
<div p-4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-1 text-sm font-medium">
{{ t('settings.live2d.change-model.from-url') }}
</div>
</div>
<div>
<input
v-model="localModelUrl"
:disabled="settings.loadingLive2dModel"
type="text"
rounded
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
transition="border duration-250 ease-in-out"
px-2 py-1 text-sm outline-none
:placeholder="t('settings.live2d.change-model.from-url-placeholder')"
>
<button
:disabled="settings.loadingLive2dModel"
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
ml-2 rounded px-2 py-1 text-sm outline-none
@click="live2dModelUrl = localModelUrl"
>
{{ t('settings.live2d.change-model.from-url-confirm') }}
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-1 text-sm font-medium">
{{ t('settings.live2d.change-model.from-file') }}
</div>
</div>
<button
:disabled="settings.loadingLive2dModel"
rounded
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
px-2 py-1 text-sm outline-none
@click="modelFile.open()"
>
{{ t('settings.live2d.change-model.from-file-select') }}
</button>
</div>
</div>
</div>
</Collapsable>
<Collapsable mt-4 w-full :default="true">
<template #trigger="slotProps">
<button
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
w-full flex items-center gap-1.5 rounded-lg px-4 py-3 outline-none
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
@click="slotProps.setVisible(!slotProps.visible)"
>
<div flex="~ row 1" items-center gap-1.5>
<div
i-solar:face-scan-circle-bold-duotone class="provider-icon size-6"
transition="filter duration-250 ease-in-out"
/>
<div>
Edit motion map
</div>
</div>
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
<div i-solar:alt-arrow-down-bold-duotone />
</div>
</button>
</template>
<div p-4>
<div v-if="settings.live2dLoadSource === 'file'" class="space-y-4">
<div v-for="motion in settings.availableLive2dMotions" :key="motion.fileName" class="flex items-center justify-between">
<div class="flex items-center gap-1 text-sm font-medium">
{{ motion.fileName }}
</div>
<div flex gap-2>
<select v-model="settings.live2dMotionMap[motion.fileName]">
<option v-for="emotion in Object.keys(Emotion)" :key="emotion">
{{ emotion }}
</option>
</select>
<button
:disabled="settings.loadingLive2dModel"
rounded
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
px-2 py-1 text-sm outline-none
@click="settings.live2dCurrentMotion = { group: motion.motionName, index: motion.motionIndex }"
>
Play
</button>
</div>
</div>
<button
:disabled="settings.loadingLive2dModel"
w-full rounded
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
@click="saveMotionMap"
>
Save and patch
</button>
<a
mt-2 block :href="exportObjectUrl"
:download="`${settings.live2dModelFile?.name}-motion-edited.zip`"
>
<button
:disabled="settings.loadingLive2dModel"
w-full rounded
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
>
Export
</button>
</a>
</div>
<div v-else>
Not available for URL model
</div>
</div>
</Collapsable>
</div>
</template>
@@ -0,0 +1,56 @@
import type { EffectScope } from 'vue'
import { useMagicKeys, whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, effectScope, watch } from 'vue'
import { useShortcutsStore } from '../stores/shortcuts'
import { useWindowControlStore } from '../stores/window-controls'
import { WindowControlMode } from '../types/window-controls'
export function useWindowShortcuts() {
const windowStore = useWindowControlStore()
const magicKeys = useMagicKeys()
const { shortcuts } = storeToRefs(useShortcutsStore())
const handlers = computed(() => [
{
handle: () => {
windowStore.setMode(WindowControlMode.MOVE)
windowStore.toggleControl()
},
shortcut: shortcuts.value.find(shortcut => shortcut.type === 'move')?.shortcut,
},
{
handle: () => {
windowStore.setMode(WindowControlMode.RESIZE)
windowStore.toggleControl()
},
shortcut: shortcuts.value.find(shortcut => shortcut.type === 'resize')?.shortcut,
},
{
handle: () => {
windowStore.setMode(WindowControlMode.DEBUG)
windowStore.toggleControl()
},
shortcut: shortcuts.value.find(shortcut => shortcut.type === 'debug')?.shortcut,
},
])
let currentScope: EffectScope | null = null
watch(handlers, () => {
if (currentScope) {
currentScope.stop()
}
currentScope = effectScope()
currentScope.run(() => {
handlers.value.forEach((handler) => {
if (!handler.shortcut) {
return
}
whenever(magicKeys[handler.shortcut], handler.handle)
})
})
}, { immediate: true })
}
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<div px-4 py-8 flex="~ col gap-4">
<RouterView />
</div>
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
+30
View File
@@ -0,0 +1,30 @@
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import Tres from '@tresjs/core'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
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 './styles/main.css'
const pinia = createPinia()
const router = createRouter({
history: createWebHashHistory(),
routes: setupLayouts(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,135 @@
<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()
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
>
<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>
<Transition
enter-active-class="transition-opacity duration-250"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-250"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="windowStore.controlMode === WindowControlMode.MOVE"
data-tauri-drag-region
class="drag-region absolute left-0 top-0 z-999 h-full w-full flex items-center justify-center overflow-hidden"
>
<div class="absolute h-32 w-full flex items-center justify-center b-2 b-pink bg-white">
<div class="wall absolute top-0 h-8" />
<div data-tauri-drag-region class="text-primary-300 absolute left-0 top-0 h-full w-full flex animate-flash animate-duration-5s animate-count-infinite items-center justify-center text-1.5rem font-bold">
DRAG HERE TO MOVE
</div>
<div data-tauri-drag-region class="wall absolute bottom-0 h-8" />
</div>
</div>
</Transition>
<Transition
enter-active-class="transition-opacity duration-250"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-250"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="windowStore.controlMode === WindowControlMode.RESIZE"
class="absolute left-0 top-0 z-999 h-full w-full"
>
<div h-full w-full animate-flash animate-duration-2.5s animate-count-infinite b-8 b-pink rounded-2xl />
</div>
</Transition>
</template>
<style scoped>
.view {
&:hover {
.interaction-area {
opacity: 1;
pointer-events: auto;
}
}
}
.drag-region {
app-region: drag;
cursor: grab !important;
}
@keyframes wall-move {
0% {
transform: translateX(calc(var(--wall-width) * -2));
}
100% {
transform: translateX(calc(var(--wall-width) * 1));
}
}
.wall {
@apply text-primary-200;
--wall-width: 8px;
animation: wall-move 1s linear infinite;
background-image: repeating-linear-gradient(
45deg,
currentColor,
currentColor var(--wall-width),
#ff00 var(--wall-width),
#ff00 calc(var(--wall-width) * 2)
);
width: calc(100% + 4 * var(--wall-width));
}
</style>
<route lang="yaml">
meta:
layout: stage
</route>
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { IconItem } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { language, disableTransitions } = storeToRefs(useSettings())
const dark = useDark()
function handleLanguageChange(event: Event) {
const target = event.target as HTMLSelectElement
language.value = target.value
}
</script>
<template>
<div
v-motion
flex="~ row" items-center
gap-2
:initial="{ opacity: 0, x: 10 }"
:enter="{ opacity: 1, x: 0 }"
:duration="100"
>
<h1 text-3xl>
{{ t('settings.title') }}
</h1>
</div>
<div flex="~ col gap-4">
<div flex="~ col gap-4">
<IconItem
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:title="t('settings.pages.modules.title')"
:description="t('settings.pages.modules.description')"
icon="i-lucide:blocks"
to="/settings/modules"
/>
<IconItem
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="50"
:title="t('settings.pages.models.title')"
:description="t('settings.pages.models.description')"
icon="i-lucide:person-standing"
to="/settings/models"
/>
<IconItem
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="100"
:title="t('settings.pages.providers.title')"
:description="t('settings.pages.providers.description')"
icon="i-lucide:brain"
to="/settings/providers"
/>
<IconItem
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="150"
:title="t('settings.pages.themes.title')"
:description="t('settings.pages.themes.description')"
icon="i-lucide:paintbrush"
to="/settings/themes"
/>
<IconItem
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="150"
:title="t('settings.pages.shortcuts.title')"
:description="t('settings.pages.shortcuts.description')"
icon="i-lucide:keyboard"
to="/settings/shortcuts"
/>
</div>
<div
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="150"
>
<h2 text-2xl>
{{ t('settings.sections.section.general.title') }}
</h2>
</div>
<div flex="~ col gap-4">
<!-- Language Setting -->
<div
v-motion
grid="~ cols-[150px_1fr]"
bg="neutral-50 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
transition="all ease-in-out duration-250" items-center gap-1.5 rounded-lg px-4
py-3
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="200"
>
<div text="sm">
<span>{{ t('settings.language.title') }}</span>
</div>
<div flex="~ row" w-full justify-end>
<select
class="w-32"
bg="transparent"
text="sm right neutral-800 dark:neutral-100"
transition="all ease-in-out duration-250"
outline="none"
cursor-pointer
@change="handleLanguageChange"
>
<option value="en-US">
{{ t('settings.language.english') }}
</option>
<option value="zh-CN">
{{ t('settings.language.chinese') }}
</option>
</select>
</div>
</div>
<!-- Theme Setting -->
<label
v-motion
bg="neutral-50 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
transition="all ease-in-out duration-250"
w-full flex cursor-pointer rounded-lg px-4 py-3
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="250"
>
<input
v-model="dark"
text="neutral-800 dark:neutral-100"
:checked="dark"
:aria-checked="dark"
type="checkbox"
hidden appearance-none outline-none
>
<div flex="~ row" w-full items-center gap-1.5>
<div text="sm" w-full flex-1>
<span>{{ t('settings.dark-mode') }}</span>
</div>
<div select-none>
<Transition name="slide-away" mode="out-in">
<div
v-if="dark"
i-solar:moon-stars-bold-duotone
transition="all ease-in-out duration-250"
/>
<div
v-else
i-solar:sun-fog-bold-duotone
transition="all ease-in-out duration-250"
/>
</Transition>
</div>
</div>
</label>
<!-- Developer Settings -->
<label
v-motion
bg="neutral-50 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
transition="all ease-in-out duration-250"
w-full flex cursor-pointer rounded-lg px-4 py-3
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="300"
>
<input
v-model="disableTransitions"
text="neutral-800 dark:neutral-100"
:checked="disableTransitions"
:aria-checked="disableTransitions"
type="checkbox"
hidden appearance-none outline-none
>
<div flex="~ row" w-full items-center gap-1.5>
<div text="sm" w-full flex-1>
<span>{{ t('settings.stage-transitions') }}</span>
</div>
<div select-none>
<Transition name="slide-away" mode="out-in">
<div
v-if="disableTransitions"
i-solar:people-nearby-bold-duotone
transition="all ease-in-out duration-250"
/>
<div
v-else
i-solar:running-2-line-duotone
transition="all ease-in-out duration-250"
/>
</Transition>
</div>
</div>
</label>
</div>
<div text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div v-motion text="60" i-lucide:cog />
</div>
</div>
</template>
<route lang="yaml">
meta:
stageTransition:
name: slide
</route>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import Live2DCanvas from '@proj-airi/stage-ui/components/Live2D/Canvas.vue'
import Live2DModel from '@proj-airi/stage-ui/components/Live2D/Model.vue'
import { useElementBounding } from '@vueuse/core'
import { Vibrant } from 'node-vibrant/browser'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import Live2DSettings from '../../../components/Widgets/Live2DSettings.vue'
const { t } = useI18n()
const router = useRouter()
const live2dContainerRef = ref<HTMLDivElement>()
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const { width, height } = useElementBounding(live2dContainerRef)
const palette = ref<string[]>([])
async function extractColorsFromModel() {
if (!live2dCanvasRef.value)
return
const frame = await live2dCanvasRef.value.captureFrame()
if (!frame) {
console.error('No frame captured')
return
}
const frameUrl = URL.createObjectURL(frame)
const vibrant = new Vibrant(frameUrl)
const paletteFromVibrant = await vibrant.getPalette()
palette.value = Object.values(paletteFromVibrant).map(color => color?.hex).filter(it => typeof it === 'string')
URL.revokeObjectURL(frameUrl)
}
</script>
<template>
<div
v-motion
flex="~ row" items-center gap-2
:initial="{ opacity: 0, x: 10 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: -10 }"
:duration="250"
>
<button @click="router.back()">
<div i-solar:alt-arrow-left-line-duotone text-2xl />
</button>
<h1 relative>
<div absolute left-0 top-0 translate-y="[-80%]">
<span text="neutral-300 dark:neutral-500" text-nowrap>{{ t('settings.title') }}</span>
</div>
<div text-nowrap text-3xl font-semibold>
{{ t('settings.pages.models.title') }}
</div>
</h1>
</div>
<div flex>
<div ref="live2dContainerRef" w="50%" h="80vh">
<Live2DCanvas v-slot="{ app }" ref="live2dCanvasRef" :width="width" :height="height">
<Live2DModel :app="app" :mouth-open-size="0" :width="width" :height="height" :paused="false" />
</Live2DCanvas>
</div>
<Live2DSettings w="50%" h="80vh" :palette="palette" @extract-colors-from-model="extractColorsFromModel" />
</div>
<div text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:person-standing />
</div>
</template>
<route lang="yaml">
meta:
stageTransition:
name: slide
</route>
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { RadioCardDetailManySelect, RadioCardSimple } from '@proj-airi/stage-ui/components'
import { useConsciousnessStore, useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink, useRouter } from 'vue-router'
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
const { availableProviders, availableTextGenerationsProvidersMetadata } = storeToRefs(providersStore)
const {
activeProvider,
activeModel,
customModelName,
modelSearchQuery,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
} = storeToRefs(consciousnessStore)
const { t } = useI18n()
const router = useRouter()
onMounted(async () => {
await consciousnessStore.loadModelsForProvider(activeProvider.value)
})
function updateCustomModelName(value: string) {
customModelName.value = value
}
</script>
<template>
<div
v-motion
flex="~ row" items-center gap-2
:initial="{ opacity: 0, x: 10 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: -10 }"
:duration="250"
>
<button @click="router.back()">
<div i-solar:alt-arrow-left-line-duotone text-xl />
</button>
<h1 relative>
<div absolute left-0 top-0 translate-y="[-80%]">
<span text="neutral-300 dark:neutral-500" text-nowrap>{{ t('settings.pages.modules.title') }}</span>
</div>
<div text-nowrap text-3xl font-semibold>
{{ t('settings.pages.modules.consciousness.title') }}
</div>
</h1>
</div>
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4">
<div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-500">
{{ t('settings.pages.providers.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.description') }}</span>
</div>
</div>
<div max-w-full>
<!--
fieldset has min-width set to --webkit-min-container, in order to use over flow scroll,
we need to set the min-width to 0.
See also: https://stackoverflow.com/a/33737340
-->
<fieldset
v-if="availableProviders.length > 0"
flex="~ row gap-4"
:style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth
role="radiogroup"
>
<RadioCardSimple
v-for="metadata in availableTextGenerationsProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeProvider"
name="provider"
:value="metadata.id"
:title="metadata.localizedName"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4"
border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800"
transition="colors duration-200 ease-in-out"
to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your LLM
providers</span>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
</div>
</div>
</div>
</div>
<!-- Model selection section -->
<div v-if="activeProvider && supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg md:text-2xl">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingActiveProviderModels" class="flex items-center justify-center py-4">
<div class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-xl />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<!-- Error state -->
<div
v-else-if="activeProviderModelError"
class="flex items-center gap-3 border border-red-200 rounded-lg bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"
>
<div i-solar:close-circle-line-duotone class="text-2xl text-red-500 dark:text-red-400" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error') }}</span>
<span class="text-sm text-red-600 dark:text-red-400">{{ activeProviderModelError }}</span>
</div>
</div>
<!-- No models available -->
<div
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
class="flex items-center gap-3 border border-amber-200 rounded-lg bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
<div i-solar:info-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models')
}}</span>
<span class="text-sm text-amber-600 dark:text-amber-400">{{
t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}</span>
</div>
</div>
<!-- Using the new RadioCardDetailManySelect component -->
<template v-else-if="providerModels.length > 0">
<RadioCardDetailManySelect
v-model="activeModel"
v-model:search-query="modelSearchQuery"
:items="providerModels"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
<!-- Provider doesn't support model listing -->
<div v-else-if="activeProvider && !supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<div
class="bg-primary-50 border-primary-200 dark:bg-primary-900/20 dark:border-primary-800 flex items-center gap-3 border rounded-lg p-4"
>
<div i-solar:info-circle-line-duotone class="text-primary-500 dark:text-primary-400 text-2xl" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.not_supported')
}}</span>
<span class="dark:text-primary-400 text-primary-600 text-sm">{{
t('settings.pages.modules.consciousness.sections.section.provider-model-selection.not_supported_description') }}</span>
</div>
</div>
<!-- Manual model input for providers without model listing -->
<div class="mt-2">
<label class="mb-1 block text-sm font-medium">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_name') }}
</label>
<input
v-model="activeModel" type="text"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
:placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_placeholder')"
>
</div>
</div>
</div>
</div>
<div text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:ghost />
</div>
</template>
<route lang="yaml">
meta:
stageTransition:
name: slide
</route>
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const router = useRouter()
const { t } = useI18n()
interface Module {
id: string
name: string
description: string
icon?: string
iconColor?: string
iconImage?: string
to: string
configured: boolean
}
// TODO: categorize modules, such as essential, messaging, gaming, etc.
const modulesList = computed<Module[]>(() => [
{
id: 'consciousness',
name: t('settings.pages.modules.consciousness.title'),
description: t('settings.pages.modules.consciousness.description'),
icon: 'i-lucide:ghost',
to: '/settings/modules/consciousness',
configured: false,
},
{
id: 'hearing',
name: t('settings.pages.modules.hearing.title'),
description: t('settings.pages.modules.hearing.description'),
icon: 'i-lucide:ear',
to: '',
configured: false,
},
{
id: 'messaging-discord',
name: t('settings.pages.modules.messaging-discord.title'),
description: t('settings.pages.modules.messaging-discord.description'),
icon: 'i-simple-icons:discord',
to: '',
configured: false,
},
{
id: 'speech',
name: t('settings.pages.modules.speech.title'),
description: t('settings.pages.modules.speech.description'),
icon: 'i-lucide:mic',
to: '/settings/modules/speech',
configured: false,
},
{
id: 'memory-short-term',
name: t('settings.pages.modules.memory-short-term.title'),
description: t('settings.pages.modules.memory-short-term.description'),
icon: 'i-lucide:book',
to: '',
configured: false,
},
{
id: 'memory-long-term',
name: t('settings.pages.modules.memory-long-term.title'),
description: t('settings.pages.modules.memory-long-term.description'),
icon: 'i-lucide:book-copy',
to: '',
configured: false,
},
{
id: 'vision',
name: t('settings.pages.modules.vision.title'),
description: t('settings.pages.modules.vision.description'),
icon: 'i-lucide:eye',
to: '',
configured: false,
},
{
id: 'game-minecraft',
name: t('settings.pages.modules.gaming-minecraft.title'),
description: t('settings.pages.modules.gaming-minecraft.description'),
iconColor: 'i-vscode-icons:file-type-minecraft',
to: '',
configured: false,
},
{
id: 'game-factorio',
name: t('settings.pages.modules.gaming-factorio.title'),
description: t('settings.pages.modules.gaming-factorio.description'),
iconImage: '',
to: '',
configured: false,
},
{
id: 'x',
name: t('settings.pages.modules.x.title'),
description: t('settings.pages.modules.x.description'),
icon: 'i-simple-icons:x',
to: '',
configured: false,
},
])
</script>
<template>
<div
v-motion
flex="~ row" items-center gap-2
:initial="{ opacity: 0, x: 10 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: -10 }"
:duration="250"
>
<button @click="router.back()">
<div i-solar:alt-arrow-left-line-duotone text-2xl />
</button>
<h1 relative>
<div absolute left-0 top-0 translate-y="[-80%]">
<span text="neutral-300 dark:neutral-500" text-nowrap>{{ t('settings.title') }}</span>
</div>
<div text-nowrap text-3xl font-semibold>
{{ t('settings.pages.modules.title') }}
</div>
</h1>
</div>
<div grid="~ cols-1 sm:cols-2 gap-4">
<IconStatusItem
v-for="(module, index) of modulesList"
:key="module.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="index * 50"
:title="module.name"
:description="module.description"
:icon="module.icon"
:icon-color="module.iconColor"
:icon-image="module.iconImage"
:to="module.to"
:configured="module.configured"
/>
</div>
<div text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:blocks />
</div>
</template>
<route lang="yaml">
meta:
stageTransition:
name: slide
</route>
@@ -0,0 +1,532 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import {
FieldCheckbox,
FieldInput,
FieldRange,
RadioCardDetailManySelect,
RadioCardSimple,
Skeleton,
TestDummyMarker,
VoiceCardManySelect,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { generateSpeech } from '@xsai/generate-speech'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink, useRouter } from 'vue-router'
const { t } = useI18n()
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
const {
availableProviders,
availableAudioSpeechProvidersMetadata,
} = storeToRefs(providersStore)
const {
activeSpeechProvider,
activeSpeechModel,
activeSpeechVoice,
activeSpeechVoiceId,
pitch,
isLoadingSpeechProviderVoices,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
modelSearchQuery,
speechProviderError,
ssmlEnabled,
availableVoices,
} = storeToRefs(speechStore)
const router = useRouter()
const voiceSearchQuery = ref('')
const useSSML = ref(false)
const testText = ref('Hello, my name is AI Assistant')
const ssmlText = ref('')
const isGenerating = ref(false)
const audioUrl = ref('')
const audioPlayer = ref<HTMLAudioElement | null>(null)
const errorMessage = ref('')
onMounted(async () => {
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
await providersStore.loadModelsForConfiguredProviders()
})
watch(activeSpeechProvider, async () => {
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
await providersStore.loadModelsForConfiguredProviders()
})
// Function to generate speech
async function generateTestSpeech() {
if (!testText.value.trim() && !useSSML.value)
return
if (useSSML.value && !ssmlText.value.trim())
return
if (!activeSpeechModel.value) {
console.error('No model selected')
return
}
if (!activeSpeechVoice.value) {
console.error('No voice selected')
return
}
const provider = providersStore.getProviderInstance(activeSpeechProvider.value) as SpeechProviderWithExtraOptions<string, any>
if (!provider) {
console.error('Failed to initialize speech provider')
return
}
const providerConfig = providersStore.getProviderConfig(activeSpeechProvider.value)
isGenerating.value = true
errorMessage.value = ''
try {
// Stop any currently playing audio
if (audioUrl.value) {
stopTestAudio()
}
const input = useSSML.value
? ssmlText.value
: speechStore.generateSSML(testText.value, activeSpeechVoice.value, { ...providerConfig, pitch: pitch.value })
const response = await generateSpeech({
...provider.speech(activeSpeechModel.value, providerConfig),
input,
voice: activeSpeechVoice.value.id,
})
// Convert the response to a blob and create an object URL
audioUrl.value = URL.createObjectURL(new Blob([response]))
// Play the audio
setTimeout(() => {
if (audioPlayer.value) {
audioPlayer.value.play()
}
}, 100)
}
catch (error) {
console.error('Error generating speech:', error)
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
}
finally {
isGenerating.value = false
}
}
// Function to stop audio playback
function stopTestAudio() {
if (audioPlayer.value) {
audioPlayer.value.pause()
audioPlayer.value.currentTime = 0
}
// Clean up the object URL to prevent memory leaks
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
audioUrl.value = ''
}
}
// Clean up when component is unmounted
onUnmounted(() => {
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
}
})
function updateCustomVoiceName(value: string) {
activeSpeechVoice.value = {
id: value,
name: value,
description: value,
previewURL: value,
languages: [{ code: 'en', title: 'English' }],
provider: activeSpeechProvider.value,
gender: 'male',
}
}
function updateCustomModelName(value: string) {
activeSpeechModel.value = value
}
</script>
<template>
<div
v-motion flex="~ row" items-center gap-2 :initial="{ opacity: 0, x: 10 }" :enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: -10 }" :duration="250"
>
<button @click="router.back()">
<div i-solar:alt-arrow-left-line-duotone text-xl />
</button>
<h1 relative>
<div absolute left-0 top-0 translate-y="[-80%]">
<span text="neutral-300 dark:neutral-500" text-nowrap>{{ t('settings.pages.modules.title') }}</span>
</div>
<div text-nowrap text-3xl font-semibold>
{{ t('settings.pages.modules.speech.title') }}
</div>
</h1>
</div>
<div flex="~ col md:row gap-6">
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="w-full md:w-[40%]">
<div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.description') }}</span>
</div>
</div>
<div max-w-full>
<fieldset
v-if="availableProviders.length > 0" flex="~ row gap-4" :style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth role="radiogroup"
>
<RadioCardSimple
v-for="metadata in availableAudioSpeechProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
name="speech-provider"
:value="metadata.id"
:title="metadata.localizedName"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4" border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800" transition="colors duration-200 ease-in-out" to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Speech Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your speech
providers</span>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
</div>
</div>
</div>
<div>
<!-- Model selection section -->
<div v-if="activeSpeechProvider && supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg md:text-2xl">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingActiveProviderModels" class="flex items-center justify-center py-4">
<div class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-xl />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<!-- Error state -->
<div
v-else-if="activeProviderModelError"
class="flex items-center gap-3 border border-red-200 rounded-lg bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"
>
<div i-solar:close-circle-line-duotone class="text-2xl text-red-500 dark:text-red-400" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error') }}</span>
<span class="text-sm text-red-600 dark:text-red-400">{{ activeProviderModelError }}</span>
</div>
</div>
<!-- No models available -->
<div
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
class="flex items-center gap-3 border border-amber-200 rounded-lg bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
<div i-solar:info-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models')
}}</span>
<span class="text-sm text-amber-600 dark:text-amber-400">{{
t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}</span>
</div>
</div>
<!-- Using the new RadioCardDetailManySelect component -->
<template v-else-if="providerModels.length > 0">
<RadioCardDetailManySelect
v-model="activeSpeechModel"
v-model:search-query="modelSearchQuery"
:items="providerModels"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
</div>
</div>
<!-- Voice Configuration Section -->
<div v-if="activeSpeechProvider">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
Voice Configuration
</h2>
<div text="neutral-400 dark:neutral-500">
<span>Customize how your AI assistant speaks</span>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoadingSpeechProviderVoices">
<div class="flex flex-col gap-4">
<Skeleton class="w-full rounded-lg p-2.5 text-sm">
<div class="h-1lh" />
</Skeleton>
<div flex="~ row gap-4">
<Skeleton class="w-full rounded-lg p-4 text-sm">
<div class="h-1lh" />
</Skeleton>
<Skeleton class="w-full rounded-lg p-4 text-sm">
<div class="h-1lh" />
</Skeleton>
<Skeleton class="w-full rounded-lg p-4 text-sm">
<div class="h-1lh" />
</Skeleton>
</div>
<Skeleton class="w-full rounded-lg p-3 text-sm">
<div class="h-1lh" />
</Skeleton>
</div>
</div>
<!-- Error state -->
<!-- Voice selection with RadioCardDetailManySelect -->
<div
v-else-if="availableVoices[activeSpeechProvider] && availableVoices[activeSpeechProvider].length > 0"
class="space-y-6"
>
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
v-model:voice-id="activeSpeechVoiceId"
:voices="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.id,
name: voice.name,
description: voice.description,
previewURL: voice.previewURL,
customizable: false,
}))"
:searchable="true"
:search-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_placeholder')"
:search-no-results-title="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices')"
:search-no-results-description="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices_description')"
:search-results-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_results', { count: 0, total: 0 })"
:custom-input-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.custom_voice_placeholder')"
:expand-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.show_more')"
:collapse-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.show_less')"
:play-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.play_sample')"
:pause-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.pause')"
@update:custom-value="updateCustomVoiceName"
/>
</div>
<div
v-else-if="speechProviderError"
class="flex items-center gap-3 border border-2 border-red-200 rounded-lg bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"
>
<div i-solar:close-circle-line-duotone class="text-2xl text-red-500 dark:text-red-400" />
<div class="flex flex-col">
<span class="font-medium">Error loading voices</span>
<span class="text-sm text-red-600 dark:text-red-400">{{ speechProviderError }}</span>
</div>
</div>
<!-- No voices available -->
<div
v-else
class="flex items-center gap-3 border border-2 border-amber-200 rounded-lg bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
<div i-solar:info-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No voices available</span>
<span class="text-sm text-amber-600 dark:text-amber-400">
No voices were found for this provider. You can enter a custom voice name below.
</span>
</div>
</div>
<!-- Voice parameters -->
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch"
description="Tune the pitch of the voice"
:min="-100" :max="100" :step="1"
:format-value="value => `${value}%`"
/>
<!-- SSML Support -->
<FieldCheckbox
v-model="ssmlEnabled"
label="Enable SSML"
description="Enable Speech Synthesis Markup Language for more control over speech output"
/>
</div>
<!-- Manual voice input when no voices are available -->
<div
v-if="!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0"
class="mt-2 space-y-6"
>
<FieldInput
v-model="activeSpeechVoiceId"
type="text"
label="Voice ID"
description="Enter the voice ID for your custom voice"
placeholder="Enter voice name (e.g., 'Rachel', 'Josh')"
/>
<!-- Model selection for ElevenLabs -->
<div v-if="activeSpeechProvider === 'elevenlabs'">
<label class="mb-1 block text-sm font-medium">
Model
</label>
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="eleven_monolingual_v1">
Monolingual v1
</option>
<option value="eleven_multilingual_v1">
Multilingual v1
</option>
<option value="eleven_multilingual_v2">
Multilingual v2
</option>
</select>
</div>
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch"
description="Tune the pitch of the voice"
:min="-100" :max="100" :step="1"
:format-value="value => `${value}%`"
/>
</div>
</div>
</div>
</div>
</div>
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
<div class="inline-flex items-center gap-4">
<TestDummyMarker />
<div>
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
</div>
</div>
</h2>
<div flex="~ col gap-4">
<FieldCheckbox
v-model="useSSML"
label="Use Custom SSML"
description="Enable to input raw SSML instead of plain text"
/>
<template v-if="!useSSML">
<textarea
v-model="testText"
:placeholder="t('settings.pages.providers.provider.elevenlabs.playground.fields.field.input.placeholder')"
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-24 w-full rounded-lg px-3 py-2 text-sm outline-none
/>
</template>
<template v-else>
<textarea
v-model="ssmlText"
placeholder="Enter SSML text..."
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-48 w-full rounded-lg px-3 py-2 text-sm font-mono outline-none
/>
</template>
<div flex="~ row" gap-4>
<button
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !activeSpeechVoice"
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !activeSpeechVoice }"
bg="neutral-700 dark:neutral-300" @click="generateTestSpeech"
>
<div flex="~ row" items-center gap-2>
<div i-solar:play-circle-bold-duotone />
<span>{{ isGenerating ? t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.generating') : t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.label') }}</span>
</div>
</button>
<button
v-if="audioUrl" border="primary-300 dark:primary-800 solid 2"
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="stopTestAudio"
>
<div flex="~ row" items-center gap-2>
<div i-solar:stop-circle-bold-duotone />
<span>Stop</span>
</div>
</button>
</div>
<audio v-if="audioUrl" ref="audioPlayer" :src="audioUrl" controls class="mt-2 w-full" />
</div>
</div>
</div>
</div>
<div
text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10
translate-y-10
>
<div text="40" i-lucide:volume-2 />
</div>
</template>
<route lang="yaml">
meta:
stageTransition:
name: slide
</route>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAccountIdInput,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'cloudflare-workers-ai'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const accountId = computed({
get: () => providers.value[providerId]?.accountId || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].accountId = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
accountId.value = providers.value[providerId]?.accountId || ''
})
// Watch settings and update the provider configuration
watch([apiKey, accountId], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
accountId: accountId.value,
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
:placeholder="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.api-key.placeholder')"
/>
<ProviderAccountIdInput
v-model="accountId"
:label="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.label')"
:description="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.description')"
:placeholder="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.placeholder')"
/>
</ProviderBasicSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'deepseek'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.deepseek.com/v1/"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { UnElevenLabsOptions } from '@xsai-ext/providers-local'
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import {
SpeechPlayground,
SpeechProviderSettings,
SpeechVoiceSettings,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'elevenlabs'
const defaultModel = 'eleven_multilingual_v2'
// Default voice settings specific to ElevenLabs
const defaultVoiceSettings = {
similarityBoost: 0.75,
stability: 0.5,
speed: 1.0,
style: 0,
useSpeakerBoost: true,
}
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Get available voices for ElevenLabs
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Get available languages
const availableLanguages = computed(() => {
return speechStore.availableLanguages
})
// Generate speech with ElevenLabs-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
return await speechStore.speech(
provider,
model,
input,
voiceId,
{
...providerConfig,
...defaultVoiceSettings,
},
)
}
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<!-- Voice settings specific to ElevenLabs -->
<template #voice-settings="{ voiceSettings, updateVoiceSettings }">
<SpeechVoiceSettings
:settings="voiceSettings"
:show-similarity-boost="true"
:show-stability="true"
:show-speed="true"
:show-style="true"
:show-speaker-boost="true"
@update="updateVoiceSettings"
/>
</template>
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:available-languages="availableLanguages"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the ElevenLabs voice synthesis."
/>
</template>
</SpeechProviderSettings>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'fireworks-ai'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="fw-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.fireworks.ai/inference/v1/"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { allProvidersMetadata } = storeToRefs(providersStore)
</script>
<template>
<div
v-motion
flex="~ row" items-center gap-2
:initial="{ opacity: 0, x: 10 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: -10 }"
:duration="250"
>
<button @click="router.back()">
<div i-solar:alt-arrow-left-line-duotone text-2xl />
</button>
<h1 relative>
<div absolute left-0 top-0 translate-y="[-80%]">
<span text="neutral-300 dark:neutral-500" text-nowrap>{{ t('settings.title') }}</span>
</div>
<div text-nowrap text-3xl font-semibold>
{{ t('settings.pages.providers.title') }}
</div>
</h1>
</div>
<div grid="~ cols-2 gap-4">
<IconStatusItem
v-for="(provider, index) of allProvidersMetadata"
:key="provider.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="index * 50"
:title="provider.localizedName"
:description="provider.localizedDescription"
:icon="provider.icon"
:icon-color="provider.iconColor"
:icon-image="provider.iconImage"
:to="`/settings/providers/${provider.id}`"
:configured="provider.configured"
/>
</div>
<div text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:brain />
</div>
</template>
<route lang="yaml">
meta:
stageTransition:
name: slide
</route>
@@ -0,0 +1,153 @@
<script setup lang="ts">
import type { UnMicrosoftOptions } from '@xsai-ext/providers-local'
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import {
FieldInput,
SpeechPlayground,
SpeechProviderSettings,
SpeechVoiceSettings,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const providerId = 'microsoft-speech'
const defaultModel = 'v1'
// Default voice settings specific to Microsoft Speech
const defaultVoiceSettings = {
pitch: 0,
speed: 1.0,
volume: 0,
}
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const pitch = ref(0)
const speed = ref(1.0)
const volume = ref(0)
// Additional settings specific to Microsoft Speech (region)
const region = computed({
get: () => providers.value[providerId]?.region as string | undefined || 'eastasia',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].region = value
},
})
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Get available voices for Microsoft Speech
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
// Get available languages
const availableLanguages = computed(() => {
return speechStore.availableLanguages
})
// Generate speech with Microsoft-specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, useSSML: boolean) {
const provider = providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnMicrosoftOptions>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get provider configuration
const providerConfig = providersStore.getProviderConfig(providerId)
// Get model from configuration or use default
const model = providerConfig.model as string | undefined || defaultModel
// For Microsoft Speech, we need to ensure we're using the right region
const options = {
...providerConfig,
region: region.value,
disableSsml: !useSSML, // If useSSML is true, we don't disable SSML
}
// If not using SSML and we have a voice, generate SSML
if (!useSSML && voiceId) {
const voice = availableVoices.value.find(v => v.id === voiceId)
if (voice) {
const ssml = speechStore.generateSSML(
input,
voice,
{ ...providerConfig, pitch: pitch.value },
)
return await speechStore.speech(
provider,
model,
ssml,
voiceId,
options,
)
}
}
// Either using direct SSML or no voice found
return await speechStore.speech(
provider,
model,
input,
voiceId,
options,
)
}
</script>
<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
:additional-settings="defaultVoiceSettings"
>
<!-- Basic settings specific to Microsoft Speech -->
<template #basic-settings>
<FieldInput
v-model="region"
:label="t('settings.pages.providers.provider.microsoft-speech.fields.field.region.label')"
:description="t('settings.pages.providers.provider.microsoft-speech.fields.field.region.description')"
placeholder="eastasia"
required
type="text"
/>
</template>
<!-- Voice settings specific to Microsoft Speech -->
<template #voice-settings="{ voiceSettings, updateVoiceSettings }">
<SpeechVoiceSettings
v-model:pitch="pitch"
v-model:speed="speed"
v-model:volume="volume"
:settings="voiceSettings"
:show-pitch="true"
:show-speed="true"
:show-volume="true"
@update="updateVoiceSettings"
/>
</template>
<!-- Replace the default playground with our standalone component -->
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:available-languages="availableLanguages"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the Microsoft Speech synthesis."
/>
</template>
</SpeechProviderSettings>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'mistral-ai'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="mis-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.mistral.ai/v1/"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'moonshot-ai'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="ms-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.moonshot.cn/v1/"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'novita-ai'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="nvt-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.novita.ai/v1/"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
FieldKeyValues,
ProviderAdvancedSettings,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'ollama'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
providersStore.initializeProvider(providerId)
// Initialize refs with current values
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.baseUrl || ''
// Initialize headers if not already set
if (!providers.value[providerId]?.headers) {
providers.value[providerId].headers = {}
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
const headers = reactive<{ key: string, value: string }[]>([
{ key: '', value: '' },
])
function addKeyValue(headers: { key: string, value: string }[], key: string, value: string) {
if (!headers)
return
headers.push({ key, value })
}
function removeKeyValue(index: number, headers: { key: string, value: string }[]) {
if (!headers)
return
if (headers.length === 1) {
headers[0].key = ''
headers[0].value = ''
}
else {
headers.splice(index, 1)
}
}
watch(headers, (headers) => {
if (headers.length > 0 && (headers[headers.length - 1].key !== '' || headers[headers.length - 1].value !== '')) {
headers.push({ key: '', value: '' })
}
}, {
deep: true,
immediate: true,
})
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderBaseUrlInput
v-model="baseUrl"
:placeholder="providerMetadata?.defaultOptions?.baseUrl as string || ''"
required
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<FieldKeyValues
v-model="headers"
:label="t('settings.pages.providers.common.section.advanced.fields.field.headers.label')"
:description="t('settings.pages.providers.common.section.advanced.fields.field.headers.description')"
:key-placeholder="t('settings.pages.providers.common.section.advanced.fields.field.headers.key.placeholder')"
:value-placeholder="t('settings.pages.providers.common.section.advanced.fields.field.headers.value.placeholder')"
@add="(key, value) => addKeyValue(headers, key, value)"
@remove="(index) => removeKeyValue(index, headers)"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'openai'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || 'https://api.openai.com/v1/',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
if (!providers.value[providerId]) {
providers.value[providerId] = {
baseUrl: 'https://api.openai.com/v1/',
}
}
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || 'https://api.openai.com/v1/'
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || 'https://api.openai.com/v1/',
}
})
function handleResetSettings() {
providers.value[providerId] = {
baseUrl: 'https://api.openai.com/v1/',
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName || 'OpenAI'"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName || 'OpenAI'"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.openai.com/v1/"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'openrouter-ai'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.baseUrl || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || providerMetadata.value?.defaultOptions?.baseUrl || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-or-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
:placeholder="providerMetadata?.defaultOptions?.baseUrl as string || ''"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'together-ai'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="togetherapi-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.together.xyz/v1/"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Get provider metadata
const providerId = 'xai'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
onMounted(() => {
// Initialize provider if it doesn't exist
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey || ''
baseUrl.value = providers.value[providerId]?.baseUrl || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="xai-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.x.ai/v1/"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { Collapsable } from '@proj-airi/stage-ui/components'
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useShortcutsStore } from '../../../stores/shortcuts'
const { t } = useI18n()
const router = useRouter()
const shortcutsStore = useShortcutsStore()
const { shortcuts } = storeToRefs(shortcutsStore)
const recordingFor = ref<string | null>(null)
const recordingKeys = ref<{
modifier: string[]
key: string
}>({
modifier: [],
key: '',
})
function startRecording(shortcutType: string) {
recordingFor.value = shortcutType
}
function isModifierKey(key: string) {
return ['Shift', 'Control', 'Alt', 'Meta'].includes(key)
}
// Handle key combinations
useEventListener('keydown', (e) => {
if (!recordingFor.value)
return
e.preventDefault()
if (e.key === 'Escape') {
recordingFor.value = null
return
}
if (isModifierKey(e.key)) {
if (recordingKeys.value.modifier.includes(e.key))
return
recordingKeys.value.modifier.push(e.key)
return
}
if (recordingKeys.value.modifier.length === 0)
return
recordingKeys.value.key = e.key.toUpperCase()
const shortcut = shortcuts.value.find(s => s.type === recordingFor.value)
if (shortcut)
shortcut.shortcut = `${recordingKeys.value.modifier.join('+')}+${recordingKeys.value.key}`
recordingKeys.value = {
modifier: [],
key: '',
}
recordingFor.value = null
}, { passive: false })
const pressKeysMessage = computed(() => {
const i18nMessage = t('settings.pages.shortcuts.sections.section.window-controls.press-keys-hint.label')
if (recordingKeys.value.modifier.length === 0)
return i18nMessage
return `${i18nMessage}: ${recordingKeys.value.modifier.join(' + ')} + ${recordingKeys.value.key}`
})
function isConflict(shortcut: typeof shortcuts.value[0]) {
return shortcuts.value.some(s => s.type !== shortcut.type && s.shortcut === shortcut.shortcut)
}
function toReadable(shortcut: string) {
return shortcut.replaceAll('Meta', '⌘').replaceAll('Control', 'Ctrl').replaceAll('Alt', '⌥').replaceAll('Shift', '⇧').replaceAll('+', ' + ')
}
</script>
<template>
<div
v-motion
flex="~ row" items-center gap-2
:initial="{ opacity: 0, x: 10 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: -10 }"
:duration="250"
>
<button @click="router.back()">
<div i-solar:alt-arrow-left-line-duotone text-2xl />
</button>
<h1 relative>
<div absolute left-0 top-0 translate-y="[-80%]">
<span text="neutral-300 dark:neutral-500" text-nowrap>{{ t('settings.title') }}</span>
</div>
<div text-nowrap text-3xl font-semibold>
{{ t('settings.pages.shortcuts.title') }}
</div>
</h1>
</div>
<div flex-col>
<Collapsable mt-4 w-full :default="true">
<template #trigger="slotProps">
<button
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
w-full flex items-center gap-1.5 rounded-lg px-4 py-3 outline-none
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
@click="slotProps.setVisible(!slotProps.visible)"
>
<div flex="~ row 1" items-center gap-1.5>
<div
i-solar:window-frame-bold-duotone class="provider-icon size-6"
transition="filter duration-250 ease-in-out"
/>
<div>
{{ t('settings.pages.shortcuts.sections.section.window-controls.title') }}
</div>
</div>
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
<div i-solar:alt-arrow-down-bold-duotone />
</div>
</button>
</template>
<div p-4 flex="~ col gap-4">
<div
v-for="shortcut in shortcuts"
:key="shortcut.name"
flex="~ row"
bg="neutral-100 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
transition="all ease-in-out duration-250" cursor-pointer
items-center justify-between gap-4 rounded-lg px-4 py-3
>
<div text-base font-medium>
{{ t(shortcut.name) }}
</div>
<div class="shortcut-item" flex="~ row" gap-2 text-sm text="neutral-500 dark:neutral-400">
<div v-if="recordingFor === shortcut.type" class="animate-flash animate-duration-2s animate-count-infinite">
{{ toReadable(pressKeysMessage) }}
</div>
<div v-else flex items-center gap-2 @click="startRecording(shortcut.type)">
<div>{{ toReadable(shortcut.shortcut) }}</div>
<div v-if="isConflict(shortcut)" i-solar:danger-circle-bold-duotone text-red-500 />
</div>
</div>
</div>
</div>
</Collapsable>
</div>
<div text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:keyboard />
</div>
</template>
@@ -0,0 +1,423 @@
<script setup lang="ts">
import { Collapsable } from '@proj-airi/stage-ui/components'
import { DEFAULT_THEME_COLORS_HUE, useSettings } from '@proj-airi/stage-ui/stores'
import { converter } from 'culori'
import { TooltipArrow, TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger } from 'radix-vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const settings = useSettings()
interface ColorPreset {
name: string
description: string
colors: Array<{
hex: string
name: string
}>
}
const colorPresets = computed<ColorPreset[]>(() => [
{
name: t('settings.pages.themes.sections.section.theme-presets.preset.morandi.title'),
description: t('settings.pages.themes.sections.section.theme-presets.preset.morandi.description'),
colors: [
{ hex: '#A5978B', name: 'Taupe' },
{ hex: '#D8CAAF', name: 'Beige' },
{ hex: '#B8B4A7', name: 'Ash Grey' },
{ hex: '#C4BCB1', name: 'Light Taupe' },
{ hex: '#E5DED8', name: 'Ivory' },
{ hex: '#9A8F7D', name: 'Olive Grey' },
{ hex: '#BEB5A7', name: 'Sand' },
{ hex: '#C9C0B6', name: 'Warm Grey' },
],
},
{
name: t('settings.pages.themes.sections.section.theme-presets.preset.monet.title'),
description: t('settings.pages.themes.sections.section.theme-presets.preset.monet.description'),
colors: [
{ hex: '#7A9EAF', name: 'Sky Blue' },
{ hex: '#B8C7CC', name: 'Mist' },
{ hex: '#D4B79C', name: 'Sand' },
{ hex: '#8B9D77', name: 'Moss Green' },
{ hex: '#C7D5CB', name: 'Water Lily' },
{ hex: '#E6D0B1', name: 'Wheat' },
{ hex: '#94A7B1', name: 'Slate Blue' },
{ hex: '#B4C8C3', name: 'Sage' },
],
},
{
name: t('settings.pages.themes.sections.section.theme-presets.preset.japanese.title'),
description: t('settings.pages.themes.sections.section.theme-presets.preset.japanese.description'),
colors: [
{ hex: '#D9B48F', name: 'Tan' },
{ hex: '#B5917A', name: 'Warm Taupe' },
{ hex: '#8C7A6B', name: 'Umber' },
{ hex: '#A17F5F', name: 'Coffee' },
{ hex: '#B98C46', name: 'Bronze' },
{ hex: '#C7A252', name: 'Gold' },
{ hex: '#DAB300', name: 'Mustard' },
{ hex: '#D19826', name: 'Amber' },
],
},
{
name: t('settings.pages.themes.sections.section.theme-presets.preset.nordic.title'),
description: t('settings.pages.themes.sections.section.theme-presets.preset.nordic.description'),
colors: [
{ hex: '#9BA7B0', name: 'Nordic Blue' },
{ hex: '#C1CBD4', name: 'Ice' },
{ hex: '#A5ADB6', name: 'Fjord' },
{ hex: '#8B959E', name: 'Steel' },
{ hex: '#D4DCE4', name: 'Glacier' },
{ hex: '#7F8A94', name: 'Slate' },
{ hex: '#B3BCC6', name: 'Cloud' },
{ hex: '#98A4AE', name: 'Stone' },
],
},
{
name: t('settings.pages.themes.sections.section.theme-presets.preset.chinese.title'),
description: t('settings.pages.themes.sections.section.theme-presets.preset.chinese.description'),
colors: [
{ hex: '#E4C6D0', name: '霞光红 (Rosy Dawn)' },
{ hex: '#A61B29', name: '枣红 (Chinese Red)' },
{ hex: '#5D513C', name: '黄栌 (Smoky Brown)' },
{ hex: '#789262', name: '竹青 (Bamboo Green)' },
{ hex: '#1C0D1A', name: '乌梅紫 (Dark Purple)' },
{ hex: '#F7C242', name: '缃色 (Golden Yellow)' },
{ hex: '#62A9DD', name: '青冥 (Azure Blue)' },
{ hex: '#8C4B3C', name: '赭石 (Ochre)' },
],
},
])
function resetToDefault() {
settings.themeColorsHue = DEFAULT_THEME_COLORS_HUE
settings.themeColorsHueDynamic = false
}
function applyPrimaryColorFromHex(color: string) {
// Convert hex color to OKLCH using culori's converter
const oklch = converter('oklch')(color)
if (!oklch)
return
// Extract hue from OKLCH color
const { h } = oklch
if (!h)
return
// Update theme settings
settings.themeColorsHue = h
settings.themeColorsHueDynamic = false
}
/**
* Check if a color is currently selected based on its hue value
* @param hexColor Hex color code to check
* @returns True if the color's hue matches the current theme hue
*/
function isColorSelected(hexColor: string): boolean {
// If dynamic coloring is enabled, no preset color is manually selected
if (settings.themeColorsHueDynamic)
return false
// Convert hex color to OKLCH
const oklch = converter('oklch')(hexColor)
if (!oklch || !oklch.h)
return false
// Compare hue values with a small tolerance for floating point comparison
const hueDifference = Math.abs(oklch.h - settings.themeColorsHue)
return hueDifference < 0.01 || hueDifference > 359.99
}
</script>
<template>
<div
v-motion
flex="~ row" items-center gap-2
:initial="{ opacity: 0, x: 10 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: -10 }"
:duration="250"
>
<button @click="router.back()">
<div i-solar:alt-arrow-left-line-duotone text-2xl />
</button>
<h1 relative>
<div absolute left-0 top-0 translate-y="[-80%]">
<span text="neutral-300 dark:neutral-500" text-nowrap>{{ t('settings.title') }}</span>
</div>
<div text-nowrap text-3xl font-semibold>
{{ t('settings.pages.themes.title') }}
</div>
</h1>
</div>
<div flex-col>
<Collapsable mt-4 w-full :default="true">
<template #trigger="slotProps">
<button
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
w-full flex items-center gap-1.5 rounded-lg px-4 py-3 outline-none
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
@click="slotProps.setVisible(!slotProps.visible)"
>
<div flex="~ row 1" items-center gap-1.5>
<div
i-solar:pallete-2-bold-duotone class="provider-icon size-6"
transition="filter duration-250 ease-in-out"
/>
<div>
{{ t('settings.pages.themes.sections.section.custom-color.title') }}
</div>
</div>
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
<div i-solar:alt-arrow-down-bold-duotone />
</div>
</button>
</template>
<div p-4>
<div class="mb-2 text-sm font-medium">
{{ t('settings.pages.themes.sections.section.custom-color.fields.field.primary-color.label') }}
</div>
<div flex="~ col gap-4">
<input
v-model="settings.themeColorsHue"
type="range"
min="0"
max="360"
step="0.01"
class="theme-hue-slider h-10 w-full"
:disabled="settings.themeColorsHueDynamic"
:class="{ 'opacity-25 cursor-not-allowed': settings.themeColorsHueDynamic }"
>
<div h-10 w-full flex overflow-hidden rounded-lg>
<div bg="primary-50" class="primary-color-bar" text-black>
50
</div>
<div bg="primary-100" class="primary-color-bar" text-black>
100
</div>
<div bg="primary-200" class="primary-color-bar" text-black>
200
</div>
<div bg="primary-300" class="primary-color-bar" text-black>
300
</div>
<div bg="primary-400" class="primary-color-bar" text-black>
400
</div>
<div bg="primary-500" class="primary-color-bar" text-black>
500
</div>
<div bg="primary-600" class="primary-color-bar" text-white>
600
</div>
<div bg="primary-700" class="primary-color-bar" text-white>
700
</div>
<div bg="primary-800" class="primary-color-bar" text-white>
800
</div>
<div bg="primary-900" class="primary-color-bar" text-white>
900
</div>
<div bg="primary-950" class="primary-color-bar" text-white>
950
</div>
</div>
<div h-10 w-full flex overflow-hidden rounded-lg class="transparency-grid">
<div bg="primary-500/5" class="primary-color-bar" text-black>
500/5
</div>
<div bg="primary-500/10" class="primary-color-bar" text-black>
500/10
</div>
<div bg="primary-500/20" class="primary-color-bar" text-black>
500/20
</div>
<div bg="primary-500/30" class="primary-color-bar" text-black>
500/30
</div>
<div bg="primary-500/40" class="primary-color-bar" text-black>
500/40
</div>
<div bg="primary-500/50" class="primary-color-bar" text-black>
500/50
</div>
<div bg="primary-500/60" class="primary-color-bar" text-black>
500/60
</div>
<div bg="primary-500/70" class="primary-color-bar" text-black>
500/70
</div>
<div bg="primary-500/80" class="primary-color-bar" text-black>
500/80
</div>
<div bg="primary-500/90" class="primary-color-bar" text-black>
500/90
</div>
<div bg="primary-500" class="primary-color-bar" text-black>
500
</div>
</div>
</div>
<div mt-4 class="flex items-center justify-end gap-4">
<label class="relative inline-flex cursor-pointer items-center">
<input
v-model="settings.themeColorsHueDynamic"
type="checkbox"
class="peer sr-only"
>
<div
class="peer-checked:bg-primary-500 h-6 w-11 rounded-full bg-neutral-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white dark:bg-neutral-600 after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
/>
<span class="ml-2 text-sm font-medium">{{ t('settings.pages.themes.sections.section.custom-color.fields.field.primary-color.rgb-on.title') }}</span>
</label>
<button
class="rounded-md bg-neutral-100 px-3 py-1.5 text-sm transition-colors dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700"
@click="resetToDefault"
>
{{ t('settings.pages.themes.sections.section.custom-color.fields.field.primary-color.reset.label') }}
</button>
</div>
</div>
</Collapsable>
<Collapsable mt-4 w-full :default="true">
<template #trigger="slotProps">
<button
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
w-full flex items-center gap-1.5 rounded-lg px-4 py-3 outline-none
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
@click="slotProps.setVisible(!slotProps.visible)"
>
<div flex="~ row 1" items-center gap-1.5>
<div
i-solar:magic-stick-2-bold-duotone class="provider-icon size-6"
transition="filter duration-250 ease-in-out"
/>
<div>
{{ t('settings.pages.themes.sections.section.theme-presets.title') }}
</div>
</div>
<div transform transition="transform duration-250" :class="{ 'rotate-180': slotProps.visible }">
<div i-solar:alt-arrow-down-bold-duotone />
</div>
</button>
</template>
<div p-4 flex="~ col gap-4">
<div
v-for="preset in colorPresets"
:key="preset.name"
flex="~ row"
bg="neutral-100 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
transition="all ease-in-out duration-250"
cursor-pointer items-center justify-between gap-4 rounded-lg px-4 py-3
>
<div>
<div text-base font-medium>
{{ preset.name }}
</div>
<div text="sm neutral-500">
{{ preset.description }}
</div>
</div>
<div flex="~ row" gap-2>
<TooltipProvider v-for="color in preset.colors" :key="color.hex">
<TooltipRoot>
<TooltipTrigger>
<div
:style="{ backgroundColor: color.hex }"
class="size-6 cursor-pointer rounded-full transition-all duration-250 ease-in-out" :class="[
isColorSelected(color.hex)
? 'scale-150 z-10 mx-1'
: 'hover:scale-110',
]"
@click="applyPrimaryColorFromHex(color.hex)"
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent class="rounded-lg bg-white px-3 py-1.5 text-sm shadow-md dark:bg-neutral-800">
{{ color.name }}
<TooltipArrow class="fill-white dark:fill-neutral-800" />
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
</div>
</div>
</div>
</Collapsable>
</div>
<div text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:paintbrush />
</div>
</template>
<style>
.primary-color-bar {
--at-apply: w-full h-full flex-1 flex items-center justify-center;
}
.theme-hue-slider {
--at-apply: rounded-lg appearance-none;
background: linear-gradient(
to right,
oklch(85% 0.2 0),
oklch(85% 0.2 60),
oklch(85% 0.2 120),
oklch(85% 0.2 180),
oklch(85% 0.2 240),
oklch(85% 0.2 300),
oklch(85% 0.2 360)
);
}
.theme-hue-slider::-webkit-slider-thumb {
--at-apply: w-2 h-12 appearance-none rounded-md bg-neutral-500/80 dark: bg-neutral-400/80 cursor-pointer shadow-md
border-2 border-white hover: bg-neutral-500 dark: hover: bg-neutral-400 transition-colors duration-200;
}
.theme-hue-slider::-moz-range-thumb {
--at-apply: w-2 h-12 bg-neutral-500/80 rounded-md dark: bg-neutral-400/80 cursor-pointer shadow-md border-2
border-white border-box hover: bg-neutral-500 dark: hover: bg-neutral-400 transition-colors duration-200;
}
.transparency-grid {
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 20px 20px;
background-position:
0 0,
0 10px,
10px -10px,
-10px 0px;
background-color: #fff;
}
</style>
<route lang="yaml">
meta:
stageTransition:
name: slide
</route>
+6
View File
@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}
@@ -0,0 +1,30 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useShortcutsStore = defineStore('shortcuts', () => {
const shortcuts = ref([
{
name: 'settings.pages.shortcuts.sections.section.window-controls.fields.field.toggle-move.label',
shortcut: useLocalStorage('shortcuts/window/move', 'Ctrl+M'),
group: 'window',
type: 'move',
},
{
name: 'settings.pages.shortcuts.sections.section.window-controls.fields.field.toggle-resize.label',
shortcut: useLocalStorage('shortcuts/window/resize', 'Ctrl+R'),
group: 'window',
type: 'resize',
},
{
name: 'settings.pages.shortcuts.sections.section.window-controls.fields.field.toggle-ignore-mouse-event.label',
shortcut: useLocalStorage('shortcuts/window/debug', 'Ctrl+I'),
group: 'window',
type: 'debug',
},
])
return {
shortcuts,
}
})
@@ -0,0 +1,43 @@
import { useLocalStorage } from '@vueuse/core'
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)
const size = useLocalStorage('window/control/size', { width: 300 * 1.5, height: 400 * 1.5 })
const position = useLocalStorage('window/control/position', { x: 0, y: 0 })
function setMode(mode: WindowControlMode) {
controlMode.value = mode
if (mode === WindowControlMode.RESIZE) {
// window.electron.ipcRenderer.send('start-resize-window')
// return
}
// window.electron.ipcRenderer.invoke('stop-move-window').then((p: [number, number]) => {
// position.value = { x: p[0], y: p[1] }
// })
// window.electron.ipcRenderer.send('stop-resize-window')
// TODO: save position and size
}
function toggleControl() {
isControlActive.value = !isControlActive.value
if (!isControlActive.value) {
controlMode.value = WindowControlMode.DEFAULT
}
}
return {
controlMode,
isControlActive,
size,
position,
setMode,
toggleControl,
}
})
@@ -0,0 +1,38 @@
@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;
}
html.dark {
color-scheme: dark;
}
.dark #app {
background: transparent !important;
}
#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,26 @@
: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;
--theme-colors-hue: 354.31;
--theme-colors-chroma: calc(0.18 + (cos(var(--theme-colors-hue) * 3.14159265 / 180) * 0.04));
--theme-colors-chroma-50: calc(var(--theme-colors-chroma) * 0.3);
--theme-colors-chroma-100: calc(var(--theme-colors-chroma) * 0.5);
--theme-colors-chroma-200: calc(var(--theme-colors-chroma) * 0.6);
--theme-colors-chroma-300: calc(var(--theme-colors-chroma) * 0.75);
--theme-colors-chroma-400: var(--theme-colors-chroma);
--theme-colors-chroma-600: calc(var(--theme-colors-chroma) * 1.15);
--theme-colors-chroma-700: calc(var(--theme-colors-chroma) * 1.1);
--theme-colors-chroma-800: calc(var(--theme-colors-chroma) * 0.85);
--theme-colors-chroma-900: calc(var(--theme-colors-chroma) * 0.7);
--theme-colors-chroma-950: calc(var(--theme-colors-chroma) * 0.5);
}
@@ -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',
}
+14
View File
@@ -0,0 +1,14 @@
declare module '*.js?url' {
const content: string
export default content
}
declare module '*.wasm?url' {
const content: string
export default content
}
declare module '*.cjs?url' {
const content: string
export default content
}
+36
View File
@@ -0,0 +1,36 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "preserve",
"lib": [
"DOM",
"ESNext"
],
"paths": {
"@proj-airi/stage-ui/*": [
"../../packages/stage-ui/src/*"
],
"@proj-airi/ui-transitions": [
"../../packages/ui-transitions/src/index.ts"
]
},
"resolveJsonModule": true,
"types": [
"vitest",
"vite/client",
"vite-plugin-vue-layouts/client",
"vite-plugin-pwa/client",
"unplugin-vue-macros/macros-global",
"unplugin-vue-router/client"
],
"allowJs": true,
"strict": true,
"skipLibCheck": true
},
"vueCompilerOptions": {
"plugins": [
"@vue-macros/volar/define-models",
"@vue-macros/volar/define-slots"
]
}
}
+103
View File
@@ -0,0 +1,103 @@
import { createExternalPackageIconLoader } from '@iconify/utils/lib/loader/external-pkg'
import { colorToString } from '@unocss/preset-mini/utils'
import { createLocalFontProcessor } from '@unocss/preset-web-fonts/local'
import {
defineConfig,
presetAttributify,
presetIcons,
presetTypography,
presetWebFonts,
presetWind3,
transformerDirectives,
transformerVariantGroup,
} from 'unocss'
import { presetScrollbar } from 'unocss-preset-scrollbar'
import { parseColor } from 'unocss/preset-mini'
function createColorSchemeConfig(hueOffset = 0) {
return {
DEFAULT: `oklch(62% var(--theme-colors-chroma) calc(var(--theme-colors-hue) + ${hueOffset}))`,
50: `color-mix(in srgb, oklch(95% var(--theme-colors-chroma-50) calc(var(--theme-colors-hue) + ${hueOffset})) 30%, oklch(100% 0 360))`,
100: `color-mix(in srgb, oklch(95% var(--theme-colors-chroma-100) calc(var(--theme-colors-hue) + ${hueOffset})) 80%, oklch(100% 0 360))`,
200: `oklch(90% var(--theme-colors-chroma-200) calc(var(--theme-colors-hue) + ${hueOffset}))`,
300: `oklch(85% var(--theme-colors-chroma-300) calc(var(--theme-colors-hue) + ${hueOffset}))`,
400: `oklch(74% var(--theme-colors-chroma-400) calc(var(--theme-colors-hue) + ${hueOffset}))`,
500: `oklch(62% var(--theme-colors-chroma) calc(var(--theme-colors-hue) + ${hueOffset}))`,
600: `oklch(54% var(--theme-colors-chroma-600) calc(var(--theme-colors-hue) + ${hueOffset}))`,
700: `oklch(49% var(--theme-colors-chroma-700) calc(var(--theme-colors-hue) + ${hueOffset}))`,
800: `oklch(42% var(--theme-colors-chroma-800) calc(var(--theme-colors-hue) + ${hueOffset}))`,
900: `oklch(37% var(--theme-colors-chroma-900) calc(var(--theme-colors-hue) + ${hueOffset}))`,
950: `oklch(29% var(--theme-colors-chroma-950) calc(var(--theme-colors-hue) + ${hueOffset}))`,
}
}
export default defineConfig({
presets: [
presetWind3(),
presetAttributify(),
presetTypography(),
presetWebFonts({
fonts: {
sans: 'DM Sans',
serif: 'DM Serif Display',
mono: 'DM Mono',
cute: 'Kiwi Maru',
cuteen: 'Sniglet',
},
// Web fonts preset
// https://unocss.dev/presets/web-fonts#serve-fonts-locally
processors: createLocalFontProcessor(),
}),
presetIcons({
scale: 1.2,
collections: {
...createExternalPackageIconLoader('@proj-airi/lobe-icons'),
},
}),
presetScrollbar(),
],
transformers: [
transformerDirectives({
applyVariable: ['--at-apply'],
}),
transformerVariantGroup(),
],
safelist: 'prose prose-sm m-auto text-left'.split(' '),
// hyoban/unocss-preset-shadcn: Use shadcn ui with UnoCSS
// https://github.com/hyoban/unocss-preset-shadcn
//
// Thanks to
// https://github.com/unovue/shadcn-vue/issues/34#issuecomment-2467318118
// https://github.com/hyoban-template/shadcn-vue-unocss-starter
//
// By default, `.ts` and `.js` files are NOT extracted.
// If you want to extract them, use the following configuration.
// It's necessary to add the following configuration if you use shadcn-vue or shadcn-svelte.
content: {
pipeline: {
include: [
// the default
/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/,
// include js/ts files
'(components|src)/**/*.{js,ts}',
],
},
},
rules: [
[/^mask-\[(.*)\]$/, ([, suffix]) => ({ '-webkit-mask-image': suffix.replace(/_/g, ' ') })],
[/^bg-dotted-\[(.*)\]$/, ([, color], { theme }) => {
const parsedColor = parseColor(color, theme)
// Util usage: https://github.com/unocss/unocss/blob/f57ef6ae50006a92f444738e50f3601c0d1121f2/packages-presets/preset-mini/src/_utils/utilities.ts#L186
return {
'background-image': `radial-gradient(circle at 1px 1px, ${colorToString(parsedColor?.cssColor ?? parsedColor?.color ?? color, 'var(--un-background-opacity)')} 1px, transparent 0)`,
'--un-background-opacity': parsedColor?.cssColor?.alpha ?? parsedColor?.alpha ?? 1,
}
}],
],
theme: {
colors: {
primary: createColorSchemeConfig(),
complementary: createColorSchemeConfig(180),
},
},
})
@@ -0,0 +1,60 @@
import { join, resolve } from 'node:path'
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import { Download } from '@proj-airi/unplugin-fetch'
import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk'
import { templateCompilerOptions } from '@tresjs/core'
import Vue from '@vitejs/plugin-vue'
import UnoCss from 'unocss/vite'
import VueMacros from 'unplugin-vue-macros/vite'
import VueRouter from 'unplugin-vue-router/vite'
import { defineConfig } from 'vite'
import Layouts from 'vite-plugin-vue-layouts'
export default defineConfig({
optimizeDeps: {
exclude: [
'@proj-airi/stage-ui/*',
],
},
resolve: {
alias: {
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'dist')),
'@proj-airi/stage-ui/stores': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'dist', 'stores')),
},
},
plugins: [
VueMacros({
plugins: {
vue: Vue({
include: [/\.vue$/, /\.md$/],
...templateCompilerOptions,
}),
vueJsx: false,
},
}),
VueRouter({
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
routesFolder: 'src/pages',
}),
// TODO: Devtools
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
Layouts(),
UnoCss(),
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
VueI18n({
runtimeOnly: true,
compositionOnly: true,
fullInstall: true,
include: [resolve(import.meta.dirname, 'locales/**')],
}),
DownloadLive2DSDK(),
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'assets/live2d/models'),
Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'assets/live2d/models'),
],
})
+11
View File
@@ -36,3 +36,14 @@ $ pnpm build:mac
# For Linux
$ pnpm build:linux
```
## TODO List
- [ ] Window control: Move, resize, hide, show
- [ ] Motions follow the cursor
- [x] System tray
- [x] i18n
- [ ] Multi-DPI
- [ ] Multi monitor
- [ ] Send message via macOS Spotlight
- [ ] Steam
+1
View File
@@ -144,6 +144,7 @@ words:
- supergroup
- superjson
- tamagotchi
- tauri
- taze
- tolist
- tresjs
+507 -6
View File
@@ -418,7 +418,7 @@ importers:
version: 2.3.0
'@intlify/unplugin-vue-i18n':
specifier: ^6.0.5
version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.22.0(jiti@2.4.2))(rollup@2.79.1)(typescript@5.8.2)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.22.0(jiti@2.4.2))(rollup@4.36.0)(typescript@5.8.2)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
'@proj-airi/drizzle-duckdb-wasm':
specifier: workspace:^
version: link:../../packages/drizzle-duckdb-wasm
@@ -469,7 +469,7 @@ importers:
version: 3.0.0-beta.5(typescript@5.8.2)(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
'@vueuse/motion':
specifier: ^3.0.3
version: 3.0.3(magicast@0.3.5)(rollup@2.79.1)(vue@3.5.13(typescript@5.8.2))
version: 3.0.3(magicast@0.3.5)(rollup@4.36.0)(vue@3.5.13(typescript@5.8.2))
electron:
specifier: ^34.3.3
version: 34.3.3
@@ -488,6 +488,334 @@ importers:
unocss-preset-scrollbar:
specifier: ^3.2.0
version: 3.2.0(unocss@66.1.0-beta.6(postcss@8.5.3)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2)))
unplugin-auto-import:
specifier: ^19.1.1
version: 19.1.1(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.36.0))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))
unplugin-vue-components:
specifier: ^28.4.1
version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.36.0))(vue@3.5.13(typescript@5.8.2))
unplugin-vue-macros:
specifier: ^2.14.5
version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.19.12)(rollup@4.36.0)(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
unplugin-vue-markdown:
specifier: ^28.3.1
version: 28.3.1(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
unplugin-vue-router:
specifier: ^0.12.0
version: 0.12.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
vite-bundle-visualizer:
specifier: ^1.2.1
version: 1.2.1(rollup@4.36.0)
vite-plugin-pwa:
specifier: ^0.21.1
version: 0.21.1(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0)
vite-plugin-vue-devtools:
specifier: ^7.7.2
version: 7.7.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.36.0))(rollup@4.36.0)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
vite-plugin-vue-layouts:
specifier: ^0.11.0
version: 0.11.0(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
vue-tsc:
specifier: ^3.0.0-alpha.2
version: 3.0.0-alpha.2(typescript@5.8.2)
apps/stage-tamagotchi-tauri:
dependencies:
'@11labs/client':
specifier: ^0.0.8
version: 0.0.8
'@formkit/auto-animate':
specifier: ^0.8.2
version: 0.8.2
'@huggingface/transformers':
specifier: ^3.4.0
version: 3.4.0
'@pixi/app':
specifier: '6'
version: 6.5.10(@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/display@6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/math@6.5.10)(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))
'@pixi/constants':
specifier: '6'
version: 6.5.10
'@pixi/core':
specifier: '6'
version: 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))
'@pixi/display':
specifier: '6'
version: 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))
'@pixi/extensions':
specifier: '6'
version: 6.5.10
'@pixi/interaction':
specifier: '6'
version: 6.5.10(@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/display@6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/math@6.5.10)(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))
'@pixi/loaders':
specifier: '6'
version: 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))
'@pixi/math':
specifier: '6'
version: 6.5.10
'@pixi/runner':
specifier: '6'
version: 6.5.10
'@pixi/settings':
specifier: '6'
version: 6.5.10(@pixi/constants@6.5.10)
'@pixi/sprite':
specifier: '6'
version: 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/display@6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))
'@pixi/ticker':
specifier: '6'
version: 6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))
'@pixi/utils':
specifier: '6'
version: 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))
'@pixiv/three-vrm':
specifier: ^3.3.6
version: 3.3.6(three@0.174.0)
'@pixiv/three-vrm-animation':
specifier: ^3.3.6
version: 3.3.6(three@0.174.0)
'@pixiv/three-vrm-core':
specifier: ^3.3.6
version: 3.3.6(three@0.174.0)
'@proj-airi/stage-ui':
specifier: workspace:^
version: link:../../packages/stage-ui
'@ricky0123/vad-web':
specifier: ^0.0.22
version: 0.0.22
'@tauri-apps/api':
specifier: ^2.4.0
version: 2.4.0
'@tauri-apps/plugin-os':
specifier: '>=2'
version: 2.2.1
'@tresjs/cientos':
specifier: ^4.2.0
version: 4.2.0(@tresjs/core@4.3.3(three@0.174.0)(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)))(@types/three@0.174.0)(react@18.3.1)(three@0.174.0)(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
'@tresjs/core':
specifier: ^4.3.3
version: 4.3.3(three@0.174.0)(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
'@unhead/vue':
specifier: ^2.0.0-rc.13
version: 2.0.0-rc.13(vue@3.5.13(typescript@5.8.2))
'@vueuse/core':
specifier: ^13.0.0
version: 13.0.0(vue@3.5.13(typescript@5.8.2))
'@vueuse/head':
specifier: ^2.0.0
version: 2.0.0(vue@3.5.13(typescript@5.8.2))
'@vueuse/shared':
specifier: ^13.0.0
version: 13.0.0(vue@3.5.13(typescript@5.8.2))
'@xsai-ext/providers-cloud':
specifier: 'catalog:'
version: 0.1.3
'@xsai-ext/providers-local':
specifier: 'catalog:'
version: 0.1.3
'@xsai-ext/shared-providers':
specifier: 'catalog:'
version: 0.1.3
'@xsai/generate-speech':
specifier: 'catalog:'
version: 0.1.3
'@xsai/generate-text':
specifier: 'catalog:'
version: 0.1.3
'@xsai/model':
specifier: 'catalog:'
version: 0.1.3
'@xsai/shared-chat':
specifier: 'catalog:'
version: 0.1.3
'@xsai/stream-text':
specifier: 'catalog:'
version: 0.1.3
'@xsai/utils-chat':
specifier: 'catalog:'
version: 0.1.3
culori:
specifier: ^4.0.1
version: 4.0.1
defu:
specifier: ^6.1.4
version: 6.1.4
drizzle-kit:
specifier: ^0.30.5
version: 0.30.5
drizzle-orm:
specifier: ^0.40.1
version: 0.40.1(@types/pg@8.11.11)(gel@2.0.0)(pg@8.14.1)(postgres@3.4.5)
jszip:
specifier: ^3.10.1
version: 3.10.1
localforage:
specifier: ^1.10.0
version: 1.10.0
nprogress:
specifier: ^0.2.0
version: 0.2.0
ofetch:
specifier: ^1.4.1
version: 1.4.1
onnxruntime-web:
specifier: ^1.21.0
version: 1.21.0
pinia:
specifier: ^3.0.1
version: 3.0.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
pixi-filters:
specifier: ^4.2.0
version: 4.2.0(5af797a0b5bd919a06578c24a7479046)
pixi-live2d-display:
specifier: ^0.4.0
version: 0.4.0(0736f5bd67d2e4de7e9fff36ad63fa2e)
popmotion:
specifier: ^11.0.5
version: 11.0.5
radix-vue:
specifier: ^1.9.17
version: 1.9.17(vue@3.5.13(typescript@5.8.2))
rehype-stringify:
specifier: ^10.0.1
version: 10.0.1
remark-parse:
specifier: ^11.0.0
version: 11.0.0
remark-rehype:
specifier: ^11.1.1
version: 11.1.1
shiki:
specifier: ^3.2.1
version: 3.2.1
three:
specifier: ^0.174.0
version: 0.174.0
unified:
specifier: ^11.0.5
version: 11.0.5
valibot:
specifier: 1.0.0-beta.9
version: 1.0.0-beta.9(typescript@5.8.2)
vaul-vue:
specifier: ^0.4.1
version: 0.4.1(reka-ui@2.1.0(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
vue:
specifier: ^3.5.13
version: 3.5.13(typescript@5.8.2)
vue-demi:
specifier: ^0.14.10
version: 0.14.10(vue@3.5.13(typescript@5.8.2))
vue-i18n:
specifier: ^11.1.2
version: 11.1.2(vue@3.5.13(typescript@5.8.2))
vue-router:
specifier: ^4.5.0
version: 4.5.0(vue@3.5.13(typescript@5.8.2))
xsschema:
specifier: 'catalog:'
version: 0.1.3(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.0.0-beta.9(typescript@5.8.2)))(zod-to-json-schema@3.24.4(zod@3.24.2))
yauzl:
specifier: ^3.2.0
version: 3.2.0
zod:
specifier: ^3.24.2
version: 3.24.2
devDependencies:
'@iconify-json/carbon':
specifier: ^1.2.8
version: 1.2.8
'@iconify-json/eos-icons':
specifier: ^1.2.2
version: 1.2.2
'@iconify-json/lucide':
specifier: ^1.2.31
version: 1.2.31
'@iconify-json/mingcute':
specifier: ^1.2.3
version: 1.2.3
'@iconify-json/simple-icons':
specifier: ^1.2.29
version: 1.2.29
'@iconify-json/solar':
specifier: ^1.2.2
version: 1.2.2
'@iconify-json/svg-spinners':
specifier: ^1.2.2
version: 1.2.2
'@iconify-json/vscode-icons':
specifier: ^1.2.16
version: 1.2.16
'@iconify/utils':
specifier: ^2.3.0
version: 2.3.0
'@intlify/unplugin-vue-i18n':
specifier: ^6.0.5
version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.22.0(jiti@2.4.2))(rollup@2.79.1)(typescript@5.8.2)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
'@proj-airi/drizzle-duckdb-wasm':
specifier: workspace:^
version: link:../../packages/drizzle-duckdb-wasm
'@proj-airi/elevenlabs':
specifier: workspace:^
version: link:../../packages/elevenlabs
'@proj-airi/lobe-icons':
specifier: ^1.0.4
version: 1.0.4
'@proj-airi/provider-transformers':
specifier: workspace:^
version: link:../../packages/provider-transformers
'@proj-airi/ui-transitions':
specifier: workspace:^
version: link:../../packages/ui-transitions
'@proj-airi/unplugin-fetch':
specifier: ^0.1.4
version: 0.1.4(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
'@proj-airi/unplugin-live2d-sdk':
specifier: ^0.1.2
version: 0.1.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
'@shikijs/markdown-it':
specifier: ^3.2.1
version: 3.2.1
'@tauri-apps/cli':
specifier: ^2.3.1
version: 2.3.1
'@types/culori':
specifier: ^2.1.1
version: 2.1.1
'@types/markdown-it-link-attributes':
specifier: ^3.0.5
version: 3.0.5
'@types/nprogress':
specifier: ^0.2.3
version: 0.2.3
'@types/three':
specifier: ^0.174.0
version: 0.174.0
'@types/yauzl':
specifier: ^2.10.3
version: 2.10.3
'@unocss/reset':
specifier: ^66.1.0-beta.6
version: 66.1.0-beta.6
'@vitejs/plugin-vue':
specifier: ^5.2.3
version: 5.2.3(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
'@vue-macros/volar':
specifier: ^3.0.0-beta.5
version: 3.0.0-beta.5(typescript@5.8.2)(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
'@vueuse/motion':
specifier: ^3.0.3
version: 3.0.3(magicast@0.3.5)(rollup@2.79.1)(vue@3.5.13(typescript@5.8.2))
less:
specifier: ^4.2.2
version: 4.2.2
markdown-it-link-attributes:
specifier: ^4.0.1
version: 4.0.1
unocss-preset-scrollbar:
specifier: ^3.2.0
version: 3.2.0(unocss@66.1.0-beta.6(postcss@8.5.3)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2)))
unplugin-auto-import:
specifier: ^19.1.1
version: 19.1.1(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))
@@ -496,7 +824,7 @@ importers:
version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(vue@3.5.13(typescript@5.8.2))
unplugin-vue-macros:
specifier: ^2.14.5
version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.19.12)(rollup@2.79.1)(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.25.0)(rollup@2.79.1)(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
unplugin-vue-markdown:
specifier: ^28.3.1
version: 28.3.1(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
@@ -4509,6 +4837,77 @@ packages:
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@tauri-apps/api@2.4.0':
resolution: {integrity: sha512-F1zXTsmwcCp+ocg6fbzD/YL0OHeSG1eynCag1UNlX2tD5+dlXy7eRbTu9cAcscPjcR7Nix7by2wiv/+VfWUieg==}
'@tauri-apps/cli-darwin-arm64@2.3.1':
resolution: {integrity: sha512-TOhSdsXYt+f+asRU+Dl+Wufglj/7+CX9h8RO4hl5k7D6lR4L8yTtdhpS7btaclOMmjYC4piNfJE70GoxhOoYWw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.3.1':
resolution: {integrity: sha512-LDwGg3AuBQ3aCeMAFaFwt0MSGOVFoXuXEe0z4QxQ7jZE5tdAOhKABaq4i569V5lShCgQZ6nLD/tmA5+GipvHnA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.3.1':
resolution: {integrity: sha512-hu3HpbbtJBvHXw5i54QHwLxOUoXWqhf7CL2YYSPOrWEEQo10NKddulP61L5gfr5z+bSSaitfLwqgTidgnaNJCA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.3.1':
resolution: {integrity: sha512-mEGgwkiGSKYXWHhGodo7zU9PCd2I/d6KkR+Wp1nzK+DxsCrEK6yJ5XxYLSQSDcKkM4dCxpVEPUiVMbDhmn08jg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-arm64-musl@2.3.1':
resolution: {integrity: sha512-tqQkafikGfnc7ISnGjSYkbpnzJKEyO8XSa0YOXTAL3J8R5Pss5ZIZY7G8kq1mwQSR/dPVR1ZLTVXgZGuysjP8w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-x64-gnu@2.3.1':
resolution: {integrity: sha512-I3puDJ2wGEauXlXbzIHn2etz78TaWs1cpN6zre02maHr6ZR7nf7euTCOGPhhfoMG0opA5mT/eLuYpVw648/VAA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-linux-x64-musl@2.3.1':
resolution: {integrity: sha512-rbWiCOBuQN7tPySkUyBs914uUikE3mEUOqV/IFospvKESw4UC3G1DL5+ybfXH7Orb8/in3JpJuVzYQjo+OSbBA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-win32-arm64-msvc@2.3.1':
resolution: {integrity: sha512-PdTmUzSeTHjJuBpCV7L+V29fPhPtToU+NZU46slHKSA1aT38MiFDXBZ/6P5Zudrt9QPMfIubqnJKbK8Ivvv7Ww==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.3.1':
resolution: {integrity: sha512-K/Xa97kspWT4UWj3t26lL2D3QsopTAxS7kWi5kObdqtAGn3qD52qBi24FH38TdvHYz4QlnLIb30TukviCgh4gw==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.3.1':
resolution: {integrity: sha512-RgwzXbP8gAno3kQEsybMtgLp6D1Z1Nec2cftryYbPTJmoMJs6e4qgtxuTSbUz5SKnHe8rGgMiFSvEGoHvbG72Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.3.1':
resolution: {integrity: sha512-xewcw/ZsCqgilTy2h7+pp2Baxoy7zLR2wXOV7SZLzkb6SshHVbm1BFAjn8iFATURRW85KLzl6wSGJ2dQHjVHqw==}
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-os@2.2.1':
resolution: {integrity: sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==}
'@testing-library/dom@10.4.0':
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
engines: {node: '>=18'}
@@ -15268,6 +15667,55 @@ snapshots:
'@tanstack/virtual-core': 3.13.2
vue: 3.5.13(typescript@5.8.2)
'@tauri-apps/api@2.4.0': {}
'@tauri-apps/cli-darwin-arm64@2.3.1':
optional: true
'@tauri-apps/cli-darwin-x64@2.3.1':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.3.1':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.3.1':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.3.1':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.3.1':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.3.1':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.3.1':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.3.1':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.3.1':
optional: true
'@tauri-apps/cli@2.3.1':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.3.1
'@tauri-apps/cli-darwin-x64': 2.3.1
'@tauri-apps/cli-linux-arm-gnueabihf': 2.3.1
'@tauri-apps/cli-linux-arm64-gnu': 2.3.1
'@tauri-apps/cli-linux-arm64-musl': 2.3.1
'@tauri-apps/cli-linux-x64-gnu': 2.3.1
'@tauri-apps/cli-linux-x64-musl': 2.3.1
'@tauri-apps/cli-win32-arm64-msvc': 2.3.1
'@tauri-apps/cli-win32-ia32-msvc': 2.3.1
'@tauri-apps/cli-win32-x64-msvc': 2.3.1
'@tauri-apps/plugin-os@2.2.1':
dependencies:
'@tauri-apps/api': 2.4.0
'@testing-library/dom@10.4.0':
dependencies:
'@babel/code-frame': 7.26.2
@@ -24041,9 +24489,16 @@ snapshots:
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.36.0)
'@vueuse/core': 13.0.0(vue@3.5.13(typescript@5.8.2))
unplugin-combine@1.2.1(esbuild@0.19.12)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)):
unplugin-combine@1.2.1(esbuild@0.19.12)(rollup@4.36.0)(unplugin@1.16.1)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)):
optionalDependencies:
esbuild: 0.19.12
rollup: 4.36.0
unplugin: 1.16.1
vite: 6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
unplugin-combine@1.2.1(esbuild@0.25.0)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)):
optionalDependencies:
esbuild: 0.25.0
rollup: 2.79.1
unplugin: 1.16.1
vite: 6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
@@ -24102,7 +24557,7 @@ snapshots:
transitivePeerDependencies:
- vue
unplugin-vue-macros@2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.19.12)(rollup@2.79.1)(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2)):
unplugin-vue-macros@2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.19.12)(rollup@4.36.0)(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2)):
dependencies:
'@vue-macros/better-define': 1.11.4(vue@3.5.13(typescript@5.8.2))
'@vue-macros/boolean-prop': 0.5.5(vue@3.5.13(typescript@5.8.2))
@@ -24134,7 +24589,53 @@ snapshots:
'@vue-macros/short-vmodel': 1.5.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/volar': 0.30.15(typescript@5.8.2)(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
unplugin: 1.16.1
unplugin-combine: 1.2.1(esbuild@0.19.12)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
unplugin-combine: 1.2.1(esbuild@0.19.12)(rollup@4.36.0)(unplugin@1.16.1)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
unplugin-vue-define-options: 1.5.5(vue@3.5.13(typescript@5.8.2))
vue: 3.5.13(typescript@5.8.2)
transitivePeerDependencies:
- '@rspack/core'
- '@vueuse/core'
- esbuild
- rolldown
- rollup
- typescript
- vite
- vue-tsc
- webpack
unplugin-vue-macros@2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.25.0)(rollup@2.79.1)(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2)):
dependencies:
'@vue-macros/better-define': 1.11.4(vue@3.5.13(typescript@5.8.2))
'@vue-macros/boolean-prop': 0.5.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/chain-call': 0.4.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/common': 1.16.1(vue@3.5.13(typescript@5.8.2))
'@vue-macros/config': 0.6.1(vue@3.5.13(typescript@5.8.2))
'@vue-macros/define-emit': 0.5.4(vue@3.5.13(typescript@5.8.2))
'@vue-macros/define-models': 1.3.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
'@vue-macros/define-prop': 0.6.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/define-props': 4.0.6(@vue-macros/reactivity-transform@1.1.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
'@vue-macros/define-props-refs': 1.3.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/define-render': 1.6.6(vue@3.5.13(typescript@5.8.2))
'@vue-macros/define-slots': 1.2.6(vue@3.5.13(typescript@5.8.2))
'@vue-macros/define-stylex': 0.2.3(vue@3.5.13(typescript@5.8.2))
'@vue-macros/devtools': 0.4.1(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
'@vue-macros/export-expose': 0.3.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/export-props': 0.6.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/export-render': 0.3.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/hoist-static': 1.7.0(vue@3.5.13(typescript@5.8.2))
'@vue-macros/jsx-directive': 0.10.6(typescript@5.8.2)
'@vue-macros/named-template': 0.5.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/reactivity-transform': 1.1.6(vue@3.5.13(typescript@5.8.2))
'@vue-macros/script-lang': 0.2.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/setup-block': 0.4.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/setup-component': 0.18.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/setup-sfc': 0.18.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/short-bind': 1.1.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/short-emits': 1.6.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/short-vmodel': 1.5.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/volar': 0.30.15(typescript@5.8.2)(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
unplugin: 1.16.1
unplugin-combine: 1.2.1(esbuild@0.25.0)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
unplugin-vue-define-options: 1.5.5(vue@3.5.13(typescript@5.8.2))
vue: 3.5.13(typescript@5.8.2)
transitivePeerDependencies: