feat(stage-pages,stage-ui): search settings, their rows, and runtime items (#3112)

This commit is contained in:
蓝莓🫐
2026-10-26 04:47:15 +07:00
committed by GitHub
parent 74ddd90c04
commit 999da4b0b6
72 changed files with 3561 additions and 36 deletions
+5
View File
@@ -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(),
@@ -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'
@@ -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">
+5
View File
@@ -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
+27
View File
@@ -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:
+6
View File
@@ -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()
+28
View File
@@ -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
@@ -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
+1
View File
@@ -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>