mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-30 19:34:10 +08:00
feat(stage-pages,stage-ui): search settings, their rows, and runtime items (#3112)
This commit is contained in:
@@ -23,6 +23,7 @@ import VueRouter from 'vue-router/vite'
|
||||
import { tryCatch } from '@moeru/std'
|
||||
import { localeRemap, serializeStartupFallbackLocales } from '@proj-airi/i18n'
|
||||
import { paraformerBilingualZhEn, xAsrBilingualZhEnInt8, zipformerMultilingual } from '@proj-airi/provider-inference/sherpaw-transcription/models'
|
||||
import { createSettingsSearch } from '@proj-airi/stage-pages/vite/settings-search'
|
||||
import { speakerModel } from '@proj-airi/stage-shared/speaker-model'
|
||||
import { kwsModel } from '@proj-airi/stage-ui/libs/voice/kws-model-info'
|
||||
import { Download } from '@proj-airi/unplugin-fetch/vite'
|
||||
@@ -40,6 +41,7 @@ function isEnvTruthy(value: string | undefined | null): boolean {
|
||||
|
||||
const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets'))
|
||||
const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache'))
|
||||
const settingsSearch = createSettingsSearch()
|
||||
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
@@ -135,6 +137,8 @@ export default defineConfig({
|
||||
|
||||
Yaml(),
|
||||
|
||||
settingsSearch.plugin,
|
||||
|
||||
VueMacros({
|
||||
plugins: {
|
||||
vue: Vue({
|
||||
@@ -162,6 +166,7 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
exclude: ['**/components/**'],
|
||||
extendRoute: settingsSearch.extendRoute,
|
||||
}),
|
||||
|
||||
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
|
||||
|
||||
@@ -15,6 +15,7 @@ import VueMacros from 'vue-macros/vite'
|
||||
import VueRouter from 'vue-router/vite'
|
||||
|
||||
import { paraformerBilingualZhEn, xAsrBilingualZhEnInt8, zipformerMultilingual } from '@proj-airi/provider-inference/sherpaw-transcription/models'
|
||||
import { createSettingsSearch } from '@proj-airi/stage-pages/vite/settings-search'
|
||||
import { speakerModel } from '@proj-airi/stage-shared/speaker-model'
|
||||
import { kwsModel } from '@proj-airi/stage-ui/libs/voice/kws-model-info'
|
||||
import { Download } from '@proj-airi/unplugin-fetch'
|
||||
@@ -24,6 +25,7 @@ import { defineConfig } from 'electron-vite'
|
||||
|
||||
const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets'))
|
||||
const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache'))
|
||||
const settingsSearch = createSettingsSearch()
|
||||
const sherpawModels = [paraformerBilingualZhEn, zipformerMultilingual, xAsrBilingualZhEnInt8]
|
||||
/** Main downloads remote Sherpaw models. It must use the endpoint that the renderer plugin uses. */
|
||||
const sherpawModelEndpoint = resolveModelEndpoint()
|
||||
@@ -240,6 +242,8 @@ export default defineConfig({
|
||||
|
||||
Yaml(),
|
||||
|
||||
settingsSearch.plugin,
|
||||
|
||||
VueMacros({
|
||||
plugins: {
|
||||
vue: Vue({
|
||||
@@ -272,6 +276,7 @@ export default defineConfig({
|
||||
resolve(import.meta.dirname, 'src', 'renderer', 'pages'),
|
||||
],
|
||||
exclude: ['**/components/**'],
|
||||
extendRoute: settingsSearch.extendRoute,
|
||||
}),
|
||||
|
||||
VitePluginVueDevTools(),
|
||||
|
||||
+2
@@ -98,6 +98,7 @@ watch(appliedConfig, () => {
|
||||
<Collapsible :default="false">
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
:aria-expanded="slotProps.visible"
|
||||
:class="[
|
||||
'w-full flex items-center justify-between gap-3 rounded-xl text-left outline-none transition-all duration-250 ease-in-out',
|
||||
]"
|
||||
@@ -168,6 +169,7 @@ watch(appliedConfig, () => {
|
||||
<Collapsible :default="false">
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
:aria-expanded="slotProps.visible"
|
||||
:class="[
|
||||
'w-full flex items-center justify-between gap-3 rounded-xl text-left outline-none transition-all duration-250 ease-in-out',
|
||||
]"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// The desktop data page shows the same downloaded models section as the shared data page.
|
||||
export { default } from '@proj-airi/stage-pages/pages/settings/data/index.search'
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { IconItem } from '@proj-airi/stage-ui/components'
|
||||
import { computed } from 'vue'
|
||||
import { settingsSearchProviders } from '@proj-airi/stage-pages/settings-search-providers'
|
||||
import { IconItem, SettingsSearchField, SettingsSearchResults } from '@proj-airi/stage-ui/components'
|
||||
import { useSettingsSearch } from '@proj-airi/stage-ui/composables/use-settings-search'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -19,12 +21,27 @@ const settings = computed(() => {
|
||||
to: route.path,
|
||||
}))
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const searchResults = useSettingsSearch(searchQuery, settingsSearchProviders)
|
||||
const searching = computed(() => searchQuery.value.trim() !== '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['flex flex-col gap-4', 'font-normal']">
|
||||
<div />
|
||||
<div :class="['flex flex-col gap-4', 'pb-12']">
|
||||
<!-- The search stays at the top while the list scrolls under it, as the page header does above it. -->
|
||||
<!-- It is the first element, so it rests where it sticks and does not move when the list starts to scroll. -->
|
||||
<div :class="['sticky top-0 z-10', 'bg-$bg-color', 'pb-2']">
|
||||
<SettingsSearchField v-model="searchQuery" />
|
||||
</div>
|
||||
<!-- Results appear at once while the user types, so they skip the enter animation of the categories. -->
|
||||
<div v-if="searching" :class="['pb-12']">
|
||||
<p v-if="!searchResults.length" :class="['text-sm text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.search.empty', { query: searchQuery.trim() }) }}
|
||||
</p>
|
||||
<SettingsSearchResults v-else :results="searchResults" :query="searchQuery" />
|
||||
</div>
|
||||
<div v-else :class="['flex flex-col gap-4', 'pb-12']">
|
||||
<IconItem
|
||||
v-for="(setting, index) in settings"
|
||||
:key="setting.to"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// The desktop models page shows the same model picker as the shared models page.
|
||||
export { default } from '@proj-airi/stage-pages/pages/settings/models/index.search'
|
||||
+9
@@ -7,6 +7,7 @@ import { render } from 'vitest-browser-vue'
|
||||
import { page } from 'vitest/browser'
|
||||
import { isProxy } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import HomeAssistantSettings from './home-assistant.vue'
|
||||
|
||||
@@ -63,10 +64,18 @@ function modeControl(mode: 'allow' | 'deny') {
|
||||
async function renderPage() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
// The page reads the settings search target from the route, as it does in the app.
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/settings/modules/home-assistant', component: { template: '<div />' } }],
|
||||
})
|
||||
await router.push('/settings/modules/home-assistant')
|
||||
await router.isReady()
|
||||
const screen = await render(HomeAssistantSettings, {
|
||||
global: {
|
||||
plugins: [
|
||||
pinia,
|
||||
router,
|
||||
createI18n({ legacy: false, locale: 'en', messages: { en: {} }, missingWarn: false, fallbackWarn: false }),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useHomeAssistantStore } from '@proj-airi/stage-ui/stores/modules/home-assistant'
|
||||
|
||||
import { homeAssistantGetConfig } from '../../../../shared/eventa/home-assistant'
|
||||
import { useTamagotchiHomeAssistantStore } from '../../../stores/tools/home-assistant'
|
||||
|
||||
const ENTITY_KIND = 'home-assistant-entity'
|
||||
|
||||
/** The marker of a device's tile. The page and the provider share it. */
|
||||
export function homeAssistantEntitySearchKey(entityId: string) {
|
||||
return settingsSearchItemKey(ENTITY_KIND, entityId)
|
||||
}
|
||||
|
||||
/** The device that a search result names, or `undefined` for any other setting. */
|
||||
export function homeAssistantEntityOfSearchKey(key: unknown) {
|
||||
const prefix = `${ENTITY_KIND}:`
|
||||
return typeof key === 'string' && key.startsWith(prefix) ? decodeURIComponent(key.slice(prefix.length)) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the devices of the instance by name.
|
||||
* The page reads the device list only while it shows the device grid, so the provider reads it under the same condition.
|
||||
*/
|
||||
const provideEntities: SettingsSearchProvider = async () => {
|
||||
const config = await useElectronEventaInvoke(homeAssistantGetConfig)()
|
||||
if (useHomeAssistantStore().exposureMode === 'all' || !config.baseUrl || !config.hasToken)
|
||||
return []
|
||||
const entities = await useTamagotchiHomeAssistantStore().listEntities()
|
||||
return entities.map(entity => ({ title: entity.name, key: homeAssistantEntitySearchKey(entity.entityId) }))
|
||||
}
|
||||
|
||||
export default provideEntities
|
||||
@@ -6,6 +6,7 @@ import { errorMessageFrom } from '@moeru/std'
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { HomeAssistantEntityTile } from '@proj-airi/stage-ui/components/scenarios/home-assistant'
|
||||
import { useHomeAssistantStateLabel } from '@proj-airi/stage-ui/composables/use-home-assistant-state-label'
|
||||
import { useSettingsSearchTarget } from '@proj-airi/stage-ui/composables/use-settings-search'
|
||||
import { domainLabel, summarizeDomains, summarizeEntities } from '@proj-airi/stage-ui/libs/home-assistant/presentation'
|
||||
import { useHomeAssistantStore } from '@proj-airi/stage-ui/stores/modules/home-assistant'
|
||||
import { Button, FieldCheckbox, FieldInput, GhostButton, Input, Radio, ScrollableArea, SettingsCard } from '@proj-airi/ui'
|
||||
@@ -15,6 +16,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { homeAssistantConfigRejections, homeAssistantGetConfig, homeAssistantSetConfig } from '../../../../shared/eventa/home-assistant'
|
||||
import { useTamagotchiHomeAssistantStore } from '../../../stores/tools/home-assistant'
|
||||
import { homeAssistantEntityOfSearchKey, homeAssistantEntitySearchKey } from './home-assistant.search'
|
||||
|
||||
const { t } = useI18n()
|
||||
/**
|
||||
@@ -118,6 +120,16 @@ const visibleEntities = computed(() => {
|
||||
|| entity.entityId.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
// A search result names one device. The grid mounts only a batch of the rows, so it filters to that device first.
|
||||
const searchTarget = useSettingsSearchTarget()
|
||||
watch(() => searchTarget.value.keys, (keys) => {
|
||||
const entityId = keys.map(homeAssistantEntityOfSearchKey).find(id => id !== undefined)
|
||||
if (!entityId)
|
||||
return
|
||||
activeDomain.value = null
|
||||
entityQuery.value = entityId
|
||||
}, { immediate: true })
|
||||
|
||||
const allVisibleSelected = computed(() =>
|
||||
visibleEntities.value.length > 0 && visibleEntities.value.every(entity => selectedIds.value.has(entity.entityId)))
|
||||
|
||||
@@ -666,6 +678,7 @@ watch(exposureMode, async (mode) => {
|
||||
<HomeAssistantEntityTile
|
||||
v-for="entity in mountedEntities"
|
||||
:key="entity.entityId"
|
||||
:data-settings-search="homeAssistantEntitySearchKey(entity.entityId)"
|
||||
:entity="entity"
|
||||
:selected="selectedIds.has(entity.entityId)"
|
||||
:state-label="stateLabel(entity.domain, entity.state)"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { electronMcpReadConfigText } from '../../../../shared/eventa'
|
||||
import { parseElectronMcpConfigText } from '../../../../shared/mcp-config'
|
||||
|
||||
/** The marker of a saved server's row. The page and the provider share it. */
|
||||
export function mcpServerSearchKey(identifier: string) {
|
||||
return settingsSearchItemKey('mcp-server', identifier)
|
||||
}
|
||||
|
||||
/** Lists the saved MCP servers by name, from the config file that the page edits. */
|
||||
const provideMcpServers: SettingsSearchProvider = async () => {
|
||||
const { text } = await useElectronEventaInvoke(electronMcpReadConfigText)()
|
||||
return Object.keys(parseElectronMcpConfigText(text).mcpServers)
|
||||
.map(identifier => ({ title: identifier, key: mcpServerSearchKey(identifier) }))
|
||||
}
|
||||
|
||||
export default provideMcpServers
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
syncJsonDraftFromServers,
|
||||
validateServerForm,
|
||||
} from './mcp-config'
|
||||
import { mcpServerSearchKey } from './mcp.search'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { trackMcpServerRemoved, trackMcpConnectionTestRun } = useAnalytics()
|
||||
@@ -399,6 +400,7 @@ onMounted(async () => {
|
||||
<article
|
||||
v-for="server in savedServers"
|
||||
:key="server.rowId"
|
||||
:data-settings-search="mcpServerSearchKey(server.identifier)"
|
||||
:class="server.enabled ? CARD_PRIMARY : CARD_MUTED"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -20,6 +20,7 @@ import VueRouter from 'vue-router/vite'
|
||||
import { tryCatch } from '@moeru/std'
|
||||
import { localeRemap, serializeStartupFallbackLocales } from '@proj-airi/i18n'
|
||||
import { paraformerBilingualZhEn, xAsrBilingualZhEnInt8, zipformerMultilingual } from '@proj-airi/provider-inference/sherpaw-transcription/models'
|
||||
import { createSettingsSearch } from '@proj-airi/stage-pages/vite/settings-search'
|
||||
import { speakerModel } from '@proj-airi/stage-shared/speaker-model'
|
||||
import { kwsModel } from '@proj-airi/stage-ui/libs/voice/kws-model-info'
|
||||
import { Download } from '@proj-airi/unplugin-fetch/vite'
|
||||
@@ -31,6 +32,7 @@ import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets'))
|
||||
const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache'))
|
||||
const settingsSearch = createSettingsSearch()
|
||||
|
||||
function hasFlagEnableMkcert(): boolean {
|
||||
if (process.argv.includes('--mkcert')) {
|
||||
@@ -146,6 +148,8 @@ export default defineConfig({
|
||||
|
||||
Yaml(),
|
||||
|
||||
settingsSearch.plugin,
|
||||
|
||||
VueMacros({
|
||||
plugins: {
|
||||
vue: Vue({
|
||||
@@ -166,6 +170,7 @@ export default defineConfig({
|
||||
resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
|
||||
],
|
||||
exclude: ['**/components/**'],
|
||||
extendRoute: settingsSearch.extendRoute,
|
||||
}),
|
||||
|
||||
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
|
||||
|
||||
@@ -73,6 +73,33 @@ Expandable/collapsible container with trigger button and vertical animation.
|
||||
**v-model**: `visible: boolean`
|
||||
**Slots**: `trigger({ visible, setVisible })`, `default({ visible, setVisible })`
|
||||
|
||||
### LinkGroup
|
||||
|
||||
Card that groups `LinkGroupItem` links under a small title, such as the settings search results of one category.
|
||||
It has the surface and the dot texture of the settings category cards, and its icon shows large and faded in the bottom-right corner.
|
||||
When an item has hover or focus, the icon scales up.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `title` | `string?` | — | Name of the group, shown small in the top-left corner |
|
||||
| `icon` | `string?` | — | Iconify class. It shows next to the title, and large in the corner |
|
||||
|
||||
**Slots**: `default` (the `LinkGroupItem` links)
|
||||
**Exposes**: `naturalWidth()`, the narrowest width at which no item cuts its text. A layout can use it to decide how many groups fit side by side.
|
||||
|
||||
### LinkGroupItem
|
||||
|
||||
One link in a `LinkGroup`: a name on the left, a short detail on the right, and an arrow.
|
||||
On hover, the theme color sweeps in from the left and the dots under the item light up.
|
||||
The name keeps its width. The detail shrinks and cuts first.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `as` | `PrimitiveProps['as']?` | `'a'` | Element or component to render, such as `RouterLink`. Other attributes, such as `to`, pass to it |
|
||||
| `detail` | `string?` | — | Short note on the right, such as where the link goes |
|
||||
|
||||
**Slots**: `default` (the name), `detail` (replaces the `detail` prop)
|
||||
|
||||
### Screen
|
||||
|
||||
Responsive screen component that calculates canvas dimensions based on breakpoints.
|
||||
|
||||
@@ -2569,6 +2569,10 @@ pages:
|
||||
label: Tip!
|
||||
description: Using a square image will leverage <b>cover</b> cropping in
|
||||
portrait mode, focusing on the center of the scene.
|
||||
search:
|
||||
placeholder: Search settings
|
||||
empty: 'No settings match "{query}".'
|
||||
not-shown: '"{name}" shows only in another state of this page, for example after you turn on a switch or choose another model type.'
|
||||
sections:
|
||||
section:
|
||||
general:
|
||||
|
||||
@@ -2362,6 +2362,10 @@ pages:
|
||||
tip:
|
||||
label: 提示!
|
||||
description: 使用正方形图片将在竖屏模式下应用<b>封面</b>裁剪,聚焦场景中心。
|
||||
search:
|
||||
placeholder: 搜索设置
|
||||
empty: 没有找到与“{query}”相关的设置。
|
||||
not-shown: “{name}”只在这个页面的其他状态下显示,比如打开某个开关或换一种模型之后。
|
||||
sections:
|
||||
section:
|
||||
general:
|
||||
|
||||
@@ -374,6 +374,12 @@ export interface ProviderDefinition<TConfig = Record<string, unknown>, TId exten
|
||||
*/
|
||||
speech?: {
|
||||
transport: 'rest' | 'bidirectional-ws'
|
||||
/**
|
||||
* Whether the voice catalogue differs for each model, as on the AIRI server.
|
||||
* Then the catalogue of one model is not the catalogue of the provider.
|
||||
* A caller that needs every voice, such as the settings search, asks `listVoices` once for each model.
|
||||
*/
|
||||
voicesByModel?: boolean
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { PageHeader } from '@proj-airi/stage-ui/components'
|
||||
import { useSettingsSearchReveal } from '@proj-airi/stage-ui/composables/use-settings-search'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { useTheme } from '@proj-airi/ui'
|
||||
import { computed, onMounted, useTemplateRef, watch } from 'vue'
|
||||
@@ -20,6 +21,8 @@ watch(() => route.path, () => {
|
||||
if (scrollContainer.value)
|
||||
scrollContainer.value.scrollTop = 0
|
||||
}, { flush: 'post' })
|
||||
// A search result opens its page with the setting in the query. Scroll to that row and highlight it.
|
||||
useSettingsSearchReveal(scrollContainer)
|
||||
|
||||
const { isDark: dark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -34,3 +34,31 @@ The active chat and vision provider routes live under `/settings/providers`. The
|
||||
- `/settings/modules/hearing/wake-words` lists the wake words of each card and the detector status. When two cards share a pronunciation, the user chooses the character that answers it. The choice stays on the device, and both cards keep their words.
|
||||
|
||||
The character card page shows an alert while a shared pronunciation has no character. The alert links to the wake words page.
|
||||
|
||||
## Settings search
|
||||
|
||||
The settings home searches every settings page, the settings on it, and the runtime items that a page lists.
|
||||
|
||||
- **Settings**: `createSettingsSearch` from `@proj-airi/stage-pages/vite/settings-search` scans each settings route at build time.
|
||||
- It follows the components that the page imports.
|
||||
- It writes the i18n keys of the names into `meta.searchKeys`, and marks the same elements with `data-settings-search`.
|
||||
- A name is `t('key')` in the `label` or `title` prop of a component, a heading, a label, or a legend.
|
||||
- A key can use a string constant of the file, a static prop, or a translation helper, such as `tn('enable')`, as its prefix.
|
||||
- **Excluded content**:
|
||||
- Buttons, notices (`Callout`, `Alert`, `ErrorContainer`), and overlay content are not settings, so they are not in the index. A button is a component whose template root is a `<button>` or another button component.
|
||||
- A branch that the app build never renders, such as a desktop-only row on the web, is not in the index either.
|
||||
- **Conditional settings**:
|
||||
- When a setting shows only in some state, the scan writes its gate into `meta.searchGates`.
|
||||
- The gate of a `v-if` or `v-show` is the control whose `v-model` or `:model-value` binds a value that the condition reads.
|
||||
A value is a store field or state of the component. A computed value stands for the values that its getter reads.
|
||||
- The gate of a slot that shows only in some state, such as the content of a collapsible section, is the name of the component usage.
|
||||
- Each control carries a `value:` marker of the value that it binds. The controls of a `v-for` give the marker to their parent.
|
||||
- If the setting is not on the page, search opens a collapsed section, or highlights the gate. Without a gate, a toast says that the setting shows in another state.
|
||||
- **Runtime items**, such as character cards or models:
|
||||
- A page lists them with a sibling `<page>.search.ts`. Its default export is a `SettingsSearchProvider` from `@proj-airi/stage-shared/settings-search`.
|
||||
- Each row binds the same key to `data-settings-search`. Build the key with `settingsSearchItemKey`, so a name with spaces stays one key.
|
||||
- An item that shows only after another step names those rows as its `gates`, nearest first. For example, a model names the card of its provider.
|
||||
- A shared list component, such as `RadioCardManySelect`, takes `searchKeyOf` for its items.
|
||||
- A page that renders only part of a long list, such as a grid that mounts rows in batches, reads `useSettingsSearchTarget()` from stage-ui. It renders the named row first.
|
||||
- **Address**: a result opens its page with one `setting` query parameter for each key, and one `setting-gate` parameter for each gate.
|
||||
- **Checks**: a production build fails when a name cannot be read, or when a page marks runtime rows without a provider. Development only warns.
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useFeatureFlagsStore } from '@proj-airi/stage-ui/stores/feature-flags'
|
||||
import { Button, FieldCheckbox, FieldSelect } from '@proj-airi/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { experimentSearchKey } from '../pages/settings/system/experimental.search'
|
||||
|
||||
const { t } = useI18n()
|
||||
const experiments = useFeatureFlagsStore()
|
||||
const prefix = 'settings.pages.system.experimental'
|
||||
@@ -30,6 +32,7 @@ const prefix = 'settings.pages.system.experimental'
|
||||
<section
|
||||
v-for="feature in experiments.features"
|
||||
:key="feature.key"
|
||||
:data-settings-search="experimentSearchKey(feature.key)"
|
||||
:class="['flex flex-col gap-3', 'rounded-xl bg-neutral-50 p-4 dark:bg-neutral-900']"
|
||||
>
|
||||
<FieldCheckbox
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
|
||||
/** Lists the character cards. A result opens the page of the card. */
|
||||
const provideCards: SettingsSearchProvider = () => {
|
||||
const cardStore = useAiriCardStore()
|
||||
return [...cardStore.cards.entries()].map(([id, card]) => ({
|
||||
title: card.name,
|
||||
key: settingsSearchItemKey('card', id),
|
||||
to: `/settings/airi-card/${id}`,
|
||||
}))
|
||||
}
|
||||
|
||||
export default provideCards
|
||||
+4
-17
@@ -1,34 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataSettingsStatusEmits } from '../status'
|
||||
|
||||
import { sherpawModels } from '@proj-airi/provider-inference/sherpaw-transcription/models'
|
||||
import { speakerModel } from '@proj-airi/stage-shared/speaker-model'
|
||||
import { useModelAssetStatus } from '@proj-airi/stage-ui/composables/use-model-asset-status'
|
||||
import { cancelSherpawModelAssets, isSherpawModelBundled, listSherpawModelAssets, removeSherpawModelAssets } from '@proj-airi/stage-ui/libs/providers/providers/sherpaw/model-assets'
|
||||
import { kwsModel } from '@proj-airi/stage-ui/libs/voice/kws-model-info'
|
||||
import { cancelSherpawModelAssets, listSherpawModelAssets, removeSherpawModelAssets } from '@proj-airi/stage-ui/libs/providers/providers/sherpaw/model-assets'
|
||||
import { DoubleCheckButton, GhostButton } from '@proj-airi/ui'
|
||||
import { computed, onMounted, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadedModelSearchKey, downloadedModelsOf } from '../downloaded-models'
|
||||
import { createDataSettingsStatusHelpers } from '../status'
|
||||
|
||||
const emit = defineEmits<DataSettingsStatusEmits>()
|
||||
const { t } = useI18n()
|
||||
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
|
||||
const { models } = useModelAssetStatus()
|
||||
const names = new Map<string, string>(Object.values(sherpawModels).map(model => [model.id, model.name]))
|
||||
// Transcription presets show their recognizer family. The wake word and voiceprint packs have no family name users know.
|
||||
const packNameKeys = new Map<string, string>([
|
||||
[kwsModel.id, 'settings.pages.data.sections.model-downloads.wake-word-model'],
|
||||
[speakerModel.id, 'settings.pages.data.sections.model-downloads.voiceprint-model'],
|
||||
])
|
||||
const downloads = computed(() => models.value.flatMap((status) => {
|
||||
const nameKey = packNameKeys.get(status.id)
|
||||
const name = nameKey ? t(nameKey) : names.get(status.id)
|
||||
if (!name || isSherpawModelBundled(status.id) || (status.state !== 'installed' && status.state !== 'downloading'))
|
||||
return []
|
||||
return [{ ...status, name }]
|
||||
}))
|
||||
const downloads = computed(() => downloadedModelsOf(models.value, t))
|
||||
const refreshing = shallowRef(false)
|
||||
const pendingId = shallowRef<string>()
|
||||
|
||||
@@ -106,6 +92,7 @@ onMounted(refresh)
|
||||
<div
|
||||
v-for="model in downloads"
|
||||
:key="model.id"
|
||||
:data-settings-search="downloadedModelSearchKey(model.id)"
|
||||
:class="['flex flex-wrap items-center justify-between gap-3 rounded-lg bg-neutral-100/70 px-3 py-2', 'dark:bg-neutral-800/60']"
|
||||
>
|
||||
<div :class="['flex flex-col gap-1']">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ModelAssetStatus } from '@proj-airi/stage-shared/model-assets'
|
||||
|
||||
import { sherpawModels } from '@proj-airi/provider-inference/sherpaw-transcription/models'
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { speakerModel } from '@proj-airi/stage-shared/speaker-model'
|
||||
import { isSherpawModelBundled } from '@proj-airi/stage-ui/libs/providers/providers/sherpaw/model-assets'
|
||||
import { kwsModel } from '@proj-airi/stage-ui/libs/voice/kws-model-info'
|
||||
|
||||
const names = new Map<string, string>(Object.values(sherpawModels).map(model => [model.id, model.name]))
|
||||
// Transcription presets show their recognizer family. The wake word and voiceprint packs have no family name users know.
|
||||
const packNameKeys = new Map<string, string>([
|
||||
[kwsModel.id, 'settings.pages.data.sections.model-downloads.wake-word-model'],
|
||||
[speakerModel.id, 'settings.pages.data.sections.model-downloads.voiceprint-model'],
|
||||
])
|
||||
|
||||
/** The marker of a downloaded model's row. The section and the settings search share it. */
|
||||
export function downloadedModelSearchKey(id: string) {
|
||||
return settingsSearchItemKey('downloaded-model', id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The speech models that this device downloaded, or is downloading, with the names that the data page shows.
|
||||
* A model bundled with the app is not a download, so it is not here.
|
||||
*/
|
||||
export function downloadedModelsOf(models: readonly ModelAssetStatus[], t: (key: string) => string) {
|
||||
return models.flatMap((status) => {
|
||||
const nameKey = packNameKeys.get(status.id)
|
||||
const name = nameKey ? t(nameKey) : names.get(status.id)
|
||||
if (!name || isSherpawModelBundled(status.id) || (status.state !== 'installed' && status.state !== 'downloading'))
|
||||
return []
|
||||
return [{ ...status, name }]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { useModelAssetStatus } from '@proj-airi/stage-ui/composables/use-model-asset-status'
|
||||
import { listSherpawModelAssets } from '@proj-airi/stage-ui/libs/providers/providers/sherpaw/model-assets'
|
||||
|
||||
import { downloadedModelSearchKey, downloadedModelsOf } from './downloaded-models'
|
||||
|
||||
/** Lists the downloaded speech models. It reads their state first, as the data page does when it opens. */
|
||||
const provideDownloadedModels: SettingsSearchProvider = async ({ t }) => {
|
||||
await listSherpawModelAssets()
|
||||
return downloadedModelsOf(useModelAssetStatus().models.value, t)
|
||||
.map(model => ({ title: model.name, key: downloadedModelSearchKey(model.id) }))
|
||||
}
|
||||
|
||||
export default provideDownloadedModels
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { IconItem, RippleGrid } from '@proj-airi/stage-ui/components'
|
||||
import { IconItem, RippleGrid, SettingsSearchField, SettingsSearchResults } from '@proj-airi/stage-ui/components'
|
||||
import { useRippleGridState } from '@proj-airi/stage-ui/composables/use-ripple-grid-state'
|
||||
import { useSettingsSearch } from '@proj-airi/stage-ui/composables/use-settings-search'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { settingsSearchProviders } from '../../settings-search-providers'
|
||||
|
||||
const router = useRouter()
|
||||
const resolveAnimation = ref<() => void>()
|
||||
const { t } = useI18n()
|
||||
@@ -38,11 +41,27 @@ const settings = computed(() => {
|
||||
to: route.path,
|
||||
}))
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const searchResults = useSettingsSearch(searchQuery, settingsSearchProviders)
|
||||
const searching = computed(() => searchQuery.value.trim() !== '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col gap-4" font-normal>
|
||||
<div pb-12>
|
||||
<!-- The search stays at the top while the list scrolls under it, as the page header does above it. -->
|
||||
<!-- It is the first element, so it rests where it sticks and does not move when the list starts to scroll. -->
|
||||
<div :class="['sticky top-0 z-10', 'bg-$bg-color', 'pb-2']">
|
||||
<SettingsSearchField v-model="searchQuery" />
|
||||
</div>
|
||||
<!-- Results appear at once while the user types, so they skip the enter animation of the categories. -->
|
||||
<div v-if="searching" :class="['pb-12']">
|
||||
<p v-if="!searchResults.length" :class="['text-sm text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.search.empty', { query: searchQuery.trim() }) }}
|
||||
</p>
|
||||
<SettingsSearchResults v-else :results="searchResults" :query="searchQuery" />
|
||||
</div>
|
||||
<div v-else pb-12>
|
||||
<RippleGrid
|
||||
:items="settings"
|
||||
:get-key="item => item.to"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { MODEL_PICKER_SEARCH_KEY } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
|
||||
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
|
||||
|
||||
/**
|
||||
* Lists the display models by name. They live in the model picker dialog, so each result points to the button that opens it,
|
||||
* as Firefox settings search points to the button of a subdialog.
|
||||
*/
|
||||
const provideDisplayModels: SettingsSearchProvider = async ({ t }) => {
|
||||
const displayModels = useDisplayModelsStore()
|
||||
await displayModels.loadDisplayModelsFromIndexedDB()
|
||||
const picker = t('settings.model-select.select-model.button')
|
||||
return displayModels.displayModels.map(model => ({ title: model.name, key: MODEL_PICKER_SEARCH_KEY, section: picker }))
|
||||
}
|
||||
|
||||
export default provideDisplayModels
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
|
||||
import { moduleModelItems } from './module-model-search'
|
||||
|
||||
/** Lists the chat providers of the page and their models. */
|
||||
const provideChatModels: SettingsSearchProvider = () =>
|
||||
moduleModelItems('consciousness', useProviderStore().moduleChatProvidersMetadata)
|
||||
|
||||
export default provideChatModels
|
||||
@@ -12,6 +12,8 @@ import { watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { moduleModelSearchKey, moduleProviderSearchKey } from './module-model-search'
|
||||
|
||||
const providersStore = useProviderStore()
|
||||
const providerStore = useProviderConfigStore()
|
||||
const airiCardStore = useAiriCardStore()
|
||||
@@ -108,6 +110,7 @@ async function updateTopPEnabled(value: boolean) {
|
||||
:id="metadata.id"
|
||||
:key="metadata.id"
|
||||
v-model="activeProvider"
|
||||
:data-settings-search="moduleProviderSearchKey('consciousness', metadata.id)"
|
||||
name="provider"
|
||||
:value="metadata.id"
|
||||
:title="metadata.localizedName || 'Unknown'"
|
||||
@@ -245,6 +248,7 @@ async function updateTopPEnabled(value: boolean) {
|
||||
v-model:search-query="modelSearchQuery"
|
||||
:model-value="activeModel"
|
||||
:items="providerModels"
|
||||
:search-key-of="model => moduleModelSearchKey('consciousness', activeProvider, model.id)"
|
||||
:searchable="true"
|
||||
:allow-custom="true"
|
||||
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
|
||||
import { moduleModelItems, providerViewItems } from '../module-model-search'
|
||||
|
||||
/** Lists the transcription providers of the page, their models, and the settings of the Hearing view that a provider adds. */
|
||||
const provideTranscription: SettingsSearchProvider = async ({ t }) => {
|
||||
const providers = useProviderStore().moduleTranscriptionProvidersMetadata
|
||||
const [models, views] = await Promise.all([
|
||||
moduleModelItems('transcription', providers),
|
||||
providerViewItems('transcription', 'hearing', providers, t),
|
||||
])
|
||||
return [...models, ...views]
|
||||
}
|
||||
|
||||
export default provideTranscription
|
||||
@@ -20,6 +20,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HearingPlaygroundTranscripts from '../components/hearing-playground-transcripts.vue'
|
||||
|
||||
import { moduleModelSearchKey, moduleProviderSearchKey } from '../module-model-search'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hearingStore = useHearingStore()
|
||||
@@ -382,6 +384,7 @@ onUnmounted(() => {
|
||||
:id="metadata.id"
|
||||
:key="metadata.id"
|
||||
v-model="activeTranscriptionProvider"
|
||||
:data-settings-search="moduleProviderSearchKey('transcription', metadata.id)"
|
||||
name="provider"
|
||||
:value="metadata.id"
|
||||
:title="metadata.localizedName || 'Unknown'"
|
||||
@@ -494,6 +497,7 @@ onUnmounted(() => {
|
||||
v-model="activeTranscriptionModel"
|
||||
v-model:search-query="transcriptionModelSearchQuery"
|
||||
:items="sortedProviderModels"
|
||||
:search-key-of="model => moduleModelSearchKey('transcription', activeTranscriptionProvider, model.id)"
|
||||
: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')"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
|
||||
/** The marker of a wake word's block. A word is unique for each card. */
|
||||
export function wakeWordSearchKey(cardId: string, text: string) {
|
||||
return settingsSearchItemKey('wake-word', cardId, text)
|
||||
}
|
||||
|
||||
/** Lists the wake words of every card, with the card that answers each one, as the page groups them. */
|
||||
const provideWakeWords: SettingsSearchProvider = () =>
|
||||
[...useAiriCardStore().cards].flatMap(([id, card]) =>
|
||||
(card.extensions.airi.wakeWords ?? []).map(word => ({ title: word.text, key: wakeWordSearchKey(id, word.text), section: card.name })))
|
||||
|
||||
export default provideWakeWords
|
||||
@@ -12,6 +12,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WakeWordConflicts from '../components/wake-word-conflicts.vue'
|
||||
|
||||
import { wakeWordSearchKey } from './wake-words.search'
|
||||
|
||||
/**
|
||||
* How one card pronunciation behaves on this device.
|
||||
*
|
||||
@@ -93,7 +95,7 @@ const stateClasses: Record<PronunciationState, string[]> = {
|
||||
<p v-if="character.words.length === 0" :class="['text-sm text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.modules.hearing.wake-words.empty') }}
|
||||
</p>
|
||||
<div v-for="word in character.words" :key="word.text" :class="['flex flex-col gap-2']">
|
||||
<div v-for="word in character.words" :key="word.text" :data-settings-search="wakeWordSearchKey(character.id, word.text)" :class="['flex flex-col gap-2']">
|
||||
<h3 :class="['font-medium text-neutral-800 dark:text-neutral-100']">
|
||||
{{ word.text }}
|
||||
</h3>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
import type { MemoryEntry } from '@proj-airi/stage-ui/stores/memory'
|
||||
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useMemoryStore } from '@proj-airi/stage-ui/stores/memory'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
|
||||
/** The marker of a memory's row. A name is unique for each card, and general memories have no card. */
|
||||
export function memorySearchKey(entry: MemoryEntry) {
|
||||
return settingsSearchItemKey('memory', entry.persona ?? '', entry.name)
|
||||
}
|
||||
|
||||
/** Lists the memories by name, with the card that keeps each one, as the page groups them. */
|
||||
const provideMemories: SettingsSearchProvider = ({ t }) => {
|
||||
const cards = useAiriCardStore().cards
|
||||
return useMemoryStore().entries.map(entry => ({
|
||||
title: entry.name,
|
||||
key: memorySearchKey(entry),
|
||||
section: entry.persona
|
||||
? cards.get(entry.persona)?.name || entry.persona
|
||||
: t('settings.pages.modules.memory-long-term.memories.general'),
|
||||
}))
|
||||
}
|
||||
|
||||
export default provideMemories
|
||||
@@ -8,6 +8,8 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { memorySearchKey } from './memory-long-term-memories.search'
|
||||
|
||||
const { t } = useI18n()
|
||||
const memory = useMemoryStore()
|
||||
const { cards } = storeToRefs(useAiriCardStore())
|
||||
@@ -54,6 +56,7 @@ function remove(entry: MemoryEntry) {
|
||||
<li
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.name"
|
||||
:data-settings-search="memorySearchKey(entry)"
|
||||
:class="['flex items-start', 'gap-3', 'rounded-lg', 'bg-white dark:bg-neutral-900/60', 'px-3 py-2.5']"
|
||||
>
|
||||
<div :class="['min-w-0 flex-1', 'flex flex-col', 'gap-0.5']">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useRecipesStore } from '@proj-airi/stage-ui/stores/recipes'
|
||||
|
||||
/** The marker of a recipe's row. The page and the provider share it. */
|
||||
export function recipeSearchKey(id: string) {
|
||||
return settingsSearchItemKey('recipe', id)
|
||||
}
|
||||
|
||||
/** Lists the recipes by name. */
|
||||
const provideRecipes: SettingsSearchProvider = () =>
|
||||
useRecipesStore().recipes.map(recipe => ({ title: recipe.name, key: recipeSearchKey(recipe.id) }))
|
||||
|
||||
export default provideRecipes
|
||||
@@ -12,6 +12,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import RecipeEditor from './components/recipe-editor.vue'
|
||||
|
||||
import { recipeSearchKey } from './memory-long-term-recipes.search'
|
||||
|
||||
/** Recipe types that the owner can write. */
|
||||
type RecipeType = 'instructions' | 'decision'
|
||||
/** Conversation recipes work inside a reply. Auto-run recipes are still in development, so their tab shows a banner. */
|
||||
@@ -173,6 +175,7 @@ function removeRecipe(id: string) {
|
||||
<li
|
||||
v-for="recipe in recipes"
|
||||
:key="recipe.id"
|
||||
:data-settings-search="recipeSearchKey(recipe.id)"
|
||||
:class="[
|
||||
'flex flex-col', 'gap-3',
|
||||
'rounded-xl', 'px-4 py-3',
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { SettingsSearchContext, SettingsSearchItem, SettingsSearchViewEntry } from '@proj-airi/stage-shared/settings-search'
|
||||
import type { ProviderMetadata } from '@proj-airi/stage-ui/libs/providers/metadata'
|
||||
import type { ProviderViews } from '@proj-airi/stage-ui/libs/providers/views'
|
||||
import type { VoiceInfo } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
|
||||
import { SETTINGS_SEARCH_VIEW_EXPORT, settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
|
||||
/** A module page that picks a provider, then a model of it. */
|
||||
export type ModelSearchModule = 'consciousness' | 'vision' | 'transcription' | 'speech'
|
||||
|
||||
/** The marker of a provider card on a module page. */
|
||||
export function moduleProviderSearchKey(module: ModelSearchModule, providerId: string) {
|
||||
return settingsSearchItemKey('module-provider', module, providerId)
|
||||
}
|
||||
|
||||
/** The marker of a model in the model list of a module page. */
|
||||
export function moduleModelSearchKey(module: ModelSearchModule, providerId: string, modelId: string) {
|
||||
return settingsSearchItemKey('module-model', module, providerId, modelId)
|
||||
}
|
||||
|
||||
/** The marker of a voice in the voice list of the speech page. */
|
||||
export function speechVoiceSearchKey(providerId: string, voiceId: string) {
|
||||
return settingsSearchItemKey('speech-voice', providerId, voiceId)
|
||||
}
|
||||
|
||||
/** Names the provider whose card shows a provider. Most providers have a card of their own. */
|
||||
export type ProviderCardOf = (providerId: string) => string
|
||||
|
||||
const ownCard: ProviderCardOf = providerId => providerId
|
||||
|
||||
/** Loads the model catalog of each provider that lists its models, as its page does when it opens. A loaded catalog is reused. */
|
||||
async function loadModelCatalogs(providers: readonly ProviderMetadata[]) {
|
||||
const providerStore = useProviderStore()
|
||||
await Promise.all(providers.map(async (provider) => {
|
||||
if (providerStore.supportsModelListing(provider.id) && providerStore.providerRuntimeState[provider.id]?.modelStatus !== 'ready')
|
||||
await providerStore.fetchModelsForProvider(provider.id).catch(() => undefined)
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the providers that a module page offers, and the models of each one.
|
||||
*
|
||||
* The model list shows only the models of the picked provider. So the provider card is the gate of each model:
|
||||
* when the provider is not picked, search highlights its card, which is the step to take first.
|
||||
* A provider without a card of its own, such as the streaming variant of the official speech provider,
|
||||
* shows its models under the card that `cardOf` names.
|
||||
*/
|
||||
export async function moduleModelItems(module: ModelSearchModule, providers: readonly ProviderMetadata[], cardOf: ProviderCardOf = ownCard): Promise<SettingsSearchItem[]> {
|
||||
const providerStore = useProviderStore()
|
||||
await loadModelCatalogs(providers)
|
||||
|
||||
return providers.flatMap((provider) => {
|
||||
const card = providers.find(candidate => candidate.id === cardOf(provider.id)) ?? provider
|
||||
const cardKey = moduleProviderSearchKey(module, card.id)
|
||||
return [
|
||||
...(card.id === provider.id ? [{ title: provider.localizedName, key: cardKey }] : []),
|
||||
...providerStore.getModelsForProvider(provider.id).map(model => ({
|
||||
title: model.name || model.id,
|
||||
key: moduleModelSearchKey(module, provider.id, model.id),
|
||||
gates: [cardKey],
|
||||
section: card.localizedName,
|
||||
})),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the voices of the speech providers.
|
||||
* A voice shows only under the models that offer it, and a model shows only under its picked provider.
|
||||
* So the gates of a voice are the cards of those models, then the card of its provider.
|
||||
*
|
||||
* A provider that declares `voicesByModel` has a voice catalog for each model. Its voices come from the catalog of each model,
|
||||
* and each voice names the models that list it. Another provider has one catalog, and a voice can name its models itself.
|
||||
* The speech page keeps the catalog of one model, so a provider with a catalog for each model reuses it only for that model.
|
||||
*/
|
||||
export async function speechVoiceItems(providers: readonly ProviderMetadata[], cardOf: ProviderCardOf = ownCard): Promise<SettingsSearchItem[]> {
|
||||
const providerStore = useProviderStore()
|
||||
const speechStore = useSpeechStore()
|
||||
await loadModelCatalogs(providers)
|
||||
|
||||
const catalogOf = async (providerId: string, model: string | undefined, byModel: boolean): Promise<VoiceInfo[]> => {
|
||||
const cached = speechStore.availableVoices[providerId]
|
||||
if (cached && (!byModel || speechStore.voiceCatalogIdentities[providerId]?.model === model))
|
||||
return cached
|
||||
return await providerStore.listProviderVoices(providerId, model).catch(() => undefined) ?? []
|
||||
}
|
||||
|
||||
const items = await Promise.all(providers.map(async (provider) => {
|
||||
const card = providers.find(candidate => candidate.id === cardOf(provider.id)) ?? provider
|
||||
const byModel = providerStore.findProviderDefinition(provider.id)?.capabilities?.speech?.voicesByModel === true
|
||||
const models = byModel ? providerStore.getModelsForProvider(provider.id).map(model => model.id) : []
|
||||
const catalogs = models.length
|
||||
? await Promise.all(models.map(async model => ({ model, voices: await catalogOf(provider.id, model, true) })))
|
||||
: [{ model: undefined, voices: await catalogOf(provider.id, undefined, byModel) }]
|
||||
|
||||
// Each voice once, with the models whose catalog lists it.
|
||||
const voices = new Map<string, { voice: VoiceInfo, models: string[] }>()
|
||||
for (const catalog of catalogs) {
|
||||
for (const voice of catalog.voices) {
|
||||
const entry = voices.get(voice.id) ?? { voice, models: [] }
|
||||
if (catalog.model)
|
||||
entry.models.push(catalog.model)
|
||||
voices.set(voice.id, entry)
|
||||
}
|
||||
}
|
||||
return [...voices.values()].map(({ voice, models: listedBy }) => ({
|
||||
title: voice.name || voice.id,
|
||||
key: speechVoiceSearchKey(provider.id, voice.id),
|
||||
gates: [
|
||||
...(listedBy.length ? listedBy : voice.compatibleModels ?? []).map(model => moduleModelSearchKey('speech', provider.id, model)),
|
||||
moduleProviderSearchKey('speech', card.id),
|
||||
],
|
||||
section: card.localizedName,
|
||||
}))
|
||||
}))
|
||||
return items.flat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the settings of the views that the providers add to a module page, such as the Hearing controls of a transcription provider.
|
||||
*
|
||||
* The build-time scan gives each view the list of its settings. The page shows only the view of the picked provider,
|
||||
* so the provider card is the last gate of each setting.
|
||||
*/
|
||||
export async function providerViewItems(
|
||||
module: ModelSearchModule,
|
||||
view: keyof ProviderViews,
|
||||
providers: readonly ProviderMetadata[],
|
||||
t: SettingsSearchContext['t'],
|
||||
): Promise<SettingsSearchItem[]> {
|
||||
const providerStore = useProviderStore()
|
||||
const items = await Promise.all(providers.map(async (provider) => {
|
||||
const load = providerStore.findProviderDefinition(provider.id)?.views?.[view]
|
||||
if (!load)
|
||||
return []
|
||||
const entries: unknown = Reflect.get(await load(), SETTINGS_SEARCH_VIEW_EXPORT)
|
||||
return (Array.isArray(entries) ? entries as SettingsSearchViewEntry[] : []).map(entry => ({
|
||||
title: t(entry.key),
|
||||
key: entry.key,
|
||||
gates: [...entry.gates, moduleProviderSearchKey(module, provider.id)],
|
||||
section: provider.localizedName,
|
||||
}))
|
||||
}))
|
||||
return items.flat()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '@proj-airi/stage-ui/libs/providers/providers/official'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
|
||||
import { moduleModelItems, speechVoiceItems } from './module-model-search'
|
||||
|
||||
/** The official card also lists the models and voices of the streaming provider, which has no card of its own. */
|
||||
function cardOf(providerId: string) {
|
||||
return providerId === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID ? OFFICIAL_SPEECH_PROVIDER_ID : providerId
|
||||
}
|
||||
|
||||
/** Lists the speech providers of the page, with their models and voices. The page offers the same providers as cards. */
|
||||
const provideSpeechModels: SettingsSearchProvider = async () => {
|
||||
const providers = useProviderStore().moduleSpeechProvidersMetadata.filter(provider => provider.id !== 'speech-noop')
|
||||
// The voices of a provider with a catalog for each model need its model list, so the models load first.
|
||||
const models = await moduleModelItems('speech', providers, cardOf)
|
||||
return [...models, ...await speechVoiceItems(providers, cardOf)]
|
||||
}
|
||||
|
||||
export default provideSpeechModels
|
||||
@@ -31,6 +31,8 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { moduleModelSearchKey, moduleProviderSearchKey, speechVoiceSearchKey } from './module-model-search'
|
||||
|
||||
const { t } = useI18n()
|
||||
const providersStore = useProviderStore()
|
||||
const providerStore = useProviderConfigStore()
|
||||
@@ -144,6 +146,17 @@ const displayedProviderModels = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* The search marker of a model option. The official card lists the models of the regular and the streaming provider,
|
||||
* so each option names the provider that owns it, not the active one.
|
||||
*/
|
||||
function speechModelSearchKey(optionId: string) {
|
||||
const streamingModelId = modelIdFromStreamingOptionId(optionId)
|
||||
if (streamingModelId != null)
|
||||
return moduleModelSearchKey('speech', OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, streamingModelId)
|
||||
return moduleModelSearchKey('speech', isOfficialSpeechSourceSelected.value ? OFFICIAL_SPEECH_PROVIDER_ID : activeSpeechProvider.value, optionId)
|
||||
}
|
||||
|
||||
const displayedSpeechModel = computed({
|
||||
get: () => activeSpeechProvider.value === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID && activeSpeechModel.value
|
||||
? streamingModelOptionId(activeSpeechModel.value)
|
||||
@@ -621,6 +634,7 @@ async function handleDeleteProvider(providerId: string) {
|
||||
:id="source.id"
|
||||
:key="source.id"
|
||||
v-model="displayedSpeechSource"
|
||||
:data-settings-search="moduleProviderSearchKey('speech', source.providerId)"
|
||||
name="speech-provider"
|
||||
:value="source.id"
|
||||
:title="source.title"
|
||||
@@ -747,6 +761,7 @@ async function handleDeleteProvider(providerId: string) {
|
||||
v-model="displayedSpeechModel"
|
||||
v-model:search-query="modelSearchQuery"
|
||||
:items="displayedProviderModels"
|
||||
:search-key-of="model => speechModelSearchKey(model.id)"
|
||||
: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')"
|
||||
@@ -811,6 +826,7 @@ async function handleDeleteProvider(providerId: string) {
|
||||
v-model:search-query="voiceSearchQuery"
|
||||
v-model:voice-id="displayedSpeechVoiceId"
|
||||
:voices="displayedVoiceOptions"
|
||||
:search-key-of="voice => speechVoiceSearchKey(activeSpeechProvider, voice.id)"
|
||||
: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')"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { stickerDisplayName, stickerSearchKey, useStickersStore } from '@proj-airi/stage-ui/stores/modules/stickers'
|
||||
|
||||
/** Lists the stickers by the names that the page shows. The custom ones load from IndexedDB first. */
|
||||
const provideStickers: SettingsSearchProvider = async ({ t }) => {
|
||||
const stickers = useStickersStore()
|
||||
await stickers.load()
|
||||
return stickers.entries.map(entry => ({ title: stickerDisplayName(entry, t), key: stickerSearchKey(entry.id) }))
|
||||
}
|
||||
|
||||
export default provideStickers
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useToolCardsStore } from '@proj-airi/stage-ui/stores/tool-cards'
|
||||
|
||||
/** The marker of a card's row. The page and the provider share it. */
|
||||
export function toolCardSearchKey(key: string) {
|
||||
return settingsSearchItemKey('tool-card', key)
|
||||
}
|
||||
|
||||
/** Lists the saved tool cards by the tool that each one shows. */
|
||||
const provideToolCards: SettingsSearchProvider = () =>
|
||||
useToolCardsStore().cards.map(card => ({ title: card.key, key: toolCardSearchKey(card.key) }))
|
||||
|
||||
export default provideToolCards
|
||||
@@ -10,6 +10,8 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { toolCardSearchKey } from './tool-cards.search'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const toolCards = useToolCardsStore()
|
||||
const { enabled, replyCardsEnabled, cards } = storeToRefs(toolCards)
|
||||
@@ -88,6 +90,7 @@ function toggleTemplate(key: string) {
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
:data-settings-search="toolCardSearchKey(row.card.key)"
|
||||
:class="[
|
||||
'grid grid-cols-1 md:grid-cols-[minmax(0,1fr)_minmax(0,360px)]',
|
||||
'gap-4',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
|
||||
import { moduleModelItems } from './module-model-search'
|
||||
|
||||
/** Lists the vision providers of the page and their models. */
|
||||
const provideVisionModels: SettingsSearchProvider = () =>
|
||||
moduleModelItems('vision', useProviderStore().moduleVisionProvidersMetadata)
|
||||
|
||||
export default provideVisionModels
|
||||
@@ -11,6 +11,8 @@ import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { moduleModelSearchKey, moduleProviderSearchKey } from './module-model-search'
|
||||
|
||||
const providersStore = useProviderStore()
|
||||
const providerStore = useProviderConfigStore()
|
||||
const airiCardStore = useAiriCardStore()
|
||||
@@ -136,6 +138,7 @@ function formatRelativeTime(timestamp: number | null) {
|
||||
v-for="metadata in moduleVisionProvidersMetadata"
|
||||
:id="metadata.id"
|
||||
:key="metadata.id"
|
||||
:data-settings-search="moduleProviderSearchKey('vision', metadata.id)"
|
||||
:model-value="activeProvider"
|
||||
name="provider"
|
||||
:value="metadata.id"
|
||||
@@ -289,6 +292,7 @@ function formatRelativeTime(timestamp: number | null) {
|
||||
v-model="activeModel"
|
||||
v-model:search-query="modelSearchQuery"
|
||||
:items="providerModels.sort((a, b) => a.id === activeModel ? -1 : b.id === activeModel ? 1 : 0)"
|
||||
:search-key-of="model => moduleModelSearchKey('vision', activeProvider, model.id)"
|
||||
: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')"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
|
||||
/** The marker of a saved workflow's row. The page and the provider share it. */
|
||||
export function comfyuiWorkflowSearchKey(id: string) {
|
||||
return settingsSearchItemKey('comfyui-workflow', id)
|
||||
}
|
||||
|
||||
/** Lists the saved ComfyUI workflows by name. */
|
||||
const provideWorkflows: SettingsSearchProvider = () =>
|
||||
useArtistryStore().comfyuiSavedWorkflows.map(workflow => ({ title: workflow.name, key: comfyuiWorkflowSearchKey(workflow.id) }))
|
||||
|
||||
export default provideWorkflows
|
||||
@@ -11,6 +11,8 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { comfyuiWorkflowSearchKey } from './comfyui.search'
|
||||
|
||||
const artistryStore = useArtistryStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -362,7 +364,7 @@ function copyToClipboard(text: string) {
|
||||
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-for="wf in comfyuiSavedWorkflows" :key="wf.id" class="flex flex-col gap-2 border border-neutral-200 rounded-lg p-3 dark:border-neutral-700">
|
||||
<div v-for="wf in comfyuiSavedWorkflows" :key="wf.id" :data-settings-search="comfyuiWorkflowSearchKey(wf.id)" class="flex flex-col gap-2 border border-neutral-200 rounded-lg p-3 dark:border-neutral-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useBackgroundStore } from '@proj-airi/stage-ui/stores'
|
||||
import { until } from '@vueuse/core'
|
||||
|
||||
/** The marker of a background's tile. The page and the provider share it. */
|
||||
export function sceneBackgroundSearchKey(id: string) {
|
||||
return settingsSearchItemKey('scene-background', id)
|
||||
}
|
||||
|
||||
/** The gallery shows the scene backgrounds and the built-in ones, not journal images or selfies. */
|
||||
export function isSceneBackground(entry: { type: string }) {
|
||||
return entry.type === 'scene' || entry.type === 'builtin'
|
||||
}
|
||||
|
||||
/** Lists the backgrounds of the scene gallery by title, after the store reads them from IndexedDB. */
|
||||
const provideBackgrounds: SettingsSearchProvider = async () => {
|
||||
const backgrounds = useBackgroundStore()
|
||||
await until(() => backgrounds.loading).toBe(false)
|
||||
return backgrounds.availableBackgrounds
|
||||
.filter(isSceneBackground)
|
||||
.map(background => ({ title: background.title, key: sceneBackgroundSearchKey(background.id) }))
|
||||
}
|
||||
|
||||
export default provideBackgrounds
|
||||
@@ -6,6 +6,8 @@ import { Button, Callout } from '@proj-airi/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isSceneBackground, sceneBackgroundSearchKey } from './index.search'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { trackSceneBackgroundSet } = useAnalytics()
|
||||
const backgroundStore = useBackgroundStore()
|
||||
@@ -13,10 +15,7 @@ const cardStore = useAiriCardStore()
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
|
||||
const sceneEntries = computed(() => {
|
||||
return backgroundStore.availableBackgrounds
|
||||
.filter(e => e.type === 'scene' || e.type === 'builtin')
|
||||
})
|
||||
const sceneEntries = computed(() => backgroundStore.availableBackgrounds.filter(isSceneBackground))
|
||||
|
||||
const activeBackgroundId = computed({
|
||||
get: () => cardStore.activeCard?.extensions?.airi?.modules?.activeBackgroundId || 'none',
|
||||
@@ -125,6 +124,7 @@ function clearDefault() {
|
||||
<div
|
||||
v-for="bg in sceneEntries"
|
||||
:key="bg.id"
|
||||
:data-settings-search="sceneBackgroundSearchKey(bg.id)"
|
||||
:class="[
|
||||
'relative aspect-square overflow-hidden rounded-xl border-2 group transition-all',
|
||||
bg.id === activeBackgroundId ? 'border-primary shadow-lg' : 'border-transparent bg-neutral-100 dark:bg-neutral-900',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useFeatureFlagsStore } from '@proj-airi/stage-ui/stores/feature-flags'
|
||||
|
||||
/** The marker of an experiment's row. The page and the provider share it. */
|
||||
export function experimentSearchKey(key: string) {
|
||||
return settingsSearchItemKey('experiment', key)
|
||||
}
|
||||
|
||||
/** Lists the experiments that the page offers, by their titles. */
|
||||
const provideExperiments: SettingsSearchProvider = ({ t }) =>
|
||||
useFeatureFlagsStore().features.map(feature => ({ title: t(feature.titleKey), key: experimentSearchKey(feature.key) }))
|
||||
|
||||
export default provideExperiments
|
||||
@@ -0,0 +1,16 @@
|
||||
/// <reference path="./vite/settings-search-providers.d.ts" />
|
||||
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
import providers from 'virtual:settings-search-providers'
|
||||
|
||||
/**
|
||||
* The loaders of the runtime item providers, by settings route path.
|
||||
*
|
||||
* Use when:
|
||||
* - A settings home passes the providers to `useSettingsSearch`.
|
||||
*
|
||||
* Expects:
|
||||
* - The app build uses `createSettingsSearch` from `@proj-airi/stage-pages/vite/settings-search`, which generates this table.
|
||||
*/
|
||||
export const settingsSearchProviders: Readonly<Record<string, () => Promise<SettingsSearchProvider>>> = providers
|
||||
@@ -0,0 +1,7 @@
|
||||
declare module 'virtual:settings-search-providers' {
|
||||
import type { SettingsSearchProvider } from '@proj-airi/stage-shared/settings-search'
|
||||
|
||||
/** The runtime item provider of each settings route, by route path. `@proj-airi/stage-pages/vite/settings-search` generates it. */
|
||||
const providers: Readonly<Record<string, () => Promise<SettingsSearchProvider>>>
|
||||
export default providers
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
||||
"./plugin-host": "./src/plugin-host.ts",
|
||||
"./screen-ambient-light": "./src/screen-ambient-light/index.ts",
|
||||
"./server-channel-qr": "./src/server-channel-qr.ts",
|
||||
"./settings-search": "./src/settings-search/index.ts",
|
||||
"./speaker-model": "./src/speaker-model.ts",
|
||||
"./stores/screen-ambient-light": "./src/stores/screen-ambient-light.ts",
|
||||
"./types/io-trace": "./src/types/io-trace.ts",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* The contract between the settings search index, the pages, and the settings layout.
|
||||
*
|
||||
* The build-time scan in `@proj-airi/stage-pages/vite/settings-search` writes the index and marks the rows.
|
||||
* The settings search in stage-ui reads the index and opens a row.
|
||||
* This module has no runtime dependencies, so both sides can import it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The query parameter that names the setting to show after a search opens its page.
|
||||
* It repeats when one result stands for several settings with the same name, such as "Scale" of each model type.
|
||||
*/
|
||||
export const SETTINGS_SEARCH_QUERY = 'setting'
|
||||
|
||||
/**
|
||||
* The query parameter that names the row to show when the setting is not on the page, such as the card of the provider
|
||||
* that lists a model. Static settings keep their gates in the route meta instead.
|
||||
*/
|
||||
export const SETTINGS_SEARCH_GATE_QUERY = 'setting-gate'
|
||||
|
||||
/**
|
||||
* The attribute that marks the row of a searchable setting.
|
||||
* Its value is a key, or several keys separated by spaces, as in `class`. A key never has a space:
|
||||
* an i18n key has none, and {@link settingsSearchItemKey} encodes the parts of a runtime key.
|
||||
*/
|
||||
export const SETTINGS_SEARCH_ATTRIBUTE = 'data-settings-search'
|
||||
|
||||
/** Route meta fields that the settings search reads. */
|
||||
export interface SettingsSearchRouteMeta {
|
||||
titleKey?: string
|
||||
title?: string
|
||||
descriptionKey?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
/** The i18n keys of the settings on the page. The build-time scan writes them. */
|
||||
searchKeys?: string[]
|
||||
/** For a setting that shows only in some state, the setting that controls it, such as its switch. The build-time scan writes them. */
|
||||
searchGates?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the marker of a runtime item from its kind and its parts, such as a card id and a wake word.
|
||||
*
|
||||
* Use when:
|
||||
* - A provider and its page need the same marker for an item.
|
||||
*
|
||||
* Returns:
|
||||
* - `kind:part:part`, with each part URI-encoded. The marker has no spaces, so a name such as "Hey AIRI" stays one key
|
||||
* in {@link SETTINGS_SEARCH_ATTRIBUTE}.
|
||||
*/
|
||||
export function settingsSearchItemKey(kind: string, ...parts: string[]) {
|
||||
return [kind, ...parts.map(part => encodeURIComponent(part))].join(':')
|
||||
}
|
||||
|
||||
/** The settings that a search result shows on its page. */
|
||||
export interface SettingsSearchTarget {
|
||||
/** The keys of the setting. The page highlights the first one that it shows. */
|
||||
keys: string[]
|
||||
/** The rows to show when no key is on the page, nearest first. */
|
||||
gates?: string[]
|
||||
}
|
||||
|
||||
/** The address of a page that shows the target. Each key and gate is a query parameter of its own. */
|
||||
export function settingsSearchLocation(path: string, target: SettingsSearchTarget) {
|
||||
const params = new URLSearchParams()
|
||||
for (const key of target.keys)
|
||||
params.append(SETTINGS_SEARCH_QUERY, key)
|
||||
for (const gate of target.gates ?? [])
|
||||
params.append(SETTINGS_SEARCH_GATE_QUERY, gate)
|
||||
return `${path}?${params}`
|
||||
}
|
||||
|
||||
/** The target that a route query names. A page without a search target has no keys. */
|
||||
export function settingsSearchTargetOf(query: Readonly<Record<string, unknown>>): Required<SettingsSearchTarget> {
|
||||
const valuesOf = (value: unknown) => (Array.isArray(value) ? value : [value]).filter((item): item is string => typeof item === 'string' && item !== '')
|
||||
return { keys: valuesOf(query[SETTINGS_SEARCH_QUERY]), gates: valuesOf(query[SETTINGS_SEARCH_GATE_QUERY]) }
|
||||
}
|
||||
|
||||
/** One runtime item, such as a character card or an MCP server, that a settings page lists. */
|
||||
export interface SettingsSearchItem {
|
||||
/** The name that a person searches for, such as the card name. */
|
||||
title: string
|
||||
/** Where on the page the item is, when the page lists it in more than one place, such as "Recently deleted". */
|
||||
section?: string
|
||||
/** The marker of the item's row on the page. The row binds the same value to {@link SETTINGS_SEARCH_ATTRIBUTE}. */
|
||||
key: string
|
||||
/**
|
||||
* The markers of the rows to show when the item's row is not on the page, nearest first.
|
||||
* For example, a voice that shows only under some models names those model cards, then the card of its provider.
|
||||
*/
|
||||
gates?: string[]
|
||||
/** A page that shows the item itself, such as the page of one card. Without it, the result opens the listing page at the row. */
|
||||
to?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The export that the build-time scan adds to a provider view, such as the Hearing controls of a transcription provider.
|
||||
* A page renders such a view by provider at runtime, so its own scan cannot reach it. The view lists its settings instead.
|
||||
*/
|
||||
export const SETTINGS_SEARCH_VIEW_EXPORT = 'settingsSearchEntries'
|
||||
|
||||
/** A setting of a provider view: its i18n key, and the rows to show when it is not on the page, nearest first. */
|
||||
export interface SettingsSearchViewEntry {
|
||||
key: string
|
||||
gates: string[]
|
||||
}
|
||||
|
||||
/** What a provider gets from the search, so its names match the page. */
|
||||
export interface SettingsSearchContext {
|
||||
/** Translates a key in the current locale, as the page does. */
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the runtime items of one settings page for the search.
|
||||
*
|
||||
* A page provides it in a sibling file named `<page>.search.ts`, as the default export.
|
||||
* The search calls it when a person starts to search, so it can load data, such as a model catalog.
|
||||
*/
|
||||
export type SettingsSearchProvider = (context: SettingsSearchContext) => SettingsSearchItem[] | Promise<SettingsSearchItem[]>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { LinkGroup, LinkGroupItem } from '@proj-airi/ui'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Link Group" group="misc">
|
||||
<Variant id="default" title="Title, icon, and details">
|
||||
<div :class="['max-w-xl p-4']">
|
||||
<LinkGroup title="Modules" icon="i-solar:layers-bold-duotone">
|
||||
<LinkGroupItem href="#" detail="Consciousness">
|
||||
Model
|
||||
</LinkGroupItem>
|
||||
<LinkGroupItem href="#" detail="Hearing › Speech recognition">
|
||||
Model
|
||||
</LinkGroupItem>
|
||||
<LinkGroupItem href="#" detail="Vision">
|
||||
Use the vision <b :class="['font-semibold']">model</b> for chat images
|
||||
</LinkGroupItem>
|
||||
<LinkGroupItem href="#">
|
||||
Modules
|
||||
</LinkGroupItem>
|
||||
</LinkGroup>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant id="narrow" title="Narrow: the detail cuts first">
|
||||
<div :class="['w-80 p-4']">
|
||||
<LinkGroup title="Providers" icon="i-solar:box-minimalistic-bold-duotone">
|
||||
<LinkGroupItem href="#" detail="Vision › OpenAI-compatible API">
|
||||
API protocol
|
||||
</LinkGroupItem>
|
||||
<LinkGroupItem href="#" detail="Nano Banana">
|
||||
Nano Banana (Google AI Studio)
|
||||
</LinkGroupItem>
|
||||
</LinkGroup>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant id="plain" title="Without title or icon">
|
||||
<div :class="['max-w-xl p-4']">
|
||||
<LinkGroup>
|
||||
<LinkGroupItem href="#">
|
||||
Characters
|
||||
</LinkGroupItem>
|
||||
</LinkGroup>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@ withDefaults(defineProps<{
|
||||
<Collapsible :default="expand">
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
:aria-expanded="slotProps.visible"
|
||||
class="w-full flex items-center justify-between rounded-lg px-3 py-2 outline-none transition-all duration-250 ease-in-out sm:px-4 sm:py-3"
|
||||
text="neutral-600 dark:neutral-400 sm sm:base"
|
||||
bg="neutral-100 dark:neutral-800"
|
||||
|
||||
@@ -33,6 +33,11 @@ interface Props {
|
||||
* expand/collapse stays visible above the grid scroll area (e.g. onboarding modal).
|
||||
*/
|
||||
fillAvailableHeight?: boolean
|
||||
/**
|
||||
* The settings search marker of each item, such as a model's key. A settings page passes it,
|
||||
* so a search result for an item scrolls to that item. Without it, the items have no marker.
|
||||
*/
|
||||
searchKeyOf?: (item: Item) => string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -209,6 +214,7 @@ function updateCustomValue(value: string) {
|
||||
:id="item.id"
|
||||
:key="item.id"
|
||||
v-model="modelValue"
|
||||
:data-settings-search="props.searchKeyOf?.(item)"
|
||||
:value="item.id"
|
||||
:title="item.name"
|
||||
:description="item.description"
|
||||
|
||||
@@ -47,6 +47,11 @@ interface Voice {
|
||||
|
||||
interface Props {
|
||||
voices: Voice[]
|
||||
/**
|
||||
* The settings search marker of each voice. A settings page passes it,
|
||||
* so a search result for a voice scrolls to that voice. Without it, the voices have no marker.
|
||||
*/
|
||||
searchKeyOf?: (voice: Voice) => string
|
||||
columns?: number
|
||||
searchable?: boolean
|
||||
searchPlaceholder?: string
|
||||
@@ -387,6 +392,7 @@ const customVoiceName = ref('')
|
||||
:key="voice.id"
|
||||
v-model:voice-id="voiceId"
|
||||
v-model:custom-voice-name="customVoiceName"
|
||||
:data-settings-search="props.searchKeyOf?.(voice)"
|
||||
name="voice"
|
||||
class="snap-start"
|
||||
:voice="voice"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { chatStickers } from '../../assets/stickers'
|
||||
import { StickerValidationError, validateStickerImage } from '../../libs/stickers/import'
|
||||
import { useStickersStore } from '../../stores/modules/stickers'
|
||||
import { stickerDisplayName, stickerSearchKey, useStickersStore } from '../../stores/modules/stickers'
|
||||
import { stickerEmotions } from '../../types/sticker'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -33,8 +33,7 @@ const previewSrc = computed(() => validFile.value && preview.value ? preview.val
|
||||
const builtin = computed(() => chatStickers.some(sticker => sticker.id === editingId.value))
|
||||
|
||||
function displayName(entry: StickerEntry) {
|
||||
const bundled = chatStickers.find(sticker => sticker.id === entry.id)
|
||||
return bundled && entry.name === bundled.description ? t(`settings.pages.modules.stickers.artwork.${entry.id}`) : entry.name
|
||||
return stickerDisplayName(entry, t)
|
||||
}
|
||||
|
||||
function showError(cause: unknown) {
|
||||
@@ -164,7 +163,7 @@ async function remove(entry: StickerEntry) {
|
||||
{{ formError }}
|
||||
</p>
|
||||
<ul :aria-label="t('settings.pages.modules.stickers.catalog')" :class="['grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4']">
|
||||
<li v-for="entry in entries" :key="entry.id" :class="['flex flex-col items-center gap-3', 'rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900']">
|
||||
<li v-for="entry in entries" :key="entry.id" :data-settings-search="stickerSearchKey(entry.id)" :class="['flex flex-col items-center gap-3', 'rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900']">
|
||||
<img :src="artwork[entry.assetId]" :alt="displayName(entry)" width="96" height="96" :class="['size-24 object-contain']">
|
||||
<span :class="['max-w-full break-words text-center text-sm font-medium']">{{ displayName(entry) }}</span>
|
||||
<div :class="['flex flex-wrap justify-center gap-1']">
|
||||
|
||||
@@ -18,6 +18,7 @@ function toggleVisible() {
|
||||
<Collapsible w-full>
|
||||
<template #trigger="slotProps">
|
||||
<button
|
||||
:aria-expanded="slotProps.visible"
|
||||
transition="all ease-in-out duration-250"
|
||||
w-full flex items-center gap-1.5 outline-none
|
||||
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
|
||||
|
||||
@@ -5,3 +5,5 @@ export { default as MicrophonePicker } from './microphone-picker.vue'
|
||||
export type * from './model-settings'
|
||||
export { default as ModelCacheManager } from './ModelCacheManager.vue'
|
||||
export { default as OptionList } from './option-list.vue'
|
||||
export { default as SettingsSearchField } from './settings-search-field.vue'
|
||||
export { default as SettingsSearchResults } from './settings-search-results.vue'
|
||||
|
||||
@@ -24,7 +24,7 @@ import VRM from './vrm.vue'
|
||||
import { useAiriCardStore } from '../../../../stores/modules/airi-card'
|
||||
import { useSettings } from '../../../../stores/settings'
|
||||
import { ModelSelectorDialog } from '../../dialogs/model-selector'
|
||||
import { resolveModelSettingsPanelRenderer } from './runtime'
|
||||
import { MODEL_PICKER_SEARCH_KEY, resolveModelSettingsPanelRenderer } from './runtime'
|
||||
|
||||
interface ModelSettingsPanelProps {
|
||||
palette: string[]
|
||||
@@ -100,7 +100,7 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
|
||||
</Callout>
|
||||
<div :class="['flex flex-wrap items-center gap-2']">
|
||||
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="stageModelSelectedDisplayModel" @pick="handleModelPick">
|
||||
<Button>
|
||||
<Button :data-settings-search="MODEL_PICKER_SEARCH_KEY">
|
||||
{{ t('settings.model-select.select-model.button') }}
|
||||
</Button>
|
||||
</ModelSelectorDialog>
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { StageAvatarBoundsPayload, StageViewState } from '@proj-airi/stage-
|
||||
|
||||
import type { StageModelRenderer } from '../../../../stores/settings/stage-model'
|
||||
|
||||
/**
|
||||
* The marker of the button that opens the model picker. The display models live in that dialog,
|
||||
* so a settings search result for a model points to this button.
|
||||
*/
|
||||
export const MODEL_PICKER_SEARCH_KEY = 'display-model-picker'
|
||||
|
||||
export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'spine' | 'tachie' | 'mmd' | 'godot'
|
||||
export type ModelSettingsRuntimePhase = 'pending' | 'loading' | 'binding' | 'mounted' | 'no-model' | 'error'
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import SettingsSearchField from './settings-search-field.vue'
|
||||
|
||||
const query = shallowRef('model')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Settings Search Field" group="misc">
|
||||
<Variant id="default" title="With a query">
|
||||
<div :class="['flex flex-col gap-4 p-4']">
|
||||
<SettingsSearchField v-model="query" />
|
||||
<p>Query: {{ query }}. Press Cmd+F or Ctrl+F anywhere to focus the field.</p>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@proj-airi/ui'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const query = defineModel<string>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
const field = useTemplateRef<HTMLElement>('field')
|
||||
|
||||
// Cmd+F or Ctrl+F, the find shortcut of the browser, finds settings while this field is on the page.
|
||||
// It moves the focus to the field from anywhere on the page, and selects the old query, so typing replaces it.
|
||||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||
const command = event.metaKey || event.ctrlKey
|
||||
if (!command || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'f')
|
||||
return
|
||||
event.preventDefault()
|
||||
const input = field.value?.querySelector('input')
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="field" :class="['settings-search-field', 'relative']">
|
||||
<div :class="['i-solar:magnifer-linear', 'pointer-events-none absolute left-3 top-1/2 -translate-y-1/2', 'text-neutral-400 dark:text-neutral-500']" />
|
||||
<Input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('settings.search.placeholder')"
|
||||
:aria-label="t('settings.search.placeholder')"
|
||||
:class="['pl-9!']"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The clear button is the native one of a search field, drawn as the magnifier on the left is: a linear icon in light gray. */
|
||||
.settings-search-field :deep(input[type='search']::-webkit-search-cancel-button) {
|
||||
--at-apply: 'i-solar:close-circle-linear size-4 text-neutral-400 dark:text-neutral-500';
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-search-field :deep(input[type='search']::-webkit-search-cancel-button:hover) {
|
||||
--at-apply: 'text-neutral-600 dark:text-neutral-300';
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingsSearchCategory, SettingsSearchResult } from '../../../composables/use-settings-search'
|
||||
|
||||
import { LinkGroup, LinkGroupItem } from '@proj-airi/ui'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
results: SettingsSearchResult[]
|
||||
/** The text that the person searched for. The matching parts of each name are bold. */
|
||||
query: string
|
||||
}>()
|
||||
|
||||
/** Joins the pages of a location, for example "Hearing › Wake words". */
|
||||
const LOCATION_SEPARATOR = ' › '
|
||||
|
||||
interface Group {
|
||||
category: SettingsSearchCategory
|
||||
results: SettingsSearchResult[]
|
||||
/** The place of the best result of the group in the result list. */
|
||||
rank: number
|
||||
}
|
||||
|
||||
/** The results by category. A category comes in the order of its best result. */
|
||||
const groups = computed(() => {
|
||||
const byCategory = new Map<string, Group>()
|
||||
for (const result of props.results) {
|
||||
const group = byCategory.get(result.category.title) ?? { category: result.category, results: [], rank: byCategory.size }
|
||||
group.results.push(result)
|
||||
byCategory.set(result.category.title, group)
|
||||
}
|
||||
return [...byCategory.values()]
|
||||
})
|
||||
|
||||
// Cards stay whole, in one grid, in the order of the results. A card that changes column keeps its element,
|
||||
// so a focused link keeps the focus. Until the cards are measured, they stand in one column.
|
||||
const container = useTemplateRef<HTMLElement>('container')
|
||||
const cards = useTemplateRef<Array<InstanceType<typeof LinkGroup>>>('cards')
|
||||
const width = ref(0)
|
||||
const gap = ref(0)
|
||||
/** The narrowest column in which every card fits its text. */
|
||||
const columnWidth = ref(0)
|
||||
/** The height of each card, by category. */
|
||||
const heights = ref(new Map<string, number>())
|
||||
|
||||
function measure() {
|
||||
if (!container.value)
|
||||
return
|
||||
width.value = container.value.clientWidth
|
||||
gap.value = Number.parseFloat(getComputedStyle(container.value).columnGap) || 0
|
||||
const measured = new Map<string, number>()
|
||||
let widest = 0
|
||||
for (const card of cards.value ?? []) {
|
||||
const element = card.$el as HTMLElement
|
||||
measured.set(element.dataset.category ?? '', element.offsetHeight)
|
||||
widest = Math.max(widest, card.naturalWidth())
|
||||
}
|
||||
columnWidth.value = widest
|
||||
const changed = measured.size !== heights.value.size || [...measured].some(([title, height]) => heights.value.get(title) !== height)
|
||||
if (changed)
|
||||
heights.value = measured
|
||||
}
|
||||
useResizeObserver(container, measure)
|
||||
watch(groups, measure, { flush: 'post' })
|
||||
|
||||
/**
|
||||
* The number of columns and the column of each card, by category. `undefined` until the cards are measured.
|
||||
* Each count that fits gets the cards tallest first, each into the shortest column. The lowest tallest column wins,
|
||||
* then the smaller height difference, then the fewer columns.
|
||||
*/
|
||||
const layout = computed(() => {
|
||||
if (columnWidth.value <= 0 || !groups.value.every(group => heights.value.has(group.category.title)))
|
||||
return undefined
|
||||
const fits = Math.floor((width.value + gap.value) / (columnWidth.value + gap.value))
|
||||
const heightOf = (group: Group) => heights.value.get(group.category.title) ?? 0
|
||||
const tallestFirst = groups.value.toSorted((left, right) => heightOf(right) - heightOf(left) || left.rank - right.rank)
|
||||
const arrange = (count: number) => {
|
||||
const columns = Array.from({ length: count }, () => ({ height: 0, groups: [] as Group[] }))
|
||||
for (const group of tallestFirst) {
|
||||
const shortest = columns.reduce((lowest, column) => column.height < lowest.height ? column : lowest)
|
||||
shortest.groups.push(group)
|
||||
shortest.height += heightOf(group) + gap.value
|
||||
}
|
||||
const heightsOf = columns.map(column => column.height)
|
||||
return { count, columns, tallest: Math.max(...heightsOf), spread: Math.max(...heightsOf) - Math.min(...heightsOf) }
|
||||
}
|
||||
const counts = Array.from({ length: Math.max(1, Math.min(fits, groups.value.length)) }, (_, index) => index + 1)
|
||||
const { count, columns } = counts.map(arrange).reduce((best, next) =>
|
||||
next.tallest < best.tallest || (next.tallest === best.tallest && next.spread < best.spread) ? next : best)
|
||||
// The column with the best card comes first. The DOM order keeps the order of the results in each column.
|
||||
const bestRank = (column: { groups: Group[] }) => Math.min(...column.groups.map(group => group.rank))
|
||||
const columnOf = new Map<string, number>()
|
||||
columns.toSorted((left, right) => bestRank(left) - bestRank(right))
|
||||
.forEach((column, index) => column.groups.forEach(group => columnOf.set(group.category.title, index)))
|
||||
return { count, columnOf }
|
||||
})
|
||||
|
||||
// The grid has rows of 1px, and each card spans as many rows as its height and the gap below it.
|
||||
// A card with a set column takes the first free rows in that column, so the cards of a column stack in the order of the results.
|
||||
const gridStyle = computed(() => layout.value
|
||||
? { gridTemplateColumns: `repeat(${layout.value.count}, minmax(0, 1fr))`, gridAutoRows: '1px', gridAutoFlow: 'row dense', rowGap: '0px' }
|
||||
: { gridTemplateColumns: 'minmax(0, 1fr)' })
|
||||
function cardStyle(group: Group) {
|
||||
const column = layout.value?.columnOf.get(group.category.title)
|
||||
return column === undefined
|
||||
? undefined
|
||||
: { gridColumn: `${column + 1}`, gridRowEnd: `span ${(heights.value.get(group.category.title) ?? 0) + gap.value}` }
|
||||
}
|
||||
|
||||
const needle = computed(() => props.query.trim().toLocaleLowerCase())
|
||||
|
||||
/** The parts of a name, with each part that matches the query marked. Bold marks a match, so color stays for hover. */
|
||||
function partsOf(title: string) {
|
||||
const query = needle.value
|
||||
const lower = title.toLocaleLowerCase()
|
||||
const parts: Array<{ text: string, match: boolean }> = []
|
||||
let from = 0
|
||||
for (let at = query ? lower.indexOf(query) : -1; at !== -1; at = lower.indexOf(query, from)) {
|
||||
if (at > from)
|
||||
parts.push({ text: title.slice(from, at), match: false })
|
||||
parts.push({ text: title.slice(at, at + query.length), match: true })
|
||||
from = at + query.length
|
||||
}
|
||||
if (from < title.length)
|
||||
parts.push({ text: title.slice(from), match: false })
|
||||
return parts
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" :class="['grid items-start gap-3']" :style="gridStyle">
|
||||
<LinkGroup
|
||||
v-for="group in groups"
|
||||
ref="cards"
|
||||
:key="group.category.title"
|
||||
:data-category="group.category.title"
|
||||
:title="group.category.title"
|
||||
:icon="group.category.icon"
|
||||
:style="cardStyle(group)"
|
||||
>
|
||||
<LinkGroupItem
|
||||
v-for="result in group.results"
|
||||
:key="`${result.to}\n${result.title}`"
|
||||
:as="RouterLink"
|
||||
:to="result.to"
|
||||
:detail="result.location.join(LOCATION_SEPARATOR)"
|
||||
>
|
||||
<template v-for="(part, partIndex) in partsOf(result.title)" :key="partIndex">
|
||||
<b v-if="part.match" :class="['font-semibold']">{{ part.text }}</b>
|
||||
<template v-else>
|
||||
{{ part.text }}
|
||||
</template>
|
||||
</template>
|
||||
</LinkGroupItem>
|
||||
</LinkGroup>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,443 @@
|
||||
import type { SettingsSearchItem, SettingsSearchProvider, SettingsSearchRouteMeta } from '@proj-airi/stage-shared/settings-search'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { ProviderMetadata } from '../libs/providers/metadata'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { SETTINGS_SEARCH_ATTRIBUTE, settingsSearchLocation, settingsSearchTargetOf } from '@proj-airi/stage-shared/settings-search'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onScopeDispose, shallowRef, toValue, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { useProviderStore } from '../stores/providers/provider'
|
||||
|
||||
/** The top-level settings page that holds a search result, such as "Modules". */
|
||||
export interface SettingsSearchCategory {
|
||||
title: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/** A page or a setting that a settings search can open. */
|
||||
export interface SettingsSearchResult {
|
||||
/** The name of the page or the setting. */
|
||||
title: string
|
||||
/** The top-level page that holds the result. A top-level page is its own category. */
|
||||
category: SettingsSearchCategory
|
||||
/** The pages and the section between the category and the result, from the top. */
|
||||
location: string[]
|
||||
to: string
|
||||
}
|
||||
|
||||
/** A settings page in the search index. */
|
||||
interface SearchPage {
|
||||
title: string
|
||||
/** The titles of the pages above this page, from the top. The first one is the category. */
|
||||
location: string[]
|
||||
category: SettingsSearchCategory
|
||||
searchKeys: string[]
|
||||
}
|
||||
|
||||
const SETTINGS_ROOT = '/settings'
|
||||
const PROVIDERS_PAGE = '/settings/providers'
|
||||
/** A page can render its rows after async data loads. Stop waiting for the row after this time. */
|
||||
const REVEAL_TIMEOUT_MS = 3000
|
||||
/** When the page stops changing for this long and the row is still missing, the current state hides it. */
|
||||
const REVEAL_QUIET_MS = 600
|
||||
/** A page can render its rows again, such as after it reloads a list. The highlight follows the new row for this long. */
|
||||
const REVEAL_FOLLOW_MS = 10000
|
||||
/** Any of these inputs means that the person took over the page, so the search stops moving it. */
|
||||
const TAKE_OVER_EVENTS = ['wheel', 'touchstart', 'pointerdown', 'keydown'] as const
|
||||
/** How long the row keeps its highlight before it fades out. */
|
||||
const HIGHLIGHT_MS = 1600
|
||||
/** The highlight is an outline like the focus outline of AIRI buttons. It keeps the surface of the row. */
|
||||
const HIGHLIGHT_CLASSES = ['outline-2', 'outline-solid']
|
||||
/** The outline sits outside the row when it fits there, and inside the row when a parent would cut it. */
|
||||
const HIGHLIGHT_OUTSIDE_CLASS = 'outline-offset-4'
|
||||
const HIGHLIGHT_INSIDE_CLASS = '-outline-offset-2'
|
||||
/** The room that the outline takes outside the row: its offset and its width, in pixels. */
|
||||
const HIGHLIGHT_OUTSIDE_ROOM = 6
|
||||
const HIGHLIGHT_COLOR_CLASS = 'outline-primary-400/60'
|
||||
/** The outline fades to transparent. Its style must stay solid until then, because a style change does not animate. */
|
||||
const HIGHLIGHT_FADE_CLASSES = ['transition-[outline-color]', 'duration-500']
|
||||
const HIGHLIGHT_FADE_MS = 500
|
||||
const HIGHLIGHT_FADED_CLASS = 'outline-transparent'
|
||||
/** A row without its own corners gets these, so the outline is not square. A card keeps its corners. */
|
||||
const HIGHLIGHT_CORNER_CLASSES = ['rounded-lg']
|
||||
|
||||
/**
|
||||
* Finds settings pages, the settings on them, and providers by name.
|
||||
*
|
||||
* Use when:
|
||||
* - The settings home filters its list as the user types.
|
||||
*
|
||||
* Expects:
|
||||
* - Settings routes keep their titles in route meta, as the settings layout reads them.
|
||||
* - The build-time scan in `@proj-airi/stage-pages/vite/settings-search` wrote `searchKeys` into the route meta.
|
||||
* - `providers` lists the runtime items of each page, such as character cards, by route path.
|
||||
* The search calls them each time a person starts to search, so the items are fresh.
|
||||
*
|
||||
* Returns:
|
||||
* - The results in index order, with titles that start with the query first. An empty query returns no results.
|
||||
*/
|
||||
export function useSettingsSearch(
|
||||
query: MaybeRefOrGetter<string>,
|
||||
providers: Readonly<Record<string, () => Promise<SettingsSearchProvider>>> = {},
|
||||
) {
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
allChatProvidersMetadata,
|
||||
allVisionProvidersMetadata,
|
||||
allAudioSpeechProvidersMetadata,
|
||||
allAudioTranscriptionProvidersMetadata,
|
||||
} = storeToRefs(useProviderStore())
|
||||
|
||||
/** The runtime items of each page, by route path. */
|
||||
const itemsByPath = shallowRef<ReadonlyMap<string, SettingsSearchItem[]>>(new Map())
|
||||
const items = computed(() => [...itemsByPath.value].flatMap(([path, pathItems]) => pathItems.map(item => ({ path, item }))))
|
||||
let loadGeneration = 0
|
||||
|
||||
/** Loads every provider. Each page's items show as soon as they arrive, so a slow catalog does not hold back the cards. */
|
||||
function loadItems() {
|
||||
const generation = ++loadGeneration
|
||||
for (const [path, load] of Object.entries(providers)) {
|
||||
void (async () => {
|
||||
try {
|
||||
const provide = await load()
|
||||
const loaded = await provide({ t })
|
||||
if (generation === loadGeneration)
|
||||
itemsByPath.value = new Map([...itemsByPath.value, [path, loaded]])
|
||||
}
|
||||
catch (error) {
|
||||
// One failing provider, such as an offline model catalog, must not hide the other results.
|
||||
console.warn(`[settings search] The items of ${path} did not load:`, errorMessageFrom(error) ?? error)
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => toValue(query).trim() !== '', (searching) => {
|
||||
if (searching)
|
||||
loadItems()
|
||||
}, { immediate: true })
|
||||
|
||||
const index = computed<SettingsSearchResult[]>(() => {
|
||||
const pages = new Map<string, SearchPage>()
|
||||
|
||||
// Routes with parameters, such as one card, need data that a route does not have. Providers name theirs below.
|
||||
for (const route of router.getRoutes()) {
|
||||
if (!route.path.startsWith(`${SETTINGS_ROOT}/`) || route.path.includes(':'))
|
||||
continue
|
||||
const meta = route.meta as SettingsSearchRouteMeta
|
||||
const title = meta.titleKey ? t(meta.titleKey) : meta.title
|
||||
if (!title)
|
||||
continue
|
||||
pages.set(route.path, {
|
||||
title,
|
||||
location: [],
|
||||
category: { title, icon: meta.icon },
|
||||
searchKeys: meta.searchKeys ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
// The location of a page is the titled pages on its path, from the top. The top one is its category.
|
||||
for (const [path, page] of pages) {
|
||||
const segments = path.slice(SETTINGS_ROOT.length + 1).split('/')
|
||||
const ancestors = segments.slice(0, -1)
|
||||
.map((_, end) => pages.get(`${SETTINGS_ROOT}/${segments.slice(0, end + 1).join('/')}`))
|
||||
.filter(ancestor => ancestor !== undefined)
|
||||
page.location = ancestors.map(ancestor => ancestor.title)
|
||||
page.category = ancestors[0]?.category ?? page.category
|
||||
}
|
||||
|
||||
// Provider pages take their names from the provider registry, as the providers page shows them.
|
||||
// A provider page can be a route with a parameter, so its settings come from the route that its link resolves to.
|
||||
const providersPage = pages.get(PROVIDERS_PAGE)
|
||||
const providerGroups: Array<[string, ProviderMetadata[]]> = [
|
||||
['chat', allChatProvidersMetadata.value],
|
||||
['vision', allVisionProvidersMetadata.value],
|
||||
['speech', allAudioSpeechProvidersMetadata.value],
|
||||
['transcription', allAudioTranscriptionProvidersMetadata.value],
|
||||
]
|
||||
// Some providers keep their settings on a module page, such as a local transcriber on the speech recognition page.
|
||||
// Such a provider is a result of its own, under that page, and the page keeps its own entry and settings.
|
||||
const providerPages: Array<[string, SearchPage]> = []
|
||||
for (const [kind, providers] of providerGroups) {
|
||||
if (!providersPage)
|
||||
break
|
||||
const location = [providersPage.title, t(`settings.pages.providers.categories.${kind}.title`)]
|
||||
for (const provider of providers) {
|
||||
// The providers page hides the No Speech choice. It has no settings.
|
||||
if (!provider.to || provider.id === 'speech-noop')
|
||||
continue
|
||||
const routePage = pages.get(provider.to)
|
||||
const meta = router.resolve(provider.to).matched.at(-1)?.meta as SettingsSearchRouteMeta | undefined
|
||||
providerPages.push([provider.to, {
|
||||
title: provider.localizedName,
|
||||
location: routePage ? [...routePage.location, routePage.title] : location,
|
||||
category: routePage?.category ?? providersPage.category,
|
||||
searchKeys: routePage ? [] : meta?.searchKeys ?? [],
|
||||
}])
|
||||
}
|
||||
}
|
||||
const providerPageByPath = new Map(providerPages.toReversed())
|
||||
|
||||
const results: SettingsSearchResult[] = []
|
||||
// A location starts with the category. The result names the category apart, so its location is what comes after it.
|
||||
const below = (titles: string[]) => titles.slice(1)
|
||||
// A runtime item opens its own page, or the listing page at its row. Its location is the page that lists it.
|
||||
for (const { path, item } of items.value) {
|
||||
const itemPath = (item.to ?? '').split('?')[0]
|
||||
const listing = pages.get(path) ?? pages.get(itemPath) ?? providerPageByPath.get(itemPath)
|
||||
if (!listing)
|
||||
continue
|
||||
results.push({
|
||||
title: item.title,
|
||||
category: listing.category,
|
||||
location: below([...listing.location, listing.title, ...(item.section ? [item.section] : [])]),
|
||||
to: item.to ?? settingsSearchLocation(path, { keys: [item.key], gates: item.gates }),
|
||||
})
|
||||
}
|
||||
for (const [path, page] of [...pages, ...providerPages]) {
|
||||
results.push({
|
||||
title: page.title,
|
||||
category: page.category,
|
||||
location: below(page.location),
|
||||
to: path,
|
||||
})
|
||||
// A section and its switch often share a name, such as "Microphone", and each model type has its own "Scale".
|
||||
// One result per name and page lists all those keys, so the page highlights whichever one it shows.
|
||||
const keysByTitle = new Map<string, string[]>()
|
||||
for (const key of page.searchKeys) {
|
||||
const title = t(key)
|
||||
if (title !== page.title)
|
||||
keysByTitle.set(title, [...keysByTitle.get(title) ?? [], key])
|
||||
}
|
||||
for (const [title, keys] of keysByTitle) {
|
||||
results.push({
|
||||
title,
|
||||
category: page.category,
|
||||
location: below([...page.location, page.title]),
|
||||
to: settingsSearchLocation(path, { keys }),
|
||||
})
|
||||
}
|
||||
}
|
||||
// The same name at the same place can come twice, such as a provider page and the provider's card on that page.
|
||||
// Runtime items come first, so the result that points to a row stays.
|
||||
const seen = new Set<string>()
|
||||
return results.filter((result) => {
|
||||
const key = [result.to.split('?')[0], result.title, result.category.title, ...result.location].join('\n')
|
||||
if (seen.has(key))
|
||||
return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
return computed(() => {
|
||||
const needle = toValue(query).trim().toLocaleLowerCase()
|
||||
if (!needle)
|
||||
return []
|
||||
|
||||
const hits = index.value.filter(result => result.title.toLocaleLowerCase().includes(needle))
|
||||
return [
|
||||
...hits.filter(result => result.title.toLocaleLowerCase().startsWith(needle)),
|
||||
...hits.filter(result => !result.title.toLocaleLowerCase().startsWith(needle)),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings that the current search result names on this page.
|
||||
*
|
||||
* Use when:
|
||||
* - A page renders only part of a long list, such as a batch of devices. It brings the named row into the rendered part.
|
||||
*
|
||||
* Returns:
|
||||
* - The keys and the gates of the route query. Both are empty when the page did not open from a search.
|
||||
*/
|
||||
export function useSettingsSearchTarget() {
|
||||
const route = useRoute()
|
||||
return computed(() => settingsSearchTargetOf(route.query))
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the setting that a search result names, then highlights its row.
|
||||
*
|
||||
* Use when:
|
||||
* - A settings layout owns the scroll container of its pages.
|
||||
*
|
||||
* Expects:
|
||||
* - The route query names the keys of the setting in `setting` parameters, one key in each.
|
||||
* `setting-gate` parameters can name the rows to show when the setting is not on the page, such as a provider card.
|
||||
* - The build-time scan or the page marked the row with the same key in {@link SETTINGS_SEARCH_ATTRIBUTE},
|
||||
* and the scan wrote the gate of each conditional setting into `meta.searchGates`.
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing. A setting that shows only in another state is not on the page. Its gate, such as the switch that turns it on,
|
||||
* gets the highlight instead. Without a gate, such as a setting of another model type, a toast says so.
|
||||
* - When the page renders the row again, such as after it reloads a list, the highlight moves to the new row.
|
||||
* It stops when the person scrolls, clicks, or types.
|
||||
*/
|
||||
export function useSettingsSearchReveal(container: MaybeRefOrGetter<HTMLElement | null | undefined>) {
|
||||
const route = useRoute()
|
||||
const target = useSettingsSearchTarget()
|
||||
const { t } = useI18n()
|
||||
let stopWaiting: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Whether the outline fits outside the row. A parent that clips its content, such as a horizontal strip of cards,
|
||||
* cuts an outline that reaches past the edges of its content. The check uses positions in the content of each parent,
|
||||
* so it does not depend on where the parent is scrolled.
|
||||
*/
|
||||
function outlineFitsOutside(row: HTMLElement, root: HTMLElement) {
|
||||
const rect = row.getBoundingClientRect()
|
||||
for (let parent = row.parentElement; parent && parent !== root.parentElement; parent = parent.parentElement) {
|
||||
const style = getComputedStyle(parent)
|
||||
if (style.overflowX === 'visible' && style.overflowY === 'visible')
|
||||
continue
|
||||
const box = parent.getBoundingClientRect()
|
||||
const left = rect.left - box.left - parent.clientLeft + parent.scrollLeft
|
||||
const top = rect.top - box.top - parent.clientTop + parent.scrollTop
|
||||
if (left < HIGHLIGHT_OUTSIDE_ROOM || top < HIGHLIGHT_OUTSIDE_ROOM
|
||||
|| left + rect.width + HIGHLIGHT_OUTSIDE_ROOM > parent.scrollWidth
|
||||
|| top + rect.height + HIGHLIGHT_OUTSIDE_ROOM > parent.scrollHeight) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function highlight(row: HTMLElement, root: HTMLElement) {
|
||||
// `inline` centers a row of a horizontal strip too, such as a collapsed list of voice cards.
|
||||
row.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' })
|
||||
const corners = getComputedStyle(row).borderRadius === '0px' ? HIGHLIGHT_CORNER_CLASSES : []
|
||||
const offset = outlineFitsOutside(row, root) ? HIGHLIGHT_OUTSIDE_CLASS : HIGHLIGHT_INSIDE_CLASS
|
||||
const added = [...corners, ...HIGHLIGHT_CLASSES, offset, HIGHLIGHT_COLOR_CLASS]
|
||||
row.classList.add(...added)
|
||||
setTimeout(() => {
|
||||
row.classList.add(...HIGHLIGHT_FADE_CLASSES)
|
||||
// The transition must apply before the color changes, or the outline disappears at once.
|
||||
requestAnimationFrame(() => row.classList.replace(HIGHLIGHT_COLOR_CLASS, HIGHLIGHT_FADED_CLASS))
|
||||
setTimeout(() => row.classList.remove(...added, ...HIGHLIGHT_FADE_CLASSES, HIGHLIGHT_FADED_CLASS), HIGHLIGHT_FADE_MS + 100)
|
||||
}, HIGHLIGHT_MS)
|
||||
}
|
||||
|
||||
/** The gates of the keys, nearest first. A gate can have its own gate, such as a switch inside another section. */
|
||||
function gatesOf(keys: string[], gates: Readonly<Record<string, string>>) {
|
||||
const found: string[] = []
|
||||
for (const key of keys) {
|
||||
let gate = gates[key]
|
||||
while (gate && !found.includes(gate)) {
|
||||
found.push(gate)
|
||||
gate = gates[gate]
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// The layout resets the scroll position after a path change. This watcher runs after it, in the same flush.
|
||||
// The container is a source too, so a window that opens on a search link still finds the row after mount.
|
||||
watch(() => [route.path, target.value, toValue(container)] as const, ([, { keys, gates }, root]) => {
|
||||
stopWaiting?.()
|
||||
// Settings with the same name, such as "Scale" of each model type, share one result. The page shows one of them.
|
||||
if (!keys.length || !root)
|
||||
return
|
||||
|
||||
const meta = route.matched.at(-1)?.meta as SettingsSearchRouteMeta | undefined
|
||||
const gateKeys = [...new Set([...gates, ...gatesOf([...keys, ...gates], meta?.searchGates ?? {})])]
|
||||
const findAny = (candidates: string[]) => {
|
||||
for (const key of candidates) {
|
||||
const row = root.querySelector<HTMLElement>(`[${SETTINGS_SEARCH_ATTRIBUTE}~="${CSS.escape(key)}"]`)
|
||||
if (row)
|
||||
return row
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// The row that has the highlight. When the page renders its rows again, the highlight moves to the new row.
|
||||
let shownRow: HTMLElement | undefined
|
||||
const showRow = (row: HTMLElement) => {
|
||||
shownRow = row
|
||||
highlight(row, root)
|
||||
}
|
||||
|
||||
let gateShown = false
|
||||
let quiet: ReturnType<typeof setTimeout> | undefined
|
||||
const opened = new Set<HTMLElement>()
|
||||
// When the row is missing, the nearest gate on the page decides the next step. A collapsed section opens, because that changes
|
||||
// only the view, and the search waits for the row again. Any other gate, such as a switch, only gets the highlight:
|
||||
// search never changes a setting.
|
||||
const settle = () => {
|
||||
if (shownRow)
|
||||
return
|
||||
for (const key of gateKeys) {
|
||||
const gate = findAny([key])
|
||||
if (!gate)
|
||||
continue
|
||||
const trigger = gate.matches('[aria-expanded]') ? gate : gate.querySelector<HTMLElement>('[aria-expanded]')
|
||||
if (trigger?.getAttribute('aria-expanded') === 'false' && !opened.has(trigger)) {
|
||||
opened.add(trigger)
|
||||
trigger.click()
|
||||
return
|
||||
}
|
||||
if (!gateShown) {
|
||||
gateShown = true
|
||||
highlight(gate, root)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
if (shownRow?.isConnected)
|
||||
return
|
||||
// The row appeared, such as after its data loaded, or the page rendered it again.
|
||||
const found = findAny(keys)
|
||||
if (found) {
|
||||
showRow(found)
|
||||
return
|
||||
}
|
||||
// A shown row that went away comes back with the list. The gates are only for a row that the page has not shown.
|
||||
if (shownRow)
|
||||
return
|
||||
// The page stopped changing and the setting is still missing, so the current state hides it.
|
||||
clearTimeout(quiet)
|
||||
quiet = setTimeout(settle, REVEAL_QUIET_MS)
|
||||
})
|
||||
|
||||
const row = findAny(keys)
|
||||
if (row)
|
||||
showRow(row)
|
||||
else
|
||||
quiet = setTimeout(settle, REVEAL_QUIET_MS)
|
||||
const timeout = setTimeout(() => {
|
||||
if (shownRow)
|
||||
return
|
||||
const gate = findAny(gateKeys)
|
||||
if (gate && !gateShown) {
|
||||
gateShown = true
|
||||
highlight(gate, root)
|
||||
}
|
||||
else if (!gate) {
|
||||
toast.info(t('settings.search.not-shown', { name: t(keys[0] ?? '') }))
|
||||
}
|
||||
}, REVEAL_TIMEOUT_MS)
|
||||
// The search stops following after a while, or as soon as the person scrolls, clicks, or types.
|
||||
const follow = setTimeout(() => stopWaiting?.(), REVEAL_FOLLOW_MS)
|
||||
const takeOver = () => stopWaiting?.()
|
||||
TAKE_OVER_EVENTS.forEach(type => root.addEventListener(type, takeOver, { passive: true }))
|
||||
observer.observe(root, { childList: true, subtree: true })
|
||||
stopWaiting = () => {
|
||||
observer.disconnect()
|
||||
clearTimeout(timeout)
|
||||
clearTimeout(quiet)
|
||||
clearTimeout(follow)
|
||||
TAKE_OVER_EVENTS.forEach(type => root.removeEventListener(type, takeOver))
|
||||
stopWaiting = undefined
|
||||
}
|
||||
}, { flush: 'post', immediate: true })
|
||||
|
||||
onScopeDispose(() => stopWaiting?.())
|
||||
}
|
||||
@@ -125,6 +125,9 @@ export const providerOfficialSpeech = defineProvider({
|
||||
icon: OFFICIAL_ICON,
|
||||
requiresCredentials: false,
|
||||
configuredBy: 'authentication',
|
||||
capabilities: {
|
||||
speech: { transport: 'rest', voicesByModel: true },
|
||||
},
|
||||
createProviderConfig: () => officialConfigSchema,
|
||||
createProvider(_config) {
|
||||
const provider = createOfficialAudioProvider()
|
||||
@@ -247,7 +250,7 @@ export const providerOfficialSpeechStreaming = defineProvider({
|
||||
configuredBy: 'authentication',
|
||||
// The speech adapter selects the WebSocket transport from this capability.
|
||||
capabilities: {
|
||||
speech: { transport: 'bidirectional-ws' },
|
||||
speech: { transport: 'bidirectional-ws', voicesByModel: true },
|
||||
},
|
||||
createProviderConfig: () => officialConfigSchema,
|
||||
createProvider(_config) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { StickerEntry } from '../../types/sticker'
|
||||
|
||||
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
|
||||
import { settingsSearchItemKey } from '@proj-airi/stage-shared/settings-search'
|
||||
import { useBroadcastChannel } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
@@ -9,6 +10,17 @@ import { chatStickers } from '../../assets/stickers'
|
||||
import { stickersRepo } from '../../database/repos/stickers.repo'
|
||||
import { StickerValidationError, validateStickerImage, validateStickerMetadata } from '../../libs/stickers/import'
|
||||
|
||||
/** The marker of a sticker's row in settings. The page and the settings search share it. */
|
||||
export function stickerSearchKey(id: string) {
|
||||
return settingsSearchItemKey('sticker', id)
|
||||
}
|
||||
|
||||
/** The name that settings shows for a sticker: the translated name of a bundled sticker, or the name that its owner gave. */
|
||||
export function stickerDisplayName(entry: StickerEntry, t: (key: string) => string) {
|
||||
const bundled = chatStickers.find(sticker => sticker.id === entry.id)
|
||||
return bundled && entry.name === bundled.description ? t(`settings.pages.modules.stickers.artwork.${entry.id}`) : entry.name
|
||||
}
|
||||
|
||||
/** IndexedDB owns the library. Broadcasts invalidate snapshots without sending image bytes between windows. */
|
||||
export const useStickersStore = defineStore('stickers', () => {
|
||||
const enabled = useLocalStorageManualReset('settings/stickers/enabled', false)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { default as BottomDrawer } from './bottom-drawer.vue'
|
||||
export { default as Collapsible } from './collapsible.vue'
|
||||
export { default as LinkGroupItem } from './link-group-item.vue'
|
||||
export { default as LinkGroup } from './link-group.vue'
|
||||
export { default as Screen } from './screen.vue'
|
||||
export { default as ScrollableArea } from './scrollable-area.vue'
|
||||
export { default as SettingsCard } from './settings-card.vue'
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
/** What a `LinkGroupItem` tells the `LinkGroup` that holds it. */
|
||||
export interface LinkGroupItemHandle {
|
||||
/** The element of the item. The group puts the dots of the item on its own dot grid. */
|
||||
element: () => HTMLElement | undefined
|
||||
/** The narrowest width at which the item does not cut its text. The item measures its own parts. */
|
||||
naturalWidth: () => number
|
||||
}
|
||||
|
||||
export interface LinkGroupContext {
|
||||
/** Adds an item to the group. Call the returned function when the item goes away. */
|
||||
register: (item: LinkGroupItemHandle) => () => void
|
||||
}
|
||||
|
||||
export const linkGroupContextKey: InjectionKey<LinkGroupContext> = Symbol('link-group')
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { inject, onBeforeUnmount, onMounted, useTemplateRef } from 'vue'
|
||||
|
||||
import { linkGroupContextKey } from './link-group-context'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
/** Element or component that the item renders as, such as `RouterLink`. Other attributes, such as `to`, pass to it. */
|
||||
as?: PrimitiveProps['as']
|
||||
/** A short note on the right, such as where the link goes. It shrinks and cuts before the name does. */
|
||||
detail?: string
|
||||
}>(), {
|
||||
as: 'a',
|
||||
})
|
||||
|
||||
const root = useTemplateRef<ComponentPublicInstance>('root')
|
||||
const nameBox = useTemplateRef<HTMLElement>('nameBox')
|
||||
const nameText = useTemplateRef<HTMLElement>('nameText')
|
||||
const detailBox = useTemplateRef<HTMLElement>('detailBox')
|
||||
const detailText = useTemplateRef<HTMLElement>('detailText')
|
||||
const arrow = useTemplateRef<HTMLElement>('arrow')
|
||||
const element = () => root.value?.$el as HTMLElement | undefined
|
||||
|
||||
/**
|
||||
* The narrowest width at which the item does not cut its text: each part at its natural width, with the gaps and the padding.
|
||||
* A text sits in an inline box inside its part. The inline box keeps the full width of the text when the part cuts it.
|
||||
*/
|
||||
function naturalWidth() {
|
||||
const item = element()
|
||||
if (!item)
|
||||
return 0
|
||||
const style = getComputedStyle(item)
|
||||
const textWidth = (box: HTMLElement | null, text: HTMLElement | null) => box && text
|
||||
? Math.max(text.getBoundingClientRect().width, Number.parseFloat(getComputedStyle(box).minWidth) || 0)
|
||||
: undefined
|
||||
const parts = [textWidth(nameBox.value, nameText.value), textWidth(detailBox.value, detailText.value), arrow.value?.offsetWidth]
|
||||
.filter(width => width !== undefined)
|
||||
const gaps = Number.parseFloat(style.columnGap) * Math.max(0, parts.length - 1)
|
||||
const padding = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight)
|
||||
return parts.reduce((sum, width) => sum + width, 0) + gaps + padding
|
||||
}
|
||||
|
||||
const group = inject(linkGroupContextKey, undefined)
|
||||
let unregister: (() => void) | undefined
|
||||
onMounted(() => {
|
||||
unregister = group?.register({ element, naturalWidth })
|
||||
})
|
||||
onBeforeUnmount(() => unregister?.())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
ref="root"
|
||||
:as="as"
|
||||
:class="[
|
||||
'link-group-item',
|
||||
'relative isolate overflow-hidden',
|
||||
'flex items-center gap-3',
|
||||
'rounded-lg py-2.5 pl-3 pr-2.5',
|
||||
'outline-none',
|
||||
]"
|
||||
>
|
||||
<!-- The name keeps its width. The detail shrinks and cuts first. -->
|
||||
<span ref="nameBox" :class="['link-group-item-name', 'min-w-[4em] flex-[0_1_auto] truncate', 'text-base']">
|
||||
<span ref="nameText"><slot /></span>
|
||||
</span>
|
||||
<span
|
||||
v-if="detail || $slots.detail"
|
||||
ref="detailBox"
|
||||
:class="[
|
||||
'link-group-item-detail',
|
||||
'min-w-[3em] flex-[1_4_auto] truncate text-right',
|
||||
'text-[13px] leading-[18px] text-neutral-400 dark:text-neutral-500',
|
||||
]"
|
||||
>
|
||||
<span ref="detailText"><slot name="detail">{{ detail }}</slot></span>
|
||||
</span>
|
||||
<div
|
||||
ref="arrow"
|
||||
:class="[
|
||||
'link-group-item-arrow',
|
||||
'i-solar:alt-arrow-right-linear ml-auto size-4 shrink-0',
|
||||
'text-neutral-400 dark:text-neutral-500',
|
||||
]"
|
||||
/>
|
||||
</Primitive>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The theme color sweeps in from the left and fills the item, fading toward the right. */
|
||||
.link-group-item::before {
|
||||
--at-apply: 'bg-gradient-to-r from-primary-500/20 via-primary-500/10 to-transparent dark:from-primary-400/20 dark:via-primary-400/10 dark:to-transparent';
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
width: 30%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
/*
|
||||
* The dots under the item light up in the theme color. LinkGroup shifts them onto its own dot grid.
|
||||
* The dots take the text color: `bg-dotted-[primary-300/50]` drops theme colors, and a text color utility keeps them.
|
||||
*/
|
||||
.link-group-item::after {
|
||||
--at-apply: 'bg-dotted-[current] text-primary-300/50 dark:text-primary-200/20';
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background-size: 10px 10px;
|
||||
background-position: var(--link-group-dot-x, 0) var(--link-group-dot-y, 0);
|
||||
mask-image: linear-gradient(to right, white 10%, transparent 75%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.link-group-item:hover::before,
|
||||
.link-group-item:focus-visible::before {
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.link-group-item:hover::after,
|
||||
.link-group-item:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Keyboard focus looks like hover, with an outline as well. */
|
||||
.link-group-item:focus-visible {
|
||||
--at-apply: 'outline-2 outline-solid -outline-offset-2 outline-primary-300 dark:outline-primary-400/50';
|
||||
}
|
||||
|
||||
.link-group-item-name,
|
||||
.link-group-item-detail {
|
||||
transition: color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.link-group-item-arrow {
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.link-group-item:hover .link-group-item-name,
|
||||
.link-group-item:focus-visible .link-group-item-name {
|
||||
--at-apply: text-primary-600 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.link-group-item:hover .link-group-item-detail,
|
||||
.link-group-item:focus-visible .link-group-item-detail {
|
||||
--at-apply: text-primary-600 dark:text-primary-300;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.link-group-item:hover .link-group-item-arrow,
|
||||
.link-group-item:focus-visible .link-group-item-arrow {
|
||||
--at-apply: text-primary-500 dark:text-primary-400;
|
||||
opacity: 1;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkGroupItemHandle } from './link-group-context'
|
||||
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { nextTick, provide, useTemplateRef } from 'vue'
|
||||
|
||||
import { linkGroupContextKey } from './link-group-context'
|
||||
|
||||
defineProps<{
|
||||
/** The name of the group, shown small in the top-left corner. */
|
||||
title?: string
|
||||
/** An Iconify class, such as `i-solar:layers-bold-duotone`. It shows next to the title, and large and faded in the bottom-right corner. */
|
||||
icon?: string
|
||||
}>()
|
||||
|
||||
const group = useTemplateRef<HTMLElement>('group')
|
||||
const items = new Set<LinkGroupItemHandle>()
|
||||
|
||||
// The dots of a hovered item sit on the dot grid of the group. The group shifts them by the offset of the item,
|
||||
// again when its size changes, as items come or go.
|
||||
function alignDots() {
|
||||
for (const item of items) {
|
||||
const element = item.element()
|
||||
element?.style.setProperty('--link-group-dot-x', `${-element.offsetLeft}px`)
|
||||
element?.style.setProperty('--link-group-dot-y', `${-element.offsetTop}px`)
|
||||
}
|
||||
}
|
||||
useResizeObserver(group, alignDots)
|
||||
|
||||
// Many items mount in one update, so they share one alignment after it.
|
||||
let alignScheduled = false
|
||||
function scheduleAlign() {
|
||||
if (alignScheduled)
|
||||
return
|
||||
alignScheduled = true
|
||||
void nextTick(() => {
|
||||
alignScheduled = false
|
||||
alignDots()
|
||||
})
|
||||
}
|
||||
|
||||
provide(linkGroupContextKey, {
|
||||
register(item) {
|
||||
items.add(item)
|
||||
scheduleAlign()
|
||||
return () => items.delete(item)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* The narrowest width at which no item cuts its text: the widest item, with the padding and the border of the group around it.
|
||||
* A layout can use it to decide how many groups fit side by side.
|
||||
*/
|
||||
function naturalWidth() {
|
||||
const element = group.value
|
||||
if (!element)
|
||||
return 0
|
||||
const style = getComputedStyle(element)
|
||||
const frame = element.offsetWidth - element.clientWidth + Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight)
|
||||
return Math.ceil(Math.max(0, ...[...items].map(item => item.naturalWidth())) + frame)
|
||||
}
|
||||
|
||||
defineExpose({ naturalWidth })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="group"
|
||||
:aria-label="title"
|
||||
:class="[
|
||||
'link-group',
|
||||
'relative isolate overflow-hidden',
|
||||
'rounded-xl px-1.5 pb-2 pt-1.5',
|
||||
'border-2 border-solid border-neutral-100 dark:border-neutral-800/25',
|
||||
'bg-neutral-50 dark:bg-neutral-900',
|
||||
]"
|
||||
>
|
||||
<!-- The faded icon sits in the bottom-right corner and the edge cuts it, as on the settings category cards. -->
|
||||
<div
|
||||
v-if="icon"
|
||||
:class="[
|
||||
'link-group-icon',
|
||||
'absolute -bottom-7 -right-2 -z-1 size-28',
|
||||
'text-neutral-400/50 dark:text-neutral-600/50',
|
||||
'transition-all duration-400 ease-in-out',
|
||||
icon,
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
v-if="title"
|
||||
:class="[
|
||||
'flex items-center gap-1.5',
|
||||
'px-3 pb-1 pt-2',
|
||||
'text-sm text-neutral-500 dark:text-neutral-400',
|
||||
]"
|
||||
>
|
||||
<div v-if="icon" :class="['size-4 shrink-0', 'text-neutral-400 dark:text-neutral-500', icon]" />
|
||||
{{ title }}
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The dot texture fills the top-left corner and fades out toward the middle, as on IconItem. */
|
||||
.link-group::after {
|
||||
--at-apply: 'bg-dotted-[neutral-200/60] dark:bg-dotted-[neutral-700/25]';
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background-size: 10px 10px;
|
||||
mask-image: linear-gradient(165deg, white 20%, transparent 40%);
|
||||
}
|
||||
|
||||
.link-group-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Hover only scales the icon. It keeps its gray, so it never covers the last item in the theme color. */
|
||||
.link-group:has(.link-group-item:hover) .link-group-icon,
|
||||
.link-group:has(.link-group-item:focus-visible) .link-group-icon {
|
||||
scale: 1.1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user