feat(stage-pocket): scan qr code to connect to tamagotchi
This commit is contained in:
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-barcode-scanner')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-native-settings')
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@8.2.0_@capacitor+core@8.2.0/node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-barcode-scanner'
|
||||
project(':capacitor-barcode-scanner').projectDir = new File('../../../node_modules/.pnpm/@capacitor+barcode-scanner@3.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/barcode-scanner/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../../../node_modules/.pnpm/@capacitor+local-notifications@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
val minSdkVersion by extra(24)
|
||||
val minSdkVersion by extra(26)
|
||||
val compileSdkVersion by extra(36)
|
||||
val targetSdkVersion by extra(36)
|
||||
val androidxActivityVersion by extra("1.11.0")
|
||||
|
||||
+10
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "ba02834b61084af7060acde9aa9e378f3dfb98b9ff948bb94ea14d8b846a36f1",
|
||||
"originHash" : "8765687701ea10de7b4fa5981471aaf26157c825c91c99a7f0e25bbdf71f12e6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "capacitor-swift-pm",
|
||||
@@ -9,6 +9,15 @@
|
||||
"revision" : "0e862e6ff13852a710c8a484180ca4d6a2cc9761",
|
||||
"version" : "8.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "osbarcodelib-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/OutSystems/OSBarcodeLib-iOS.git",
|
||||
"state" : {
|
||||
"revision" : "1ae7a716331be720f9f1075ef033276014c341ec",
|
||||
"version" : "2.1.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>AIRI needs access to your local network to connect to your Tamagotchi host.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>AIRI uses the camera to scan Tamagotchi connection QR codes.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
"revision" : "0e862e6ff13852a710c8a484180ca4d6a2cc9761",
|
||||
"version" : "8.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "osbarcodelib-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/OutSystems/OSBarcodeLib-iOS.git",
|
||||
"state" : {
|
||||
"revision" : "1ae7a716331be720f9f1075ef033276014c341ec",
|
||||
"version" : "2.1.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
||||
@@ -12,6 +12,7 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorBarcodeScanner", path: "../../../../../node_modules/.pnpm/@capacitor+barcode-scanner@3.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/barcode-scanner"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../../../node_modules/.pnpm/@capacitor+local-notifications@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorNativeSettings", path: "../../../../../node_modules/.pnpm/capacitor-native-settings@8.1.0_@capacitor+core@8.2.0/node_modules/capacitor-native-settings")
|
||||
],
|
||||
@@ -21,6 +22,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorNativeSettings", package: "CapacitorNativeSettings")
|
||||
]
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "catalog:",
|
||||
"@capacitor/barcode-scanner": "catalog:",
|
||||
"@capacitor/core": "catalog:",
|
||||
"@capacitor/ios": "catalog:",
|
||||
"@capacitor/local-notifications": "catalog:",
|
||||
|
||||
@@ -134,7 +134,7 @@ const extraSteps = computed(() => [
|
||||
</StageTransitionGroup>
|
||||
|
||||
<ToasterRoot @close="id => toast.dismiss(id)">
|
||||
<Toaster />
|
||||
<Toaster rich-colors />
|
||||
</ToasterRoot>
|
||||
|
||||
<!-- First Time Setup Dialog -->
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { Client, WebSocketEventSource } from '@proj-airi/server-sdk'
|
||||
|
||||
import { getHostWebSocketConstructor } from './websocket-bridge'
|
||||
|
||||
export async function probeServerChannelQrPayload(payload: ServerChannelQrPayload) {
|
||||
const websocketConstructor = getHostWebSocketConstructor()
|
||||
if (!websocketConstructor) {
|
||||
throw new Error('AIRI host websocket bridge is unavailable')
|
||||
}
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
for (const url of payload.urls) {
|
||||
const client = new Client({
|
||||
autoConnect: false,
|
||||
autoReconnect: false,
|
||||
connectTimeoutMs: 2_000,
|
||||
name: WebSocketEventSource.StageWeb,
|
||||
token: payload.authToken,
|
||||
url,
|
||||
websocketConstructor,
|
||||
})
|
||||
|
||||
try {
|
||||
await client.connect({ timeout: 2_500 })
|
||||
client.close()
|
||||
return url
|
||||
}
|
||||
catch (error) {
|
||||
client.close()
|
||||
errors.push(`${url}: ${errorMessageFrom(error) ?? 'Unknown websocket probe error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No candidate server channel URL was reachable. ${errors.join('; ')}`)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import ConnectionSettings from '@proj-airi/stage-pages/pages/settings/connection/ConnectionSettings.vue'
|
||||
|
||||
import ServerChannelQrScanner from './server-channel-qr-scanner.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConnectionSettings>
|
||||
<template #platform-specific>
|
||||
<ServerChannelQrScanner />
|
||||
</template>
|
||||
</ConnectionSettings>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
titleKey: settings.pages.connection.title
|
||||
subtitleKey: settings.title
|
||||
descriptionKey: settings.pages.connection.description
|
||||
icon: i-solar:wi-fi-router-bold-duotone
|
||||
settingsEntry: true
|
||||
order: 8
|
||||
stageTransition:
|
||||
name: slide
|
||||
</route>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { CapacitorBarcodeScanner, CapacitorBarcodeScannerTypeHint } from '@capacitor/barcode-scanner'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { parseServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
|
||||
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { probeServerChannelQrPayload } from '../../../modules/server-channel-qr-probe'
|
||||
|
||||
const { t } = useI18n()
|
||||
const serverChannelStore = useModsServerChannelStore()
|
||||
|
||||
const scanning = shallowRef(false)
|
||||
const errorMessage = shallowRef('')
|
||||
|
||||
async function scanServerChannelQrCode() {
|
||||
scanning.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const scanResult = await CapacitorBarcodeScanner.scanBarcode({
|
||||
hint: CapacitorBarcodeScannerTypeHint.QR_CODE,
|
||||
scanInstructions: t('settings.pages.connection.qr-scan.instructions'),
|
||||
})
|
||||
const payload = parseServerChannelQrPayload(scanResult.ScanResult)
|
||||
const url = await probeServerChannelQrPayload(payload)
|
||||
|
||||
serverChannelStore.websocketAuthToken = payload.authToken
|
||||
serverChannelStore.websocketUrl = url
|
||||
toast.success(t('settings.pages.connection.qr-scan.success.title'), {
|
||||
description: t('settings.pages.connection.qr-scan.success.description', { url }),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = errorMessageFrom(error) ?? t('settings.pages.connection.qr-scan.errors.failed')
|
||||
toast.error(t('settings.pages.connection.qr-scan.errors.title'), {
|
||||
description: errorMessage.value,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['flex flex-col items-start justify-between gap-3']">
|
||||
<div :class="['flex flex-col gap-1']">
|
||||
<div :class="['text-sm font-medium text-neutral-900 dark:text-neutral-100']">
|
||||
{{ t('settings.pages.connection.qr-scan.title') }}
|
||||
</div>
|
||||
<p :class="['m-0 text-xs leading-5 text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.connection.qr-scan.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary-muted"
|
||||
:loading="scanning"
|
||||
:label="t('settings.pages.connection.qr-scan.action')"
|
||||
@click="scanServerChannelQrCode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -136,7 +136,13 @@ export default defineConfig({
|
||||
importMode: 'async',
|
||||
routesFolder: [
|
||||
resolve(import.meta.dirname, 'src', 'pages'),
|
||||
resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
|
||||
{
|
||||
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
|
||||
exclude: base => [
|
||||
...base,
|
||||
'**/settings/connection/index.vue',
|
||||
],
|
||||
},
|
||||
],
|
||||
exclude: ['**/components/**'],
|
||||
}),
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"three": "^0.183.2",
|
||||
"unified": "^11.0.5",
|
||||
"unspeech": "catalog:xsai",
|
||||
"uqr": "catalog:",
|
||||
"uuid": "^13.0.0",
|
||||
"valibot": "^1.3.1",
|
||||
"vaul-vue": "^0.4.1",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ElectronServerChannelConfig } from '../../../../shared/eventa'
|
||||
|
||||
import { randomUUID, X509Certificate } from 'node:crypto'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { isIP } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import { env, platform } from 'node:process'
|
||||
|
||||
@@ -13,6 +14,7 @@ import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { createServer, getLocalIPs } from '@proj-airi/server-runtime/server'
|
||||
import { createServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { app, ipcMain, session } from 'electron'
|
||||
import { createCA, createCert } from 'mkcert'
|
||||
@@ -23,6 +25,7 @@ import { z } from 'zod'
|
||||
import {
|
||||
electronApplyServerChannelConfig,
|
||||
electronGetServerChannelConfig,
|
||||
electronGetServerChannelQrPayload,
|
||||
} from '../../../../shared/eventa'
|
||||
import { createConfig } from '../../../libs/electron/persistence'
|
||||
import { ensureServerChannelConfigDefaults } from './config'
|
||||
@@ -75,6 +78,50 @@ function getServerChannelPort() {
|
||||
return env.SERVER_CHANNEL_PORT ? Number.parseInt(env.SERVER_CHANNEL_PORT) : 6121
|
||||
}
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
|
||||
|
||||
function isLoopbackHost(host: string) {
|
||||
return LOOPBACK_HOSTS.has(host)
|
||||
}
|
||||
|
||||
function getServerChannelQrHosts(config: ElectronServerChannelConfig, serverChannel: Server) {
|
||||
if (config.hostname === '0.0.0.0') {
|
||||
return Array.from(new Set(serverChannel.getConnectionHost()))
|
||||
.filter(host => !isLoopbackHost(host))
|
||||
.sort()
|
||||
}
|
||||
|
||||
if (isLoopbackHost(config.hostname)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [config.hostname]
|
||||
}
|
||||
|
||||
function createServerChannelUrl(protocol: 'ws' | 'wss', host: string) {
|
||||
const urlHost = isIP(host) === 6 ? `[${host}]` : host
|
||||
// TODO: Deduplicate the server channel websocket path with `packages/server-runtime/src/index.ts`
|
||||
// and `packages/server-sdk/src/client.ts` so this does not rely on three separate `/ws` literals.
|
||||
return `${protocol}://${urlHost}:${getServerChannelPort()}/ws`
|
||||
}
|
||||
|
||||
function getServerChannelQrPayload(config: ElectronServerChannelConfig, serverChannel: Server) {
|
||||
const protocol = config.tlsConfig ? 'wss' : 'ws'
|
||||
const urls = getServerChannelQrHosts(config, serverChannel)
|
||||
.map(host => createServerChannelUrl(protocol, host))
|
||||
|
||||
if (!urls.length) {
|
||||
throw new Error('No reachable private LAN address is available for the current server channel host.')
|
||||
}
|
||||
|
||||
return createServerChannelQrPayload({
|
||||
type: 'airi:server-channel',
|
||||
version: 1,
|
||||
urls,
|
||||
authToken: config.authToken,
|
||||
})
|
||||
}
|
||||
|
||||
async function getChannelServerConfig(): Promise<ElectronServerChannelConfig> {
|
||||
const config = channelServerConfigStore.get() || { hostname: '127.0.0.1', authToken: '', tlsConfig: null }
|
||||
|
||||
@@ -413,6 +460,11 @@ export async function createServerChannelService(params: { serverChannel: Server
|
||||
return await getChannelServerConfig()
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronGetServerChannelQrPayload, async () => {
|
||||
const config = await getChannelServerConfig()
|
||||
return getServerChannelQrPayload(config, params.serverChannel)
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronApplyServerChannelConfig, async (req) => {
|
||||
const current = await getChannelServerConfig()
|
||||
const next = await normalizeChannelServerOptions(req, current)
|
||||
|
||||
@@ -7,6 +7,8 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ServerChannelQrCard from './server-channel-qr-card.vue'
|
||||
|
||||
import { useServerChannelSettingsStore } from '../../../stores/settings/server-channel'
|
||||
import {
|
||||
hostnameFromExposureMode,
|
||||
@@ -121,6 +123,8 @@ const exposureModeOptions = computed(() => [
|
||||
>
|
||||
{{ lastApplyError }}
|
||||
</Callout>
|
||||
|
||||
<ServerChannelQrCard />
|
||||
</template>
|
||||
</ConnectionSettings>
|
||||
</template>
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { Button, Callout, Collapsible } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { renderSVG } from 'uqr'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { electronGetServerChannelQrPayload } from '../../../../shared/eventa'
|
||||
import { useServerChannelSettingsStore } from '../../../stores/settings/server-channel'
|
||||
|
||||
const { t } = useI18n()
|
||||
const getServerChannelQrPayload = useElectronEventaInvoke(electronGetServerChannelQrPayload)
|
||||
const { authToken, hostname, tlsConfig } = storeToRefs(useServerChannelSettingsStore())
|
||||
|
||||
const loading = shallowRef(false)
|
||||
const payload = shallowRef<ServerChannelQrPayload>()
|
||||
const errorMessage = shallowRef('')
|
||||
|
||||
const payloadText = computed(() => {
|
||||
if (!payload.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return JSON.stringify(payload.value)
|
||||
})
|
||||
|
||||
const qrCodeSource = computed(() => {
|
||||
if (!payloadText.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const svg = renderSVG(payloadText.value, {
|
||||
border: 2,
|
||||
ecc: 'M',
|
||||
pixelSize: 8,
|
||||
whiteColor: 'transparent',
|
||||
blackColor: '#111827',
|
||||
})
|
||||
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
|
||||
})
|
||||
|
||||
async function refreshPayload() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
payload.value = await getServerChannelQrPayload()
|
||||
}
|
||||
catch (error) {
|
||||
payload.value = undefined
|
||||
errorMessage.value = errorMessageFrom(error) ?? t('settings.pages.connection.qr.errors.unavailable')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([hostname, tlsConfig, authToken], () => {
|
||||
void refreshPayload()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Collapsible :default="false">
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
:class="[
|
||||
'w-full flex items-center justify-between gap-3 rounded-xl text-left outline-none transition-all duration-250 ease-in-out',
|
||||
]"
|
||||
@click="slotProps.setVisible(!slotProps.visible)"
|
||||
>
|
||||
<div :class="['min-w-0 flex flex-col gap-1']">
|
||||
<div :class="['text-sm font-medium text-neutral-900 dark:text-neutral-100']">
|
||||
{{ t('settings.pages.connection.qr.title') }}
|
||||
</div>
|
||||
<p :class="['m-0 text-xs leading-5 text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.connection.qr.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'mt-0.5 shrink-0 text-neutral-400 transition-transform duration-250 dark:text-neutral-500',
|
||||
'i-solar:alt-arrow-down-linear',
|
||||
slotProps.visible ? 'rotate-180' : 'rotate-0',
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'mt-3 rounded-2xl border border-neutral-200/70 bg-white/70 p-4 dark:border-neutral-700/70 dark:bg-neutral-900/50',
|
||||
'flex flex-col gap-4',
|
||||
]"
|
||||
>
|
||||
<div :class="['flex items-start justify-between gap-3']">
|
||||
<p :class="['m-0 text-xs leading-5 text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.connection.qr.token-hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
v-if="errorMessage"
|
||||
theme="orange"
|
||||
:label="t('settings.pages.connection.qr.errors.title')"
|
||||
>
|
||||
<p :class="['m-0 text-xs leading-5']">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</Callout>
|
||||
|
||||
<div
|
||||
v-else-if="payload"
|
||||
:class="[
|
||||
'grid grid-cols-1 gap-4',
|
||||
'md:grid-cols-[auto_minmax(0,1fr)]',
|
||||
]"
|
||||
>
|
||||
<img
|
||||
:src="qrCodeSource"
|
||||
:alt="t('settings.pages.connection.qr.image-alt')"
|
||||
:class="['h-48 w-48']"
|
||||
>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary-muted"
|
||||
:loading="loading"
|
||||
:label="t('settings.pages.connection.qr.refresh')"
|
||||
@click="refreshPayload"
|
||||
/>
|
||||
|
||||
<div :class="['min-w-0 flex flex-col gap-3']">
|
||||
<Collapsible :default="false">
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
:class="[
|
||||
'w-full flex items-center justify-between gap-3 rounded-xl text-left outline-none transition-all duration-250 ease-in-out',
|
||||
]"
|
||||
@click="slotProps.setVisible(!slotProps.visible)"
|
||||
>
|
||||
<div :class="['text-xs font-medium text-neutral-600 dark:text-neutral-300']">
|
||||
{{ t('settings.pages.connection.qr.candidates') }}
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'shrink-0 text-neutral-400 transition-transform duration-250 dark:text-neutral-500',
|
||||
'i-solar:alt-arrow-down-linear',
|
||||
slotProps.visible ? 'rotate-180' : 'rotate-0',
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<ul :class="['m-0 mt-3 list-none flex flex-col gap-2 p-0']">
|
||||
<li
|
||||
v-for="url in payload.urls"
|
||||
:key="url"
|
||||
:class="[
|
||||
'rounded-lg',
|
||||
'bg-neutral-100/80 dark:bg-neutral-800/80',
|
||||
'px-3 py-2',
|
||||
'font-mono text-xs text-neutral-700 dark:text-neutral-200',
|
||||
'break-all',
|
||||
]"
|
||||
>
|
||||
{{ url }}
|
||||
</li>
|
||||
</ul>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Locale } from '@intlify/core'
|
||||
import type { ServerOptions } from '@proj-airi/server-runtime/server'
|
||||
import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
|
||||
import type {
|
||||
ThreeHitTestReadTracePayload,
|
||||
ThreeSceneRenderInfoTracePayload,
|
||||
@@ -30,6 +31,7 @@ export interface ElectronServerChannelConfig {
|
||||
}
|
||||
export const electronGetServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig>('eventa:invoke:electron:server-channel:get-config')
|
||||
export const electronApplyServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig, Partial<ElectronServerChannelConfig>>('eventa:invoke:electron:server-channel:apply-config')
|
||||
export const electronGetServerChannelQrPayload = defineInvokeEventa<ServerChannelQrPayload>('eventa:invoke:electron:server-channel:get-qr-payload')
|
||||
|
||||
export const electronPluginList = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:list')
|
||||
export const electronPluginSetEnabled = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean, path?: string }>('eventa:invoke:electron:plugins:set-enabled')
|
||||
|
||||
@@ -34,13 +34,13 @@ dialogs:
|
||||
retry: Retry
|
||||
credentialsSafeLabel: Keep your API keys and credentials safe!
|
||||
credentialsSafeLocal: >-
|
||||
AIRI is running pure locally in your PC, and we will never steal
|
||||
your credentials for AI / LLM providers. But keep in mind that your API
|
||||
keys are sensitive information. Make sure to keep them safe and do not
|
||||
share them with anyone.
|
||||
AIRI is running pure locally in your PC, and we will never steal your
|
||||
credentials for AI / LLM providers. But keep in mind that your API keys
|
||||
are sensitive information. Make sure to keep them safe and do not share
|
||||
them with anyone.
|
||||
credentialsSafeOpenSource: >-
|
||||
AIRI is open sourced at {github}, if you want to check how we handle
|
||||
your credentials, feel free to inspect our code.
|
||||
AIRI is open sourced at {github}, if you want to check how we handle your
|
||||
credentials, feel free to inspect our code.
|
||||
enableChatCheck: >-
|
||||
Allow airi to send a "ping" message to the first available chat model to
|
||||
check availability
|
||||
@@ -70,7 +70,7 @@ dialogs:
|
||||
stateNotGranted: Not granted
|
||||
language:
|
||||
title: Language
|
||||
description: >
|
||||
description: |
|
||||
UI language. You can set characters' language later.
|
||||
controls-island:
|
||||
icon-size:
|
||||
@@ -82,7 +82,9 @@ controls-island:
|
||||
analytics:
|
||||
notice:
|
||||
title: Usage analytics
|
||||
description: AIRI collects anonymous usage analytics to help us understand how the app is used and improve stability. No personal data is collected.
|
||||
description: >-
|
||||
AIRI collects anonymous usage analytics to help us understand how the app
|
||||
is used and improve stability. No personal data is collected.
|
||||
privacyPrefix: Read the
|
||||
privacyLink: privacy policy
|
||||
onboardingHint: You can turn analytics off later in Settings > System > General.
|
||||
@@ -402,14 +404,21 @@ pages:
|
||||
description: Playing Minecraft!
|
||||
title: Minecraft
|
||||
enable: Enable Minecraft Integration
|
||||
enable-description: Include Minecraft self-knowledge in Stage chat turns and keep passive runtime visibility enabled.
|
||||
enable-description: >-
|
||||
Include Minecraft self-knowledge in Stage chat turns and keep passive
|
||||
runtime visibility enabled.
|
||||
configured: Minecraft is properly configured!
|
||||
read-only:
|
||||
title: Read-only shell
|
||||
description: Stage currently exposes Minecraft as a passive shell. Runtime status and traffic are visible here, but service configuration stays in the Minecraft service files.
|
||||
description: >-
|
||||
Stage currently exposes Minecraft as a passive shell. Runtime status
|
||||
and traffic are visible here, but service configuration stays in the
|
||||
Minecraft service files.
|
||||
setup:
|
||||
title: Setup
|
||||
description: You can find the instructions for setting up the bot in the following file
|
||||
description: >-
|
||||
You can find the instructions for setting up the bot in the following
|
||||
file
|
||||
runtime:
|
||||
title: Observed runtime
|
||||
connection: Connection
|
||||
@@ -423,7 +432,7 @@ pages:
|
||||
status:
|
||||
service-online: Service online
|
||||
service-offline: Service offline
|
||||
no-runtime-context: 'No bot context observed yet'
|
||||
no-runtime-context: No bot context observed yet
|
||||
last-context-seconds: 'Last bot context: {seconds}s ago'
|
||||
last-context-stale: 'Last bot context: Stale'
|
||||
hearing:
|
||||
@@ -435,12 +444,18 @@ pages:
|
||||
description: Select the suitable speech recognition provider
|
||||
confidence-threshold:
|
||||
title: Confidence Threshold
|
||||
description: Filter out low-confidence transcriptions to reduce Whisper hallucinations. Values closer to 0 are more strict; drag to the leftmost to disable. Only effective for providers supporting Whisper API (e.g., OpenAI, Groq).
|
||||
description: >-
|
||||
Filter out low-confidence transcriptions to reduce Whisper
|
||||
hallucinations. Values closer to 0 are more strict; drag to the
|
||||
leftmost to disable. Only effective for providers supporting
|
||||
Whisper API (e.g., OpenAI, Groq).
|
||||
disabled: Disabled
|
||||
verbose-json-note: >-
|
||||
Note: If your provider does not support verbose_json responses, this setting will have no effect.
|
||||
Note: If your provider does not support verbose_json responses,
|
||||
this setting will have no effect.
|
||||
verbose-json-unsupported: >-
|
||||
Your provider did not return verbose_json segments. Confidence filtering had no effect on the last transcription.
|
||||
Your provider did not return verbose_json segments. Confidence
|
||||
filtering had no effect on the last transcription.
|
||||
memory-long-term:
|
||||
description: Long-term memory specific settings and management
|
||||
title: Long-Term Memory
|
||||
@@ -530,7 +545,9 @@ pages:
|
||||
apply-and-restart: Save and restart stdio MCP
|
||||
messages:
|
||||
opened: Config file opened at {path}
|
||||
restarted: MCP servers restarted. Started {started}, failed {failed}, skipped {skipped}
|
||||
restarted: >-
|
||||
MCP servers restarted. Started {started}, failed {failed}, skipped
|
||||
{skipped}
|
||||
flux:
|
||||
title: Flux
|
||||
buy: Charge
|
||||
@@ -608,7 +625,8 @@ pages:
|
||||
thinking-mode:
|
||||
label: Thinking Mode
|
||||
description: >
|
||||
Controls Ollama thinking behavior. GPT-OSS only supports low/medium/high levels.
|
||||
Controls Ollama thinking behavior. GPT-OSS only supports
|
||||
low/medium/high levels.
|
||||
options:
|
||||
auto: Auto (provider default)
|
||||
disable: Disable
|
||||
@@ -652,7 +670,7 @@ pages:
|
||||
integration guide</a>.
|
||||
common:
|
||||
continueAnyway: Continue Anyway
|
||||
goToModelSelection: 'Select Model →'
|
||||
goToModelSelection: Select Model →
|
||||
fields:
|
||||
field:
|
||||
api-key:
|
||||
@@ -946,17 +964,48 @@ pages:
|
||||
placeholder: ws://localhost:6121/ws
|
||||
server-hostname:
|
||||
label: Expose On Network
|
||||
description: Choose whether the AIRI gateway only listens on this device or can be reached from your local network.
|
||||
description: >-
|
||||
Choose whether the AIRI gateway only listens on this device or can be
|
||||
reached from your local network.
|
||||
advanced-label: Bind Hostname
|
||||
advanced-description: Use a specific hostname or IP address when you need manual network binding.
|
||||
advanced-description: >-
|
||||
Use a specific hostname or IP address when you need manual network
|
||||
binding.
|
||||
options:
|
||||
this-device: This device
|
||||
all: All
|
||||
advanced: Advanced
|
||||
server-auth-token:
|
||||
label: Auth Token
|
||||
description: Security token required by remote clients that connect to this gateway. When left empty, AIRI generates a new token automatically.
|
||||
description: >-
|
||||
Security token required by remote clients that connect to this gateway.
|
||||
When left empty, AIRI generates a new token automatically.
|
||||
placeholder: Auto-generate
|
||||
qr:
|
||||
title: Connect from Stage Pocket
|
||||
description: >-
|
||||
Scan this QR code from Stage Pocket to try all reachable local network
|
||||
addresses and save the first working one.
|
||||
refresh: Refresh
|
||||
image-alt: AIRI server channel connection QR code
|
||||
candidates: Candidate addresses
|
||||
token-hint: >-
|
||||
The QR code includes the auth token so Stage Pocket can authenticate
|
||||
after connecting. Do not share it publicly.
|
||||
errors:
|
||||
title: QR code unavailable
|
||||
unavailable: Failed to prepare the connection QR code.
|
||||
qr-scan:
|
||||
title: Quick Connect
|
||||
description: Scan the QR code from Tamagotchi to quick connect
|
||||
instructions: Point the camera at the AIRI connection QR code.
|
||||
action: Scan
|
||||
success:
|
||||
title: Connected address saved
|
||||
description: 'URL been used: {url}'
|
||||
errors:
|
||||
title: QR scan failed
|
||||
failed: Failed to scan or connect with the QR code.
|
||||
scene:
|
||||
description: Configure the environment where the character lives
|
||||
title: Scene
|
||||
@@ -1032,7 +1081,7 @@ pages:
|
||||
transcripts
|
||||
performance-playground:
|
||||
title: Performance Playground
|
||||
description: 'VRM expressions + TTS lip sync playground'
|
||||
description: VRM expressions + TTS lip sync playground
|
||||
theme-presets:
|
||||
presets:
|
||||
default:
|
||||
|
||||
@@ -4,8 +4,7 @@ animations:
|
||||
stage-transitions:
|
||||
title: 是否开启舞台切换动画
|
||||
use-page-specific-transitions:
|
||||
description: >-
|
||||
某些页面会有自己的过场动画,这将覆盖舞台过场动画
|
||||
description: 某些页面会有自己的过场动画,这将覆盖舞台过场动画
|
||||
title: 是否使用页面特定过场动画
|
||||
dialogs:
|
||||
onboarding:
|
||||
@@ -22,7 +21,7 @@ dialogs:
|
||||
validationSuccess: 配置验证成功
|
||||
validationPartial: Configuration partially validated
|
||||
validationFailed: 配置验证失败
|
||||
validationError: '验证错误:{error}'
|
||||
validationError: 验证错误:{error}
|
||||
testGeneration: Ping API
|
||||
testGenerationRunning: Ping...
|
||||
testGenerationFailed: Ping test failed
|
||||
@@ -32,12 +31,11 @@ dialogs:
|
||||
next: 下一步
|
||||
retry: 重试
|
||||
credentialsSafeLabel: Keep your API keys and credentials safe!
|
||||
credentialsSafeLocal: >-
|
||||
AIRI完全本地运行在您的电脑中,我们绝不窃取您对大模型提供商的凭据。但请牢记,您的API密钥属于敏感信息。务必妥善保管,切勿与他人分享。
|
||||
credentialsSafeOpenSource: >-
|
||||
AIRI在{github}开源,如果您想确认我们如何存储您的凭据,您可以自由浏览源码。
|
||||
credentialsSafeLocal: AIRI完全本地运行在您的电脑中,我们绝不窃取您对大模型提供商的凭据。但请牢记,您的API密钥属于敏感信息。务必妥善保管,切勿与他人分享。
|
||||
credentialsSafeOpenSource: AIRI在{github}开源,如果您想确认我们如何存储您的凭据,您可以自由浏览源码。
|
||||
enableChatCheck: >-
|
||||
Allow airi to send a "ping" message to the first available chat model to check availability
|
||||
Allow airi to send a "ping" message to the first available chat model to
|
||||
check availability
|
||||
start: 开始吧!
|
||||
loginPrompt: 登录并使用AIRI官方提供商以获得最佳体验。
|
||||
loginAction: 登录
|
||||
@@ -46,8 +44,7 @@ dialogs:
|
||||
buyFlux: Flux充值
|
||||
select-model: 选择模型
|
||||
no-models: 找不到可用模型
|
||||
no-models-help: >-
|
||||
请返回上一步并检查您的 API Key,或检查网络连接。
|
||||
no-models-help: 请返回上一步并检查您的 API Key,或检查网络连接。
|
||||
permissions:
|
||||
title: 通知
|
||||
description: 您现在可以允许通知或者继续并稍后设置。
|
||||
@@ -63,7 +60,7 @@ dialogs:
|
||||
stateNotGranted: 未授权
|
||||
language:
|
||||
title: 语言
|
||||
description: >
|
||||
description: |
|
||||
界面语言。你也可以稍后再设置角色语言。
|
||||
controls-island:
|
||||
icon-size:
|
||||
@@ -121,7 +118,7 @@ live2d:
|
||||
title: 帧率
|
||||
description: 限制渲染帧率
|
||||
options:
|
||||
unlimited: '∞'
|
||||
unlimited: ∞
|
||||
microphone: 麦克风
|
||||
models: 模型
|
||||
pages:
|
||||
@@ -173,36 +170,32 @@ pages:
|
||||
scenario: 你最近醒来,忘记了之前的所有生活。
|
||||
systemprompt: 你将收到消息,请像真实人类一样回复。
|
||||
posthistoryinstructions: 记得模仿人类的行为。
|
||||
modules_info: >-
|
||||
为此角色卡配置模块。如未设置,将使用默认模块配置。
|
||||
modules_info: 为此角色卡配置模块。如未设置,将使用默认模块配置。
|
||||
use_default: 使用默认
|
||||
use_default_not_configured: 使用默认(未配置)
|
||||
fields_info:
|
||||
subtitle: >-
|
||||
您可以在这里填写有关您正在创建的角色的一些详细信息,解释他的背景和情境,以及应该如何回应您的互动。
|
||||
subtitle: 您可以在这里填写有关您正在创建的角色的一些详细信息,解释他的背景和情境,以及应该如何回应您的互动。
|
||||
name: 是该角色的正式名称。
|
||||
nickname: 您也可以提供一个昵称,它将被优先使用。
|
||||
description: 该角色的描述。
|
||||
notes: 如果您想添加一些个人备注。
|
||||
personality: >-
|
||||
在这里描述您的角色的个性。例如:害羞?好奇?其他?
|
||||
personality: 在这里描述您的角色的个性。例如:害羞?好奇?其他?
|
||||
scenario: 周围环境是怎样的?当前的情境是什么?
|
||||
greetings_field: 问候语
|
||||
greetings: 您的角色应该如何说“你好”?
|
||||
systemprompt: 在这里向 AI LLM 解释当被提示时应该如何回应。
|
||||
posthistoryinstructions: 在消息历史之后,放入 AI LLM 应该阅读的内容。
|
||||
version: >-
|
||||
卡片版本,如果您从之前的卡片做了更改,应当增加此版本号。
|
||||
version: 卡片版本,如果您从之前的卡片做了更改,应当增加此版本号。
|
||||
consciousness_model: 为这个角色选择 AI 模型。
|
||||
speech_model: 为此角色指定语音合成模型与音色。
|
||||
errors:
|
||||
name: 错误:你必须提供一个有效的名称!
|
||||
version: '错误:版本号无效!'
|
||||
description: '错误:你必须为此卡片提供描述。'
|
||||
personality: '错误:必须为该角色提供性格描述。'
|
||||
scenario: '错误:必须提供一个情境。'
|
||||
systemprompt: '错误:请提供系统提示。'
|
||||
posthistoryinstructions: '错误:必须提供消息历史后的提示。'
|
||||
version: 错误:版本号无效!
|
||||
description: 错误:你必须为此卡片提供描述。
|
||||
personality: 错误:必须为该角色提供性格描述。
|
||||
scenario: 错误:必须提供一个情境。
|
||||
systemprompt: 错误:请提供系统提示。
|
||||
posthistoryinstructions: 错误:必须提供消息历史后的提示。
|
||||
modules: 模块
|
||||
name_asc: 名称 (A-Z)
|
||||
name_desc: 名称 (Z-A)
|
||||
@@ -266,7 +259,7 @@ pages:
|
||||
reset: 重置桌面数据
|
||||
confirmations:
|
||||
tooltip: 您确定吗?
|
||||
'yes': '是'
|
||||
'yes': 是
|
||||
status:
|
||||
exported: 聊天会话已导出。
|
||||
imported: 聊天会话已导入。
|
||||
@@ -308,23 +301,19 @@ pages:
|
||||
title: 参数
|
||||
parameters:
|
||||
adaptive_threshold:
|
||||
description: >-
|
||||
是否根据信号随时间变化的方差应用自适应阈值
|
||||
description: 是否根据信号随时间变化的方差应用自适应阈值
|
||||
label: 自适应阈值
|
||||
buffer_duration:
|
||||
description: 内部分析缓冲区的持续时间
|
||||
label: 缓冲时长
|
||||
envelope_filter_frequency:
|
||||
description: >-
|
||||
应用于平滑能量变化的包络滤波器频率
|
||||
description: 应用于平滑能量变化的包络滤波器频率
|
||||
label: 包络滤波器频率
|
||||
highpass_filter_frequency:
|
||||
description: >-
|
||||
高通滤波器的频率设置,用于抑制低频噪声(如次低频噪声)
|
||||
description: 高通滤波器的频率设置,用于抑制低频噪声(如次低频噪声)
|
||||
label: 高通滤波器频率
|
||||
lowpass_filter_frequency:
|
||||
description: >-
|
||||
低通滤波器的频率设置,用于削减高频信号(如人声)
|
||||
description: 低通滤波器的频率设置,用于削减高频信号(如人声)
|
||||
label: 低通滤波器频率
|
||||
min_beat_interval:
|
||||
description: 最大每分钟节拍数(BPM)或检测到的节拍之间最小间隔
|
||||
@@ -344,7 +333,7 @@ pages:
|
||||
provider-model-selection:
|
||||
collapse: 折叠
|
||||
custom_model_placeholder: 输入自订模型名称
|
||||
current_model_label: '当前模型:'
|
||||
current_model_label: 当前模型:
|
||||
description: 为意识选择合适的 LLM 服务来源
|
||||
error: 获取出错啦
|
||||
health_check_failed: 健康检查失败
|
||||
@@ -386,14 +375,21 @@ pages:
|
||||
description: 一起玩 Minecraft!
|
||||
title: 我的世界 Minecraft
|
||||
enable: 启用我的世界集成
|
||||
enable-description: Include Minecraft self-knowledge in Stage chat turns and keep passive runtime visibility enabled.
|
||||
enable-description: >-
|
||||
Include Minecraft self-knowledge in Stage chat turns and keep passive
|
||||
runtime visibility enabled.
|
||||
configured: 我的世界已正确配置!
|
||||
read-only:
|
||||
title: Read-only shell
|
||||
description: Stage currently exposes Minecraft as a passive shell. Runtime status and traffic are visible here, but service configuration stays in the Minecraft service files.
|
||||
description: >-
|
||||
Stage currently exposes Minecraft as a passive shell. Runtime status
|
||||
and traffic are visible here, but service configuration stays in the
|
||||
Minecraft service files.
|
||||
setup:
|
||||
title: Setup
|
||||
description: You can find the instructions for setting up the bot in the following file
|
||||
description: >-
|
||||
You can find the instructions for setting up the bot in the following
|
||||
file
|
||||
runtime:
|
||||
title: Observed runtime
|
||||
connection: Connection
|
||||
@@ -407,7 +403,7 @@ pages:
|
||||
status:
|
||||
service-online: Service online
|
||||
service-offline: Service offline
|
||||
no-runtime-context: 'No bot context observed yet'
|
||||
no-runtime-context: No bot context observed yet
|
||||
last-context-seconds: 'Last bot context: {seconds}s ago'
|
||||
last-context-stale: 'Last bot context: Stale'
|
||||
hearing:
|
||||
@@ -419,12 +415,18 @@ pages:
|
||||
description: 选择合适的语音转文本的服务来源
|
||||
confidence-threshold:
|
||||
title: Confidence Threshold
|
||||
description: Filter out low-confidence transcriptions to reduce Whisper hallucinations. Values closer to 0 are more strict; drag to the leftmost to disable. Only effective for providers supporting Whisper API (e.g., OpenAI, Groq).
|
||||
description: >-
|
||||
Filter out low-confidence transcriptions to reduce Whisper
|
||||
hallucinations. Values closer to 0 are more strict; drag to the
|
||||
leftmost to disable. Only effective for providers supporting
|
||||
Whisper API (e.g., OpenAI, Groq).
|
||||
disabled: 禁用
|
||||
verbose-json-note: >-
|
||||
Note: If your provider does not support verbose_json responses, this setting will have no effect.
|
||||
Note: If your provider does not support verbose_json responses,
|
||||
this setting will have no effect.
|
||||
verbose-json-unsupported: >-
|
||||
Your provider did not return verbose_json segments. Confidence filtering had no effect on the last transcription.
|
||||
Your provider did not return verbose_json segments. Confidence
|
||||
filtering had no effect on the last transcription.
|
||||
memory-long-term:
|
||||
description: 长期记忆
|
||||
title: 长期记忆
|
||||
@@ -468,7 +470,8 @@ pages:
|
||||
search_voices_results: 找到 {count} / {total} 个声线
|
||||
unsupported_voice_warning_title: 没有支持的声线
|
||||
unsupported_voice_warning_content: >-
|
||||
我们正在尽快支持该模型的所有音色,如果你迫切希望支持该模型音色,请在 GitHub 上联系我们 https://github.com/moeru-ai/airi/issues
|
||||
我们正在尽快支持该模型的所有音色,如果你迫切希望支持该模型音色,请在 GitHub 上联系我们
|
||||
https://github.com/moeru-ai/airi/issues
|
||||
show_less: 显示更少
|
||||
show_more: 显示更多
|
||||
title: 选择语音合成服务来源
|
||||
@@ -542,11 +545,10 @@ pages:
|
||||
explained:
|
||||
chat: 文本生成模型服务来源,例如 OpenRouter, OpenAI, Ollama
|
||||
Speech: 语音(文本转语音)模型服务来源,例如 ElevenLabs, Azure Speech
|
||||
Transcription: >-
|
||||
转录(语音转文本)模型服务来源,例如 Whisper.cpp, OpenAI, Azure Speech
|
||||
Transcription: 转录(语音转文本)模型服务来源,例如 Whisper.cpp, OpenAI, Azure Speech
|
||||
helpinfo:
|
||||
title: 第一次使用?
|
||||
description: >
|
||||
description: |
|
||||
AIRI 需要配置至少一个 {chat} 服务来源,才能正常思考和运作。你可以把它看作是 AIRI 系统中角色的大脑。
|
||||
catalog:
|
||||
edit:
|
||||
@@ -558,8 +560,7 @@ pages:
|
||||
validation:
|
||||
failed:
|
||||
title: 请检查您的服务来源设置,验证失败。
|
||||
description: >-
|
||||
如果你想要继续,你仍然可以保存当前的配置。
|
||||
description: 如果你想要继续,你仍然可以保存当前的配置。
|
||||
action: 仍要保存
|
||||
config:
|
||||
common:
|
||||
@@ -572,7 +573,8 @@ pages:
|
||||
base-url:
|
||||
label: Base URL
|
||||
description: >-
|
||||
用于连接远程服务的 baseUrl。通常看起来像是 http://localhost:11434/v1 或 https://example.com/v1。
|
||||
用于连接远程服务的 baseUrl。通常看起来像是 http://localhost:11434/v1 或
|
||||
https://example.com/v1。
|
||||
placeholder: 基础 Url……
|
||||
headers:
|
||||
label: 添加自定义 HTTP 标头
|
||||
@@ -583,7 +585,7 @@ pages:
|
||||
placeholder: 请求头数值
|
||||
thinking-mode:
|
||||
label: 深度思考模式
|
||||
description: >
|
||||
description: |
|
||||
控制 Olama 深度思考的开关。GPT-OSS 只支持低/中/高水平。
|
||||
options:
|
||||
auto: 自动(默认服务来源)
|
||||
@@ -618,10 +620,14 @@ pages:
|
||||
openai-compatible-check-connectivity:
|
||||
label: Ollama需要额外的配置
|
||||
content: >-
|
||||
很抱歉,Ollama 连接检查失败。不要担心,如果是 Ollama,需要额外配置才能通过 Ollama。 您需要在启动 Olama 应用程序或 Ollama CLI 服务器时明确设置 <code>OLLAMA_HOST</code> 详情,请检查 <a href="https://airi.moeru.ai/docs/en/docs/manual/integrations/providers/chat/ollama">Ollama 集成指南</a>。
|
||||
很抱歉,Ollama 连接检查失败。不要担心,如果是 Ollama,需要额外配置才能通过 Ollama。
|
||||
您需要在启动 Olama 应用程序或 Ollama CLI 服务器时明确设置
|
||||
<code>OLLAMA_HOST</code> 详情,请检查 <a
|
||||
href="https://airi.moeru.ai/docs/en/docs/manual/integrations/providers/chat/ollama">Ollama
|
||||
集成指南</a>。
|
||||
common:
|
||||
continueAnyway: 仍然继续
|
||||
goToModelSelection: '选择模型→'
|
||||
goToModelSelection: 选择模型→
|
||||
fields:
|
||||
field:
|
||||
api-key:
|
||||
@@ -666,14 +672,11 @@ pages:
|
||||
helpinfo:
|
||||
title: 开始之前
|
||||
description:
|
||||
part1: >-
|
||||
虽然 Anthropic 最近宣布他们正在提供对 OpenAI SDK 兼容性的 Beta 版支持
|
||||
part2: '(您可以在此处阅读更多信息)'
|
||||
part1: 虽然 Anthropic 最近宣布他们正在提供对 OpenAI SDK 兼容性的 Beta 版支持
|
||||
part2: (您可以在此处阅读更多信息)
|
||||
part3: 但由于实现细节附带
|
||||
part4: >-
|
||||
与 OpenAI SDK 不一致,目前无法在浏览器中使用此提供程序。
|
||||
part5: >-
|
||||
如果您确实需要使用此提供程序,则需要一个专用的代理后端,例如在
|
||||
part4: 与 OpenAI SDK 不一致,目前无法在浏览器中使用此提供程序。
|
||||
part5: 如果您确实需要使用此提供程序,则需要一个专用的代理后端,例如在
|
||||
part6: 上运行的Serverless Function,或者一些绕过 CORS 的服务,以绕过 CORS 限制。
|
||||
cloudflare-workers-ai:
|
||||
description: Cloudflare.com
|
||||
@@ -758,8 +761,7 @@ pages:
|
||||
description: 较小的模型加载更快,但质量可能略有下降。
|
||||
models:
|
||||
fp32-webgpu:
|
||||
description: >-
|
||||
基于 WebGPU 的全精度模型 - 建议在支持的设备上使用
|
||||
description: 基于 WebGPU 的全精度模型 - 建议在支持的设备上使用
|
||||
fp32:
|
||||
description: 全精度模型
|
||||
fp16:
|
||||
@@ -911,17 +913,44 @@ pages:
|
||||
placeholder: ws://localhost:6121/ws
|
||||
server-hostname:
|
||||
label: Expose On Network
|
||||
description: Choose whether the AIRI gateway only listens on this device or can be reached from your local network.
|
||||
description: >-
|
||||
Choose whether the AIRI gateway only listens on this device or can be
|
||||
reached from your local network.
|
||||
advanced-label: Bind Hostname
|
||||
advanced-description: Use a specific hostname or IP address when you need manual network binding.
|
||||
advanced-description: >-
|
||||
Use a specific hostname or IP address when you need manual network
|
||||
binding.
|
||||
options:
|
||||
this-device: This device
|
||||
all: All
|
||||
advanced: Advanced
|
||||
server-auth-token:
|
||||
label: Auth Token
|
||||
description: Security token required by remote clients that connect to this gateway. When left empty, AIRI generates a new token automatically.
|
||||
description: >-
|
||||
Security token required by remote clients that connect to this gateway.
|
||||
When left empty, AIRI generates a new token automatically.
|
||||
placeholder: Auto-generate
|
||||
qr:
|
||||
title: 显示二维码
|
||||
description: 口袋 AIRI 可通过二维码快速连接到本机
|
||||
refresh: 刷新
|
||||
image-alt: AIRI 主脑连接二维码
|
||||
candidates: 候选地址
|
||||
token-hint: 二维码包含认证 token,用于让 Stage Pocket 连接后完成认证。不要公开分享。
|
||||
errors:
|
||||
title: 无法生成二维码
|
||||
unavailable: 无法准备连接二维码。
|
||||
qr-scan:
|
||||
title: 快速连接
|
||||
description: 扫描 Tamagotchi 上的二维码来快速连接主脑
|
||||
instructions: 将摄像头对准 AIRI 连接二维码。
|
||||
action: 扫描
|
||||
success:
|
||||
title: 连接成功!
|
||||
description: 使用的连接地址:{url}
|
||||
errors:
|
||||
title: 扫码失败
|
||||
failed: 无法通过此二维码扫码或连接。
|
||||
scene:
|
||||
description: 配置角色所在环境
|
||||
title: 场景
|
||||
@@ -981,8 +1010,7 @@ pages:
|
||||
title: 拍立得
|
||||
description: 拍摄模型照片的工具
|
||||
context-flow:
|
||||
description: >-
|
||||
检查传入的上下文更新和传出的聊天流事件
|
||||
description: 检查传入的上下文更新和传出的聊天流事件
|
||||
websocket-inspector:
|
||||
title: WebSocket 检查器
|
||||
description: 检查原始 WebSocket 流量
|
||||
@@ -991,11 +1019,10 @@ pages:
|
||||
description: 测试颜色提取
|
||||
providers-transcription-realtime-aliyun-nls:
|
||||
title: 阿里云实时转写
|
||||
description: >-
|
||||
将麦克风音频流式传输到阿里云 NLS 并检查实时转写
|
||||
description: 将麦克风音频流式传输到阿里云 NLS 并检查实时转写
|
||||
performance-playground:
|
||||
title: 性能测试场地
|
||||
description: 'VRM 表情 + TTS lip sync 实验平台'
|
||||
description: VRM 表情 + TTS lip sync 实验平台
|
||||
theme-presets:
|
||||
presets:
|
||||
default:
|
||||
@@ -1061,8 +1088,7 @@ pages:
|
||||
color-6: 金黄色
|
||||
color-7: 天蓝色
|
||||
color-8: 赭黄色
|
||||
description: >-
|
||||
中国传统色彩,源自古代纺织品、瓷器和绘画
|
||||
description: 中国传统色彩,源自古代纺织品、瓷器和绘画
|
||||
title: 中国传统颜色
|
||||
title: 预设
|
||||
title: 系统
|
||||
@@ -1127,9 +1153,7 @@ vrm:
|
||||
skybox-specular-mix: 漫反射/镜面反射混合系数
|
||||
websocket-secure-enabled:
|
||||
title: 启用安全WebSocket (WSS)
|
||||
description: >-
|
||||
启用 WSS (WebSocket Secure) 以便在开发模式下允许移动设备(Pocket) 连接HTTPS 。这将自动生成一个自签名证书。
|
||||
description: 启用 WSS (WebSocket Secure) 以便在开发模式下允许移动设备(Pocket) 连接HTTPS 。这将自动生成一个自签名证书。
|
||||
wip:
|
||||
title: 正在开发中
|
||||
description: >-
|
||||
此功能正在开发中,尚未公开发布。请在未来的更新中再次查看此功能。
|
||||
description: 此功能正在开发中,尚未公开发布。请在未来的更新中再次查看此功能。
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
".": "./src/index.ts",
|
||||
"./auth": "./src/auth/index.ts",
|
||||
"./beat-sync": "./src/beat-sync/index.ts",
|
||||
"./server-channel-qr": "./src/server-channel-qr.ts",
|
||||
"./electron-renderer": "./src/electron-renderer.d.ts",
|
||||
"./composables": "./src/composables/index.ts"
|
||||
},
|
||||
@@ -27,6 +28,7 @@
|
||||
"dependencies": {
|
||||
"@vueuse/core": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"valibot": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { InferInput, InferOutput } from 'valibot'
|
||||
|
||||
import { array, check, literal, minLength, object, parse, pipe, string, transform } from 'valibot'
|
||||
|
||||
export const SERVER_CHANNEL_QR_PAYLOAD_TYPE = 'airi:server-channel'
|
||||
export const SERVER_CHANNEL_QR_PAYLOAD_VERSION = 1
|
||||
|
||||
function isWebSocketUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return (url.protocol === 'ws:' || url.protocol === 'wss:') && !!url.hostname
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWebSocketUrl(value: string) {
|
||||
return new URL(value).toString()
|
||||
}
|
||||
|
||||
export const ServerChannelQrUrlSchema = pipe(
|
||||
string(),
|
||||
check(isWebSocketUrl, 'Expected a ws or wss URL.'),
|
||||
transform(normalizeWebSocketUrl),
|
||||
)
|
||||
|
||||
export const ServerChannelQrPayloadSchema = object({
|
||||
type: literal(SERVER_CHANNEL_QR_PAYLOAD_TYPE),
|
||||
version: literal(SERVER_CHANNEL_QR_PAYLOAD_VERSION),
|
||||
urls: pipe(array(ServerChannelQrUrlSchema), minLength(1)),
|
||||
authToken: string(),
|
||||
})
|
||||
|
||||
export type ServerChannelQrPayloadInput = InferInput<typeof ServerChannelQrPayloadSchema>
|
||||
export type ServerChannelQrPayload = InferOutput<typeof ServerChannelQrPayloadSchema>
|
||||
|
||||
export function createServerChannelQrPayload(payload: ServerChannelQrPayloadInput) {
|
||||
return parse(ServerChannelQrPayloadSchema, payload)
|
||||
}
|
||||
|
||||
export function parseServerChannelQrPayload(raw: string) {
|
||||
return parse(ServerChannelQrPayloadSchema, JSON.parse(raw))
|
||||
}
|
||||
@@ -369,4 +369,34 @@ describe('channel-server store reconnect', () => {
|
||||
|
||||
expect(serverSdkMocks.MockClient.instances).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses the persisted websocket auth token when initialize does not receive an explicit token', async () => {
|
||||
const store = useModsServerChannelStore()
|
||||
store.websocketAuthToken = 'persisted-secret'
|
||||
|
||||
const initializePromise = store.initialize()
|
||||
const client = serverSdkMocks.MockClient.instances[0]
|
||||
|
||||
expect(client.options.token).toBe('persisted-secret')
|
||||
|
||||
client.simulateAuthenticated()
|
||||
await initializePromise
|
||||
})
|
||||
|
||||
it('reconnects when the persisted websocket auth token changes', async () => {
|
||||
const store = useModsServerChannelStore()
|
||||
store.websocketAuthToken = 'initial-secret'
|
||||
|
||||
const initializePromise = store.initialize()
|
||||
const firstClient = serverSdkMocks.MockClient.instances[0]
|
||||
|
||||
firstClient.simulateAuthenticated()
|
||||
await initializePromise
|
||||
|
||||
store.websocketAuthToken = 'rotated-secret'
|
||||
await nextTick()
|
||||
|
||||
expect(serverSdkMocks.MockClient.instances.length).toBeGreaterThan(1)
|
||||
expect(serverSdkMocks.MockClient.instances.at(-1)?.options.token).toBe('rotated-secret')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,6 +59,7 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
|
||||
|
||||
const defaultWebSocketUrl = import.meta.env.VITE_AIRI_WS_URL || 'ws://localhost:6121/ws'
|
||||
const websocketUrl = useLocalStorage('settings/connection/websocket-url', defaultWebSocketUrl)
|
||||
const websocketAuthToken = useLocalStorage('settings/connection/websocket-auth-token', '')
|
||||
const registeredListeners: ChannelListenerEntry[] = []
|
||||
const replayableEvents = new Map<keyof WebSocketEvents, WebSocketBaseEvent<any, any>>()
|
||||
|
||||
@@ -109,7 +110,7 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
|
||||
client.value = new Client({
|
||||
name: isStageWeb() ? WebSocketEventSource.StageWeb : isStageTamagotchi() ? WebSocketEventSource.StageTamagotchi : WebSocketEventSource.StageWeb,
|
||||
url: websocketUrl.value || defaultWebSocketUrl,
|
||||
token: options?.token,
|
||||
token: options?.token ?? (websocketAuthToken.value || undefined),
|
||||
websocketConstructor: websocketConstructor.value,
|
||||
heartbeat: {
|
||||
// Keep client and server heartbeat windows aligned to reduce false-positive disconnects.
|
||||
@@ -320,8 +321,8 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
|
||||
}
|
||||
}
|
||||
|
||||
watch(websocketUrl, (newUrl, oldUrl) => {
|
||||
if (newUrl === oldUrl)
|
||||
watch([websocketUrl, websocketAuthToken], ([newUrl, newToken], [oldUrl, oldToken]) => {
|
||||
if (newUrl === oldUrl && newToken === oldToken)
|
||||
return
|
||||
|
||||
if (!hasReconnectableWebSocketScheme(newUrl))
|
||||
@@ -336,6 +337,7 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
|
||||
return {
|
||||
connected,
|
||||
pendingSendCount,
|
||||
websocketAuthToken,
|
||||
websocketUrl,
|
||||
ensureConnected,
|
||||
|
||||
|
||||
Generated
+35
@@ -12,6 +12,9 @@ catalogs:
|
||||
'@capacitor/android':
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
'@capacitor/barcode-scanner':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
'@capacitor/cli':
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
@@ -279,6 +282,9 @@ catalogs:
|
||||
unstorage:
|
||||
specifier: ^1.17.4
|
||||
version: 1.17.4
|
||||
uqr:
|
||||
specifier: ^0.1.3
|
||||
version: 0.1.3
|
||||
valibot:
|
||||
specifier: 1.2.0
|
||||
version: 1.2.0
|
||||
@@ -684,6 +690,9 @@ importers:
|
||||
'@capacitor/android':
|
||||
specifier: 'catalog:'
|
||||
version: 8.2.0(@capacitor/core@8.2.0)
|
||||
'@capacitor/barcode-scanner':
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.2(@capacitor/core@8.2.0)
|
||||
'@capacitor/core':
|
||||
specifier: 'catalog:'
|
||||
version: 8.2.0
|
||||
@@ -1315,6 +1324,9 @@ importers:
|
||||
unspeech:
|
||||
specifier: catalog:xsai
|
||||
version: 0.1.11
|
||||
uqr:
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.3
|
||||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
@@ -2881,6 +2893,9 @@ importers:
|
||||
pinia:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
|
||||
valibot:
|
||||
specifier: 'catalog:'
|
||||
version: 1.2.0(typescript@5.9.3)
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.30(typescript@5.9.3)
|
||||
@@ -4917,6 +4932,11 @@ packages:
|
||||
peerDependencies:
|
||||
'@capacitor/core': ^8.2.0
|
||||
|
||||
'@capacitor/barcode-scanner@3.0.2':
|
||||
resolution: {integrity: sha512-eD8G0dj/vDTytzFY3qK/5EWZtv17NHMN97gx5KSHUuqFXOScf+5e4Rwwak8zDkX17VLXNym6y3eIn8AvCLY+Pg==}
|
||||
peerDependencies:
|
||||
'@capacitor/core': '>=8.0.0'
|
||||
|
||||
'@capacitor/cli@8.2.0':
|
||||
resolution: {integrity: sha512-1cMEk0d/I6tl1U+v/lnJR5Oylpx8ZBIHrvQxD5zK0MkjYOUyQAAGJgh97rkhGJqjAUvrGpa8H4BmyhNQN9a17A==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
@@ -13281,6 +13301,9 @@ packages:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
html5-qrcode@2.3.8:
|
||||
resolution: {integrity: sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==}
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
|
||||
|
||||
@@ -17053,6 +17076,9 @@ packages:
|
||||
resolution: {integrity: sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
uqr@0.1.3:
|
||||
resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
@@ -19008,6 +19034,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@capacitor/core': 8.2.0
|
||||
|
||||
'@capacitor/barcode-scanner@3.0.2(@capacitor/core@8.2.0)':
|
||||
dependencies:
|
||||
'@capacitor/core': 8.2.0
|
||||
html5-qrcode: 2.3.8
|
||||
|
||||
'@capacitor/cli@8.2.0':
|
||||
dependencies:
|
||||
'@ionic/cli-framework-output': 2.2.8
|
||||
@@ -27984,6 +28015,8 @@ snapshots:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
|
||||
html5-qrcode@2.3.8: {}
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
@@ -32535,6 +32568,8 @@ snapshots:
|
||||
semver: 7.7.4
|
||||
xdg-basedir: 5.1.0
|
||||
|
||||
uqr@0.1.3: {}
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
@@ -29,6 +29,7 @@ patchedDependencies:
|
||||
catalog:
|
||||
'@better-auth/oauth-provider': 1.5.6
|
||||
'@capacitor/android': ^8.2.0
|
||||
'@capacitor/barcode-scanner': ^3.0.2
|
||||
'@capacitor/cli': ^8.2.0
|
||||
'@capacitor/core': ^8.2.0
|
||||
'@capacitor/ios': ^8.2.0
|
||||
@@ -118,6 +119,7 @@ catalog:
|
||||
uncrypto: ^0.1.3
|
||||
unplugin-info: ^1.2.4
|
||||
unstorage: ^1.17.4
|
||||
uqr: ^0.1.3
|
||||
valibot: 1.2.0
|
||||
vite: ^8.0.2
|
||||
vite-plugin-inspect: ^11.3.3
|
||||
|
||||
Reference in New Issue
Block a user