feat(stage-tamagotchi): added fundamental vision

This commit is contained in:
Neko Ayaka
2026-03-20 02:27:21 +08:00
committed by Neko
parent a2e134d498
commit 2dd2a149b8
35 changed files with 3288 additions and 7 deletions
+1
View File
@@ -38,6 +38,7 @@
"@xsai/generate-speech": "catalog:",
"@xsai/stream-transcription": "0.4.0-beta.8",
"animejs": "^4.3.6",
"d3": "catalog:",
"dompurify": "^3.3.1",
"nanoid": "^5.1.6",
"node-vibrant": "^4.0.4",
@@ -0,0 +1,645 @@
<script setup lang="ts">
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { TimelineRange, TimeSeriesChart } from '@proj-airi/stage-ui/components'
import { useChatContextStore } from '@proj-airi/stage-ui/stores/chat/context-store'
import { Button, FieldCheckbox, FieldInput, FieldRange, FieldSelect } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { TooltipArrow, TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
import { computed, ref, watch } from 'vue'
const chatContextStore = useChatContextStore()
const { activeContexts, contextHistory } = storeToRefs(chatContextStore)
const selectedSource = ref('all')
const strategyFilter = ref<'all' | ContextUpdateStrategy>('all')
const textFilter = ref('')
const onlyWithText = ref(false)
const maxHistoryEntries = ref(220)
const followLatest = ref(true)
const showMetadata = ref(false)
const rangeSelection = ref<[number, number] | null>(null)
const selectedEntryId = ref<string | null>(null)
const sourceOptions = computed(() => {
const entries = Object.keys(activeContexts.value).sort()
return [
{ label: 'All modules', value: 'all' },
...entries.map(source => ({ label: source, value: source })),
]
})
const strategyOptions = [
{ label: 'All strategies', value: 'all' },
{ label: 'Replace', value: ContextUpdateStrategy.ReplaceSelf },
{ label: 'Append', value: ContextUpdateStrategy.AppendSelf },
]
const baseHistory = computed(() => {
const query = textFilter.value.trim().toLowerCase()
return contextHistory.value
.filter((entry) => {
if (selectedSource.value === 'all')
return true
return entry.sourceKey === selectedSource.value
})
.filter((entry) => {
if (strategyFilter.value === 'all')
return true
return entry.strategy === strategyFilter.value
})
.filter((entry) => {
if (!onlyWithText.value)
return true
return Boolean(entry.text?.trim())
})
.filter((entry) => {
if (!query)
return true
return [
entry.sourceKey,
entry.text,
entry.id,
entry.contextId,
].filter(Boolean).join(' ').toLowerCase().includes(query)
})
.slice(-maxHistoryEntries.value)
})
const filteredHistory = computed(() => {
if (!rangeSelection.value)
return baseHistory.value
const [start, end] = rangeSelection.value
return baseHistory.value.filter((entry) => {
const timestamp = entry.createdAt ?? 0
return timestamp >= start && timestamp <= end
})
})
const rangeData = computed(() => baseHistory.value.map(entry => entry.createdAt ?? 0))
const activeContextGroups = computed(() => {
const keys = selectedSource.value === 'all'
? Object.keys(activeContexts.value)
: [selectedSource.value]
return keys
.sort()
.map((key) => {
const contexts = activeContexts.value[key] ?? []
const latest = contexts.at(-1)?.createdAt ?? 0
return {
key,
contexts,
latest,
}
})
.sort((a, b) => b.latest - a.latest)
})
const totalActiveContexts = computed(() => {
return Object.values(activeContexts.value).reduce((sum, contexts) => sum + contexts.length, 0)
})
const updateWindowMs = 60_000
const updateBucketCount = 30
const updateBucketMs = updateWindowMs / updateBucketCount
const updateTimeline = computed(() => {
const now = Date.now()
const buckets = Array.from({ length: updateBucketCount }, () => 0)
for (const entry of filteredHistory.value) {
const ts = entry.createdAt || now
const offset = ts - (now - updateWindowMs)
if (offset < 0 || offset > updateWindowMs)
continue
const index = Math.min(updateBucketCount - 1, Math.max(0, Math.floor(offset / updateBucketMs)))
buckets[index] += 1
}
const peak = Math.max(1, ...buckets)
const normalized = buckets.map(value => value / peak)
return {
normalized,
currentValue: normalized[normalized.length - 1] ?? 0,
total: buckets.reduce((sum, value) => sum + value, 0),
}
})
const queueEntries = computed(() => filteredHistory.value.slice(-80))
const selectedEntry = computed(() => {
if (!selectedEntryId.value)
return null
return filteredHistory.value.find(entry => entry.id === selectedEntryId.value) || null
})
const activeFilterSummary = computed(() => {
const filters = []
if (selectedSource.value !== 'all')
filters.push(`module:${selectedSource.value}`)
if (strategyFilter.value !== 'all')
filters.push(`strategy:${formatStrategy(strategyFilter.value)}`)
if (onlyWithText.value)
filters.push('text-only')
if (textFilter.value.trim())
filters.push(`query:${textFilter.value.trim()}`)
if (rangeSelection.value) {
const [start, end] = rangeSelection.value
filters.push(`${new Date(start).toLocaleTimeString()}${new Date(end).toLocaleTimeString()}`)
}
return filters.length ? filters.join(' · ') : 'All updates'
})
function formatStrategy(strategy: ContextUpdateStrategy) {
return strategy === ContextUpdateStrategy.AppendSelf ? 'Append' : 'Replace'
}
function formatTimestamp(timestamp: number) {
return new Date(timestamp).toLocaleTimeString()
}
function formatRelativeTime(timestamp: number | null | undefined) {
if (!timestamp)
return 'Never'
const diffMs = Date.now() - timestamp
const diffSeconds = Math.max(0, Math.floor(diffMs / 1000))
if (diffSeconds < 60)
return `${diffSeconds}s ago`
const diffMinutes = Math.floor(diffSeconds / 60)
if (diffMinutes < 60)
return `${diffMinutes}m ago`
const diffHours = Math.floor(diffMinutes / 60)
return `${diffHours}h ago`
}
function summarizeText(text: string, limit = 120) {
if (!text)
return 'No context text.'
if (text.length <= limit)
return text
return `${text.slice(0, limit)}`
}
function queueDotClass(entry: { strategy: ContextUpdateStrategy }) {
if (entry.strategy === ContextUpdateStrategy.AppendSelf)
return 'bg-emerald-400/80'
return 'bg-amber-400/80'
}
function resetRange() {
rangeSelection.value = null
}
watch(() => filteredHistory.value.length, () => {
if (!followLatest.value)
return
const latest = filteredHistory.value.at(-1)
if (latest)
selectedEntryId.value = latest.id
})
watch([selectedSource, strategyFilter, textFilter, onlyWithText], () => {
if (!followLatest.value)
return
const latest = filteredHistory.value.at(-1)
if (latest)
selectedEntryId.value = latest.id
})
</script>
<template>
<div :class="['flex', 'flex-col', 'gap-6']">
<div
:class="[
'rounded-2xl',
'bg-gradient-to-br',
'from-neutral-100',
'via-white',
'to-primary-100/50',
'p-5',
'shadow-sm',
'dark:from-neutral-900/80',
'dark:via-neutral-950',
'dark:to-primary-900/20',
]"
>
<div :class="['flex', 'flex-col', 'gap-5', 'lg:flex-row', 'lg:items-center', 'lg:justify-between']">
<div :class="['flex', 'flex-col', 'gap-2']">
<div :class="['text-xs', 'uppercase', 'tracking-[0.25em]', 'text-neutral-400']">
Context Observatory
</div>
<div :class="['text-2xl', 'font-semibold', 'text-neutral-800', 'dark:text-neutral-100']">
Active contexts: {{ totalActiveContexts }}
</div>
<div :class="['text-sm', 'text-neutral-500', 'dark:text-neutral-400']">
{{ activeFilterSummary }}
</div>
</div>
<div :class="['grid', 'gap-3', 'md:grid-cols-2', 'xl:grid-cols-4']">
<FieldSelect
v-model="selectedSource"
label="Module"
:options="sourceOptions"
/>
<FieldSelect
v-model="strategyFilter"
label="Strategy"
:options="strategyOptions"
/>
<FieldInput
v-model="textFilter"
label="Search"
placeholder="Find by text / id"
/>
<FieldRange
v-model="maxHistoryEntries"
label="Log depth"
:min="40"
:max="300"
:step="20"
:format-value="value => `${value} entries`"
/>
</div>
</div>
<div :class="['mt-4', 'grid', 'gap-4', 'md:grid-cols-3']">
<FieldCheckbox
v-model="onlyWithText"
label="Text-only updates"
description="Hide empty or metadata-only updates."
/>
<FieldCheckbox
v-model="followLatest"
label="Follow latest"
description="Auto-select newest update."
/>
<FieldCheckbox
v-model="showMetadata"
label="Show metadata"
description="Display raw metadata in details."
/>
</div>
</div>
<div :class="['grid', 'gap-6', 'xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]']">
<div :class="['flex', 'flex-col', 'gap-6']">
<div
:class="[
'rounded-2xl',
'bg-neutral-100',
'p-4',
'shadow-sm',
'dark:bg-[rgba(0,0,0,0.35)]',
]"
>
<div :class="['flex', 'items-center', 'justify-between']">
<div :class="['text-sm', 'uppercase', 'tracking-wide', 'text-neutral-400']">
Cadence
</div>
<div :class="['text-xs', 'text-neutral-400']">
{{ updateTimeline.total }} updates / min
</div>
</div>
<TimeSeriesChart
:is-active="false"
:history="updateTimeline.normalized"
:current-value="updateTimeline.currentValue"
:show-header="false"
:show-legend="false"
:show-current-value="false"
:show-area="true"
:show-active-indicator="false"
:height="96"
unit="/min"
:precision="0"
/>
<div :class="['mt-4', 'flex', 'items-center', 'justify-between']">
<div :class="['text-sm', 'uppercase', 'tracking-wide', 'text-neutral-400']">
Timeline range
</div>
<Button
size="sm"
variant="secondary"
:disabled="!rangeSelection"
:label="rangeSelection ? 'Reset range' : 'Full range'"
@click="resetRange"
/>
</div>
<TimelineRange
v-model:range="rangeSelection"
:data="rangeData"
:height="140"
:bins="48"
/>
</div>
<div
:class="[
'rounded-2xl',
'bg-neutral-100',
'p-4',
'shadow-sm',
'dark:bg-[rgba(0,0,0,0.35)]',
]"
>
<div :class="['flex', 'items-center', 'justify-between']">
<div :class="['text-sm', 'uppercase', 'tracking-wide', 'text-neutral-400']">
Active contexts
</div>
<div :class="['text-xs', 'text-neutral-400']">
{{ activeContextGroups.length }} modules
</div>
</div>
<div :class="['mt-3', 'grid', 'gap-3', 'lg:grid-cols-2']">
<div
v-for="group in activeContextGroups"
:key="group.key"
:class="[
'rounded-xl',
'border',
'border-neutral-200',
'bg-white',
'p-3',
'dark:border-neutral-800',
'dark:bg-neutral-900',
]"
>
<div :class="['flex', 'items-center', 'justify-between', 'gap-2']">
<div :class="['text-sm', 'font-semibold', 'text-neutral-700', 'dark:text-neutral-200']">
{{ group.key }}
</div>
<div :class="['text-xs', 'text-neutral-400']">
{{ group.contexts.length }} contexts · {{ formatRelativeTime(group.latest) }}
</div>
</div>
<div v-if="group.contexts.length === 0" :class="['mt-2', 'text-xs', 'text-neutral-400']">
No active contexts.
</div>
<div v-else :class="['mt-3', 'max-h-60', 'space-y-3', 'overflow-y-auto', 'pr-1']">
<div
v-for="context in group.contexts.slice().reverse()"
:key="context.id"
:class="[
'rounded-lg',
'border',
'border-neutral-100',
'bg-neutral-50',
'p-3',
'text-sm',
'text-neutral-700',
'dark:border-neutral-800/60',
'dark:bg-neutral-950/60',
'dark:text-neutral-200',
]"
>
<div :class="['flex', 'items-center', 'justify-between', 'text-xs', 'text-neutral-400']">
<span>{{ formatStrategy(context.strategy) }}</span>
<span>{{ context.createdAt ? formatTimestamp(context.createdAt) : 'Unknown' }}</span>
</div>
<div :class="['mt-2', 'whitespace-pre-wrap', 'text-sm', 'text-neutral-600', 'dark:text-neutral-300']">
{{ context.text || 'No context text.' }}
</div>
</div>
</div>
</div>
<div
v-if="activeContextGroups.length === 0"
:class="[
'rounded-xl',
'border-2',
'border-dashed',
'border-neutral-200',
'px-4',
'py-10',
'text-sm',
'text-neutral-400',
'dark:border-neutral-800',
]"
>
No active context modules yet.
</div>
</div>
</div>
</div>
<div :class="['flex', 'flex-col', 'gap-6']">
<div
:class="[
'rounded-2xl',
'bg-neutral-100',
'p-4',
'shadow-sm',
'dark:bg-[rgba(0,0,0,0.35)]',
]"
>
<div :class="['flex', 'items-center', 'justify-between']">
<div :class="['text-sm', 'uppercase', 'tracking-wide', 'text-neutral-400']">
Selected update
</div>
<div :class="['text-xs', 'text-neutral-400']">
{{ selectedEntry ? formatTimestamp(selectedEntry.createdAt || Date.now()) : 'None' }}
</div>
</div>
<div v-if="selectedEntry" :class="['mt-3', 'space-y-3']">
<div :class="['flex', 'items-center', 'gap-2', 'text-xs', 'text-neutral-400']">
<span>{{ selectedEntry.sourceKey }}</span>
<span>·</span>
<span>{{ formatStrategy(selectedEntry.strategy) }}</span>
<span>·</span>
<span>{{ selectedEntry.contextId }}</span>
</div>
<div :class="['whitespace-pre-wrap', 'rounded-lg', 'bg-white', 'p-3', 'text-sm', 'text-neutral-700', 'dark:bg-neutral-900', 'dark:text-neutral-200']">
{{ selectedEntry.text || 'No context text.' }}
</div>
<pre
v-if="showMetadata"
:class="[
'rounded-lg',
'bg-neutral-900',
'p-3',
'text-xs',
'text-neutral-100',
'overflow-auto',
]"
>
{{ JSON.stringify(selectedEntry.metadata ?? {}, null, 2) }}
</pre>
</div>
<div v-else :class="['mt-3', 'text-sm', 'text-neutral-400']">
Pick a queue dot or stream block to inspect.
</div>
</div>
<div
:class="[
'rounded-2xl',
'bg-neutral-100',
'p-4',
'shadow-sm',
'dark:bg-[rgba(0,0,0,0.35)]',
]"
>
<div :class="['flex', 'items-center', 'justify-between']">
<div :class="['text-sm', 'uppercase', 'tracking-wide', 'text-neutral-400']">
Stream blocks
</div>
<div :class="['text-xs', 'text-neutral-400']">
{{ queueEntries.length }} recent updates
</div>
</div>
<div :class="['mt-4', 'grid', 'grid-cols-6', 'gap-2', 'sm:grid-cols-8']">
<TooltipProvider v-for="entry in queueEntries" :key="entry.id" :delay-duration="120">
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
:class="[
'h-9',
'w-9',
'rounded-lg',
'border',
'border-neutral-200',
'bg-white',
'transition',
'duration-150',
'dark:border-neutral-800',
'dark:bg-neutral-900',
selectedEntryId === entry.id ? 'ring-2 ring-primary-500/70' : 'hover:border-neutral-300',
]"
@click="selectedEntryId = entry.id"
>
<span :class="['block', 'h-full', 'w-full', 'rounded-lg', queueDotClass(entry)]" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
:class="[
'rounded-md',
'bg-white',
'px-2',
'py-1',
'text-xs',
'text-neutral-700',
'shadow-md',
'dark:bg-neutral-900',
'dark:text-neutral-200',
]"
>
<div>{{ entry.sourceKey }} · {{ formatStrategy(entry.strategy) }}</div>
<div>{{ entry.createdAt ? formatTimestamp(entry.createdAt) : 'Unknown' }}</div>
<div>{{ summarizeText(entry.text, 80) }}</div>
<TooltipArrow :class="['fill-white', 'dark:fill-neutral-900']" />
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
</div>
<div :class="['mt-4', 'flex', 'flex-col', 'gap-2']">
<button
v-for="entry in filteredHistory.slice().reverse().slice(0, 12)"
:key="entry.id"
type="button"
:class="[
'flex',
'items-start',
'justify-between',
'gap-3',
'rounded-xl',
'border',
'border-neutral-200',
'bg-white',
'p-3',
'text-left',
'transition',
'duration-150',
selectedEntryId === entry.id ? 'border-primary-400/70 shadow-sm' : 'hover:border-neutral-300',
'dark:border-neutral-800',
'dark:bg-neutral-900',
'dark:hover:border-neutral-700',
]"
@click="selectedEntryId = entry.id"
>
<div :class="['flex', 'flex-col', 'gap-1']">
<div :class="['flex', 'items-center', 'gap-2']">
<span :class="['h-2.5', 'w-2.5', 'rounded-full', queueDotClass(entry)]" />
<span :class="['text-xs', 'uppercase', 'tracking-wide', 'text-neutral-400']">
{{ entry.sourceKey }}
</span>
<span :class="['text-xs', 'text-neutral-400']">
{{ formatStrategy(entry.strategy) }}
</span>
</div>
<div :class="['text-sm', 'text-neutral-700', 'dark:text-neutral-200']">
{{ summarizeText(entry.text, 120) }}
</div>
</div>
<TooltipProvider :delay-duration="150">
<TooltipRoot>
<TooltipTrigger as-child>
<div :class="['text-xs', 'text-neutral-400', 'cursor-help']">
{{ entry.createdAt ? formatTimestamp(entry.createdAt) : 'Unknown' }}
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
:class="[
'rounded-md',
'bg-white',
'px-2',
'py-1',
'text-xs',
'text-neutral-700',
'shadow-md',
'dark:bg-neutral-900',
'dark:text-neutral-200',
]"
>
{{ entry.createdAt ? new Date(entry.createdAt).toLocaleString() : 'Unknown timestamp' }}
<TooltipArrow :class="['fill-white', 'dark:fill-neutral-900']" />
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
</button>
<div
v-if="filteredHistory.length === 0"
:class="[
'rounded-xl',
'border-2',
'border-dashed',
'border-neutral-200',
'px-4',
'py-10',
'text-center',
'text-sm',
'text-neutral-400',
'dark:border-neutral-800',
]"
>
No updates yet. Emit a context update to populate the stream.
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,9 +1,387 @@
<script setup lang="ts">
import { WIP } from '@proj-airi/stage-ui/components'
import { Alert, ErrorContainer, RadioCardManySelect, RadioCardSimple } from '@proj-airi/stage-ui/components'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useVisionProcessingStore, useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
const providersStore = useProvidersStore()
const visionStore = useVisionStore()
const visionProcessingStore = useVisionProcessingStore()
const { persistedChatProvidersMetadata, configuredProviders } = storeToRefs(providersStore)
const {
activeProvider,
activeModel,
customModelName,
modelSearchQuery,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
} = storeToRefs(visionStore)
const {
captureIntervalMs,
captureCount,
contextUpdateCount,
lastCaptureAt,
lastContextUpdateAt,
isRunning,
} = storeToRefs(visionProcessingStore)
const { t } = useI18n()
const { trackProviderClick } = useAnalytics()
watch(activeProvider, async (provider) => {
if (!provider)
return
await visionStore.loadModelsForProvider(provider)
}, { immediate: true })
function updateCustomModelName(value: string) {
customModelName.value = value
}
function handleDeleteProvider(providerId: string) {
if (activeProvider.value === providerId) {
activeProvider.value = ''
activeModel.value = ''
}
providersStore.deleteProvider(providerId)
}
const formattedLastCapture = computed(() => formatRelativeTime(lastCaptureAt.value))
const formattedLastContextUpdate = computed(() => formatRelativeTime(lastContextUpdateAt.value))
function formatRelativeTime(timestamp: number | null) {
if (!timestamp)
return 'Never'
const diffMs = Date.now() - timestamp
const diffSeconds = Math.max(0, Math.floor(diffMs / 1000))
if (diffSeconds < 60)
return `${diffSeconds}s ago`
const diffMinutes = Math.floor(diffSeconds / 60)
if (diffMinutes < 60)
return `${diffMinutes}m ago`
const diffHours = Math.floor(diffMinutes / 60)
return `${diffHours}h ago`
}
</script>
<template>
<WIP />
<div :class="['flex', 'flex-col', 'gap-6']">
<div :class="['rounded-xl', 'bg-neutral-50', 'p-4', 'dark:bg-[rgba(0,0,0,0.3)]']">
<div :class="['flex', 'flex-col', 'gap-4']">
<div>
<h2 :class="['text-lg', 'text-neutral-500', 'md:text-2xl', 'dark:text-neutral-500']">
{{ t('settings.pages.providers.title') }}
</h2>
<div :class="['text-neutral-400', 'dark:text-neutral-400']">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.description') }}</span>
</div>
</div>
<div :class="['max-w-full']">
<fieldset
v-if="persistedChatProvidersMetadata.length > 0"
:class="['flex', 'min-w-0', 'flex-row', 'gap-4', 'of-x-scroll', 'scroll-smooth']"
:style="{ 'scrollbar-width': 'none' }"
role="radiogroup"
>
<RadioCardSimple
v-for="metadata in persistedChatProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeProvider"
name="provider"
:value="metadata.id"
:title="metadata.localizedName || 'Unknown'"
:description="metadata.localizedDescription"
@click="trackProviderClick(metadata.id, 'vision')"
>
<template #topRight>
<button
type="button"
:class="[
'rounded',
'bg-neutral-100',
'p-1',
'text-neutral-600',
'transition-colors',
'hover:bg-neutral-200',
'dark:bg-neutral-800/60',
'dark:text-neutral-300',
'dark:hover:bg-neutral-700/60',
]"
@click.stop.prevent="handleDeleteProvider(metadata.id)"
>
<div :class="['text-base', 'i-solar:trash-bin-trash-bold-duotone']" />
</button>
</template>
<template v-if="configuredProviders[metadata.id] === false" #bottomRight>
<div
:class="[
'rounded',
'bg-amber-100',
'px-2',
'py-0.5',
'text-xs',
'font-medium',
'text-amber-700',
'dark:bg-amber-900/30',
'dark:text-amber-300',
]"
>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.health_check_failed') }}
</div>
</template>
</RadioCardSimple>
<RouterLink
to="/settings/providers"
:class="[
'relative',
'min-w-50',
'w-fit',
'rounded-xl',
'border-2',
'border-neutral-100',
'bg-white',
'p-4',
'transition',
'duration-200',
'ease-in-out',
'hover:border-primary-500/30',
'dark:border-neutral-900',
'dark:bg-neutral-900/20',
'dark:hover:border-primary-400/30',
'flex',
'items-center',
'justify-center',
]"
>
<div :class="['text-2xl', 'text-neutral-500', 'dark:text-neutral-500', 'i-solar:add-circle-line-duotone']" />
<div
:class="['absolute', 'inset-0', 'z--1', 'bg-dotted-neutral-200/80', 'dark:bg-dotted-neutral-700/50']"
:style="{ 'background-size': '10px 10px', 'mask-image': 'linear-gradient(165deg, white 30%, transparent 50%)' }"
/>
</RouterLink>
</fieldset>
<div v-else>
<RouterLink
to="/settings/providers"
:class="[
'flex',
'items-center',
'gap-3',
'rounded-lg',
'border-2',
'border-dashed',
'border-neutral-200',
'bg-neutral-50',
'p-4',
'transition',
'duration-200',
'ease-in-out',
'dark:border-neutral-800',
'dark:bg-neutral-800',
]"
>
<div :class="['text-2xl', 'text-amber-500', 'dark:text-amber-400', 'i-solar:warning-circle-line-duotone']" />
<div :class="['flex', 'flex-col']">
<span :class="['font-medium']">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_providers_configured_title') }}
</span>
<span :class="['text-sm', 'text-neutral-400', 'dark:text-neutral-500']">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_providers_configured_description') }}
</span>
</div>
<div :class="['ml-auto', 'text-xl', 'text-neutral-400', 'dark:text-neutral-500', 'i-solar:arrow-right-line-duotone']" />
</RouterLink>
</div>
</div>
</div>
</div>
<div v-if="activeProvider && supportsModelListing" :class="['rounded-xl', 'bg-neutral-50', 'p-4', 'dark:bg-[rgba(0,0,0,0.3)]']">
<div :class="['flex', 'flex-col', 'gap-4']">
<div>
<h2 :class="['text-lg', 'md:text-2xl']">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div :class="['text-neutral-400', 'dark:text-neutral-400']">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<div v-if="isLoadingActiveProviderModels" :class="['flex', 'items-center', 'justify-center', 'py-4']">
<div :class="['mr-2', 'animate-spin']">
<div :class="['text-xl', 'i-solar:spinner-line-duotone']" />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<ErrorContainer
v-else-if="activeProviderModelError"
:title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error')"
:error="activeProviderModelError"
/>
<Alert
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
type="warning"
>
<template #title>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models') }}
</template>
<template #content>
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}
</template>
</Alert>
<template v-else-if="providerModels.length > 0">
<RadioCardManySelect
v-model="activeModel"
v-model:search-query="modelSearchQuery"
:items="providerModels.sort((a, b) => a.id === activeModel ? -1 : b.id === activeModel ? 1 : 0)"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
<div v-else-if="activeProvider && !supportsModelListing" :class="['rounded-xl', 'bg-neutral-50', 'p-4', 'dark:bg-[rgba(0,0,0,0.3)]']">
<div :class="['flex', 'flex-col', 'gap-4']">
<div>
<h2 :class="['text-lg', 'text-neutral-500', 'md:text-2xl', 'dark:text-neutral-400']">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div :class="['text-neutral-400', 'dark:text-neutral-500']">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<div
:class="[
'flex',
'items-center',
'gap-3',
'rounded-lg',
'border',
'border-primary-200',
'bg-primary-50',
'p-4',
'dark:border-primary-800',
'dark:bg-primary-900/20',
]"
>
<div :class="['text-2xl', 'text-primary-500', 'dark:text-primary-400', 'i-solar:info-circle-line-duotone']" />
<div :class="['flex', 'flex-col']">
<span :class="['font-medium']">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.not_supported') }}
</span>
<span :class="['text-sm', 'text-primary-600', 'dark:text-primary-400']">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.not_supported_description') }}
</span>
</div>
</div>
<div :class="['mt-2']">
<label :class="['mb-1', 'block', 'text-sm', 'font-medium']">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_name') }}
</label>
<input
v-model="activeModel"
type="text"
:class="[
'w-full',
'rounded',
'border',
'border-neutral-300',
'bg-white',
'px-3',
'py-2',
'dark:border-neutral-700',
'dark:bg-neutral-900',
]"
:placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_placeholder')"
>
</div>
</div>
</div>
<div :class="['rounded-xl', 'bg-neutral-50', 'p-4', 'dark:bg-[rgba(0,0,0,0.3)]']">
<div :class="['flex', 'flex-col', 'gap-4']">
<div>
<h2 :class="['text-lg', 'text-neutral-500', 'md:text-2xl', 'dark:text-neutral-400']">
Vision capture cadence
</h2>
<div :class="['text-neutral-400', 'dark:text-neutral-400']">
Tune how frequently the vision ticker captures a frame.
</div>
</div>
<FieldRange
v-model="captureIntervalMs"
label="Capture interval"
description="Lower values capture more frequently and may increase resource use."
:min="500"
:max="15000"
:step="250"
:format-value="value => `${(value / 1000).toFixed(2)}s`"
/>
<div :class="['grid', 'gap-4', 'md:grid-cols-3']">
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<div :class="['text-xs', 'uppercase', 'tracking-wide', 'text-neutral-400']">
Ticker
</div>
<div :class="['text-sm', 'font-medium', 'text-neutral-600', 'dark:text-neutral-200']">
{{ isRunning ? 'Active' : 'Idle' }}
</div>
<div :class="['text-xs', 'text-neutral-400']">
Last capture {{ formattedLastCapture }}
</div>
</div>
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<div :class="['text-xs', 'uppercase', 'tracking-wide', 'text-neutral-400']">
Captures
</div>
<div :class="['text-sm', 'font-medium', 'text-neutral-600', 'dark:text-neutral-200']">
{{ captureCount }}
</div>
<div :class="['text-xs', 'text-neutral-400']">
Last update {{ formattedLastCapture }}
</div>
</div>
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<div :class="['text-xs', 'uppercase', 'tracking-wide', 'text-neutral-400']">
Context updates
</div>
<div :class="['text-sm', 'font-medium', 'text-neutral-600', 'dark:text-neutral-200']">
{{ contextUpdateCount }}
</div>
<div :class="['text-xs', 'text-neutral-400']">
Last update {{ formattedLastContextUpdate }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<route lang="yaml">
@@ -13,5 +391,4 @@ meta:
subtitleKey: settings.title
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -67,7 +67,7 @@ useScrollToHash(() => route.hash, {
</script>
<template>
<div mb-6 flex flex-col gap-5>
<div :class="['mb-6', 'flex', 'flex-col', 'gap-5', 'pb-10']">
<div bg="primary-500/10 dark:primary-800/25" rounded-lg p-4>
<div mb-2 text-xl font-normal text="primary-800 dark:primary-100">
{{ $t('settings.pages.providers.helpinfo.title') }}
+3
View File
@@ -30,6 +30,7 @@
"./stores/analytics": "./src/stores/analytics/index.ts",
"./stores/character": "./src/stores/character/index.ts",
"./stores/settings": "./src/stores/settings/index.ts",
"./stores/modules/vision": "./src/stores/modules/vision/index.ts",
"./stores/*": "./src/stores/*.ts",
"./stores": "./src/stores/index.ts",
"./workers/vad": "./src/workers/vad/index.ts",
@@ -93,6 +94,7 @@
"animejs": "^4.3.6",
"better-auth": "catalog:",
"culori": "^4.0.2",
"d3": "catalog:",
"date-fns": "^4.1.0",
"dompurify": "^3.3.1",
"es-toolkit": "catalog:",
@@ -160,6 +162,7 @@
"@proj-airi/vite-plugin-warpdrive": "workspace:*",
"@types/audioworklet": "catalog:",
"@types/culori": "^4.0.1",
"@types/d3": "catalog:",
"@types/hast": "catalog:",
"@types/splitpanes": "catalog:",
"@types/unist": "catalog:",
@@ -1,6 +1,8 @@
export { default as AudioSpectrumVisualizer } from './audio-spectrum-visualizer.vue'
export { default as AudioSpectrum } from './audio-spectrum.vue'
export { default as LevelMeter } from './level-meter.vue'
export { default as ProcessingMeter } from './processing-meter.vue'
export { default as TestDummyMarker } from './test-dummy-marker.vue'
export { default as ThresholdMeter } from './threshold-meter.vue'
export { default as TimeSeriesChart } from './time-series-chart.vue'
export { default as TimelineRange } from './timeline-range.vue'
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import ProcessingMeter from './processing-meter.vue'
const processingHistory = ref<number[]>([])
const processingValue = ref(0)
const contextUpdatesPerMinute = ref(12)
const captureRatePerMinute = ref(20)
let animationFrame: number | null = null
function pushHistory(value: number) {
processingHistory.value = [...processingHistory.value, value].slice(-200)
}
function animate() {
const base = 140 + Math.sin(Date.now() / 900) * 40
const jitter = (Math.random() - 0.5) * 30
processingValue.value = Math.max(40, base + jitter)
pushHistory(processingValue.value)
contextUpdatesPerMinute.value = Math.max(0, Math.min(60, contextUpdatesPerMinute.value + (Math.random() - 0.5) * 6))
captureRatePerMinute.value = Math.max(0, Math.min(60, captureRatePerMinute.value + (Math.random() - 0.5) * 8))
animationFrame = requestAnimationFrame(animate)
}
onMounted(() => {
for (let i = 0; i < 20; i += 1)
pushHistory(120 + Math.random() * 50)
animate()
})
onUnmounted(() => {
if (animationFrame)
cancelAnimationFrame(animationFrame)
})
</script>
<template>
<Story title="Processing Meter" group="gadgets">
<Variant id="vision-processing" title="Vision Processing">
<div :class="['p-4', 'max-w-3xl']">
<ProcessingMeter
title="Vision ticker"
:processing-history="processingHistory"
:processing-value="processingValue"
processing-label="Inference latency"
processing-unit="ms"
:rate-value="Math.round(contextUpdatesPerMinute)"
:rate-max="60"
rate-label="Context updates"
rate-unit="/min"
:secondary-rate-value="Math.round(captureRatePerMinute)"
:secondary-rate-max="60"
secondary-rate-label="Capture rate"
secondary-rate-unit="/min"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed } from 'vue'
import LevelMeter from './level-meter.vue'
import TimeSeriesChart from './time-series-chart.vue'
interface Props {
title?: string
processingHistory: Readonly<number[]>
processingValue?: number | null
processingUnit?: string
processingLabel?: string
processingMax?: number
rateValue?: number
rateMax?: number
rateLabel?: string
rateUnit?: string
secondaryRateValue?: number
secondaryRateMax?: number
secondaryRateLabel?: string
secondaryRateUnit?: string
}
const props = withDefaults(defineProps<Props>(), {
title: 'Processing',
processingValue: null,
processingUnit: 'ms',
processingLabel: 'Inference latency',
processingMax: 0,
rateValue: 0,
rateMax: 60,
rateLabel: 'Context updates',
rateUnit: '/min',
secondaryRateValue: undefined,
secondaryRateMax: 60,
secondaryRateLabel: 'Capture rate',
secondaryRateUnit: '/min',
})
const resolvedProcessingMax = computed(() => {
const historyMax = props.processingHistory.length > 0
? Math.max(...props.processingHistory)
: 0
return Math.max(1, props.processingMax || 0, historyMax)
})
const processingValueResolved = computed(() => {
if (props.processingValue !== null && props.processingValue !== undefined)
return props.processingValue
return props.processingHistory.at(-1) ?? 0
})
const normalizedProcessingHistory = computed(() => {
const maxValue = resolvedProcessingMax.value
return props.processingHistory.map(value => Math.min(1, value / maxValue))
})
const normalizedProcessingValue = computed(() => {
return Math.min(1, processingValueResolved.value / resolvedProcessingMax.value)
})
const formattedProcessingValue = computed(() => {
return `${processingValueResolved.value.toFixed(0)}${props.processingUnit}`
})
</script>
<template>
<div :class="['flex', 'flex-col', 'gap-4', 'rounded-2xl', 'bg-white/70', 'p-4', 'shadow-sm', 'dark:bg-neutral-900/50']">
<div :class="['flex', 'items-center', 'justify-between']">
<div :class="['flex', 'flex-col', 'gap-1']">
<div :class="['text-xs', 'uppercase', 'tracking-wide', 'text-neutral-400']">
{{ title }}
</div>
<div :class="['text-base', 'font-semibold', 'text-neutral-700', 'dark:text-neutral-200']">
{{ processingLabel }}
</div>
</div>
<div :class="['text-sm', 'text-neutral-500', 'dark:text-neutral-400']">
{{ formattedProcessingValue }}
</div>
</div>
<TimeSeriesChart
:is-active="false"
:history="normalizedProcessingHistory"
:current-value="normalizedProcessingValue"
:show-legend="false"
:show-header="false"
:show-area="true"
:show-active-indicator="false"
:show-current-value="false"
:height="72"
:precision="0"
:unit="processingUnit"
/>
<div :class="['grid', 'gap-4', 'md:grid-cols-2']">
<LevelMeter
:level="rateValue"
:min="0"
:max="rateMax"
:label="rateLabel"
:unit="rateUnit"
/>
<LevelMeter
v-if="secondaryRateValue !== undefined"
:level="secondaryRateValue"
:min="0"
:max="secondaryRateMax"
:label="secondaryRateLabel"
:unit="secondaryRateUnit"
/>
</div>
</div>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import TimelineRange from './timeline-range.vue'
const data = ref<number[]>([])
const range = ref<[number, number] | null>(null)
let intervalId: ReturnType<typeof setInterval> | null = null
function seedData() {
const now = Date.now()
data.value = Array.from({ length: 120 }, (_, index) => now - (120 - index) * 1000)
}
onMounted(() => {
seedData()
intervalId = setInterval(() => {
data.value = [...data.value.slice(-200), Date.now()]
}, 1000)
})
onUnmounted(() => {
if (intervalId)
clearInterval(intervalId)
})
</script>
<template>
<Story title="Timeline Range" group="gadgets">
<Variant id="basic" title="Basic">
<div :class="['p-4', 'max-w-3xl']">
<TimelineRange
v-model:range="range"
:data="data"
:height="140"
:bins="40"
/>
<div :class="['mt-3', 'text-xs', 'text-neutral-400']">
{{ range ? `Range: ${new Date(range[0]).toLocaleTimeString()} - ${new Date(range[1]).toLocaleTimeString()}` : 'No range selected' }}
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { useElementBounding } from '@vueuse/core'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as d3 from 'd3'
interface Props {
data: number[]
height?: number
bins?: number
range?: [number, number] | null
}
const props = withDefaults(defineProps<Props>(), {
height: 120,
bins: 40,
range: null,
})
const emit = defineEmits<{
(event: 'update:range', value: [number, number] | null): void
}>()
const containerRef = ref<HTMLDivElement | null>(null)
const { width } = useElementBounding(containerRef, { windowResize: true })
let svg: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
let brushBehavior: d3.BrushBehavior<unknown> | null = null
let xScale: d3.ScaleLinear<number, number> | null = null
const sanitizedData = computed(() => props.data.filter(value => Number.isFinite(value)))
function clearChart() {
if (!containerRef.value)
return
containerRef.value.innerHTML = ''
svg = null
brushBehavior = null
xScale = null
}
function drawChart() {
if (!containerRef.value)
return
const containerWidth = Math.max(0, Math.floor(width.value || 0))
if (containerWidth === 0)
return
clearChart()
const margin = {
top: 12,
right: 14,
bottom: 20,
left: 28,
}
const chartWidth = Math.max(0, containerWidth - margin.left - margin.right)
const chartHeight = Math.max(0, props.height - margin.top - margin.bottom)
const data = sanitizedData.value
if (data.length === 0) {
svg = d3.select(containerRef.value)
.append('svg')
.attr('width', containerWidth)
.attr('height', props.height)
return
}
const [minValueRaw, maxValueRaw] = d3.extent(data) as [number, number]
const minValue = Number.isFinite(minValueRaw) ? minValueRaw : 0
const maxValue = Number.isFinite(maxValueRaw) ? maxValueRaw : minValue + 1
const paddedMax = maxValue === minValue ? maxValue + 1 : maxValue
xScale = d3.scaleLinear()
.domain([minValue, paddedMax])
.range([0, chartWidth])
const bins = d3.bin()
.domain(xScale.domain() as [number, number])
.thresholds(props.bins)(data)
const yScale = d3.scaleLinear()
.domain([0, d3.max(bins, bin => bin.length) || 1])
.range([chartHeight, 0])
svg = d3.select(containerRef.value)
.append('svg')
.attr('width', containerWidth)
.attr('height', props.height)
.style('overflow', 'visible')
const chart = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
chart
.append('g')
.attr('class', 'timeline-range-bars')
.selectAll('rect')
.data(bins)
.join('rect')
.attr('x', bin => xScale?.(bin.x0 ?? minValue) ?? 0)
.attr('y', bin => yScale(bin.length))
.attr('width', bin => Math.max(1, (xScale?.(bin.x1 ?? minValue) ?? 0) - (xScale?.(bin.x0 ?? minValue) ?? 0) - 1))
.attr('height', bin => chartHeight - yScale(bin.length))
.attr('rx', 2)
.attr('fill', 'rgba(66,72,83,0.85)')
const axis = d3.axisBottom(xScale).ticks(4).tickFormat((d) => {
if (typeof d === 'number')
return new Date(d).toLocaleTimeString()
return `${d}`
})
chart
.append('g')
.attr('class', 'timeline-range-axis')
.attr('transform', `translate(0,${chartHeight})`)
.call(axis)
.call(g => g.select('.domain').attr('opacity', 0.2))
.call(g => g.selectAll('text').attr('fill', '#9CA1AE'))
.call(g => g.selectAll('line').attr('opacity', 0.08))
brushBehavior = d3.brushX()
.extent([[0, 0], [chartWidth, chartHeight]])
.on('brush end', (event) => {
if (!xScale)
return
if (!event.selection) {
emit('update:range', null)
return
}
const [start, end] = event.selection as [number, number]
emit('update:range', [xScale.invert(start), xScale.invert(end)])
})
const brushGroup = chart
.append('g')
.attr('class', 'timeline-range-brush')
.call(brushBehavior)
brushGroup.selectAll('.selection')
.attr('fill', 'rgba(114,163,183,0.25)')
.attr('stroke', 'rgba(114,163,183,0.6)')
if (props.range) {
const [start, end] = props.range
brushGroup.call(brushBehavior.move, [xScale(start), xScale(end)])
}
}
onMounted(() => {
drawChart()
})
watch([width, sanitizedData, () => props.bins, () => props.height], () => {
drawChart()
})
watch(() => props.range, (nextRange) => {
if (!svg || !brushBehavior || !xScale)
return
const brushGroup = svg.select<SVGGElement>('g.timeline-range-brush')
if (!nextRange) {
brushGroup.call(brushBehavior.move, null)
return
}
brushGroup.call(brushBehavior.move, [xScale(nextRange[0]), xScale(nextRange[1])])
})
onBeforeUnmount(() => {
clearChart()
})
</script>
<template>
<div ref="containerRef" :class="['w-full']" />
</template>
@@ -10,4 +10,5 @@ export * from './use-chat-session/summary'
export * from './use-optimistic'
export * from './use-scroll-to-hash'
export * from './use-versioned-local-storage'
export * from './vision'
export * from './whisper'
@@ -13,6 +13,7 @@ import { useMinecraftStore } from '../stores/modules/gaming-minecraft'
import { useHearingStore } from '../stores/modules/hearing'
import { useSpeechStore } from '../stores/modules/speech'
import { useTwitterStore } from '../stores/modules/twitter'
import { useVisionStore } from '../stores/modules/vision'
export interface Module {
id: string
@@ -33,6 +34,7 @@ export function useModulesList() {
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
const hearingStore = useHearingStore()
const visionStore = useVisionStore()
const discordStore = useDiscordStore()
const twitterStore = useTwitterStore()
const minecraftStore = useMinecraftStore()
@@ -73,7 +75,7 @@ export function useModulesList() {
description: t('settings.pages.modules.vision.description'),
icon: 'i-solar:eye-closed-bold-duotone',
to: '/settings/modules/vision',
configured: false,
configured: visionStore.configured,
category: 'essential',
},
{
@@ -0,0 +1,2 @@
export * from './use-vision-inference'
export * from './use-vision-workloads'
@@ -0,0 +1,82 @@
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { CommonContentPart, Message } from '@xsai/shared-chat'
import type { VisionWorkloadId } from './use-vision-workloads'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useLLM } from '../../stores/llm'
import { useVisionStore } from '../../stores/modules/vision'
import { useProvidersStore } from '../../stores/providers'
import { getVisionWorkload } from './use-vision-workloads'
export interface VisionInferenceInput {
imageDataUrl: string
workloadId: VisionWorkloadId
promptOverride?: string
}
function parseDataUrl(dataUrl: string) {
if (!dataUrl.startsWith('data:'))
return { mimeType: 'image/png', base64: dataUrl, url: dataUrl }
const [, meta, data] = dataUrl.match(/^data:([^,]+),(.*)$/) || []
const mimeType = meta?.split(';')[0] || 'image/png'
const base64 = meta?.includes('base64') ? data : btoa(data)
return {
mimeType,
base64,
url: `data:${mimeType};base64,${base64}`,
}
}
export function useVisionInference() {
const llmStore = useLLM()
const providersStore = useProvidersStore()
const visionStore = useVisionStore()
const { activeProvider, activeModel } = storeToRefs(visionStore)
const lastText = ref('')
async function runVisionInference(input: VisionInferenceInput) {
if (!activeProvider.value || !activeModel.value)
throw new Error('Vision provider/model not configured')
const provider = await providersStore.getProviderInstance<ChatProvider>(activeProvider.value)
const workload = getVisionWorkload(input.workloadId)
const prompt = input.promptOverride ?? workload.prompt
const { url } = parseDataUrl(input.imageDataUrl)
const contentParts: CommonContentPart[] = [
{ type: 'text', text: prompt },
{
type: 'image_url',
image_url: {
url,
},
},
]
const messages: Message[] = [
{ role: 'user', content: contentParts },
]
let buffer = ''
await llmStore.stream(activeModel.value, provider, messages, {
onStreamEvent: (event) => {
if (event.type === 'text-delta') {
buffer += event.text
}
},
})
lastText.value = buffer.trim()
return lastText.value
}
return {
lastText,
runVisionInference,
}
}
@@ -0,0 +1,55 @@
export type VisionWorkloadId = 'screen:interpret' | 'screen:understand' | 'screen:ocr' | 'screen:ui-automation'
export interface VisionWorkloadConfig {
id: VisionWorkloadId
label: string
description: string
prompt: string
}
export const VISION_WORKLOADS: VisionWorkloadConfig[] = [
{
id: 'screen:interpret',
label: 'Screen interpret',
description: 'Summarize what is on screen and relevant UI state.',
prompt: [
'You are an on-device vision assistant.',
'Interpret the current screen in a concise, structured summary:',
'- identify the active app or page',
'- list key UI elements and their states',
'- call out user intent or next likely action',
'Keep it factual and short, avoid speculation.',
].join('\n'),
},
{
id: 'screen:understand',
label: 'Screen understanding',
description: 'Explain screen intent and key tasks.',
prompt: [
'Explain what the screen is for and what the user can do next.',
'Focus on primary actions, warnings, and notable state changes.',
].join('\n'),
},
{
id: 'screen:ocr',
label: 'OCR focus',
description: 'Extract readable text from the screen.',
prompt: [
'Extract visible text from the screen.',
'Return plain text, preserve structure with line breaks when possible.',
].join('\n'),
},
{
id: 'screen:ui-automation',
label: 'UI automation',
description: 'Describe actionable UI elements for automation.',
prompt: [
'Identify actionable UI elements (buttons, inputs, menus).',
'Return a list of elements with labels and approximate purpose.',
].join('\n'),
},
]
export function getVisionWorkload(id: VisionWorkloadId) {
return VISION_WORKLOADS.find(workload => workload.id === id) || VISION_WORKLOADS[0]
}
@@ -6,8 +6,15 @@ import { ref, toRaw } from 'vue'
import { getEventSourceKey } from '../../utils/event-source'
export interface ContextHistoryEntry extends ContextMessage {
sourceKey: string
}
const CONTEXT_HISTORY_LIMIT = 400
export const useChatContextStore = defineStore('chat-context', () => {
const activeContexts = ref<Record<string, ContextMessage[]>>({})
const contextHistory = ref<ContextHistoryEntry[]>([])
function ingestContextMessage(envelope: ContextMessage) {
const sourceKey = getEventSourceKey(envelope)
@@ -21,10 +28,19 @@ export const useChatContextStore = defineStore('chat-context', () => {
else if (envelope.strategy === ContextUpdateStrategy.AppendSelf) {
activeContexts.value[sourceKey].push(envelope)
}
contextHistory.value = [
...contextHistory.value,
{
...envelope,
sourceKey,
},
].slice(-CONTEXT_HISTORY_LIMIT)
}
function resetContexts() {
activeContexts.value = {}
contextHistory.value = []
}
function getContextsSnapshot() {
@@ -35,5 +51,7 @@ export const useChatContextStore = defineStore('chat-context', () => {
ingestContextMessage,
resetContexts,
getContextsSnapshot,
activeContexts,
contextHistory,
}
})
@@ -6,3 +6,4 @@ export * from './gaming-minecraft'
export * from './hearing'
export * from './speech'
export * from './twitter'
export * from './vision'
@@ -0,0 +1,7 @@
export interface VisionAgentConfig {
id: string
name: string
description: string
}
export const VISION_AGENTS: VisionAgentConfig[] = []
@@ -0,0 +1,5 @@
export * from './agents'
export * from './orchestrator'
export * from './processing-store'
export * from './store'
export * from './utils/model-catalog'
@@ -0,0 +1,93 @@
import type { CommonContentPart } from '@xsai/shared-chat'
import type { VisionWorkloadId } from '../../../composables/vision/use-vision-workloads'
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useVisionInference } from '../../../composables/vision'
import { getVisionWorkload } from '../../../composables/vision/use-vision-workloads'
import { useModsServerChannelStore } from '../../mods/api/channel-server'
import { useVisionStore } from './store'
export interface VisionCapturePayload {
imageDataUrl: string
workloadId: VisionWorkloadId
sourceId?: string
capturedAt?: number
publishContext?: boolean
}
export const useVisionOrchestratorStore = defineStore('vision-orchestrator', () => {
const visionStore = useVisionStore()
const { activeProvider, activeModel } = storeToRefs(visionStore)
const modsServerChannelStore = useModsServerChannelStore()
const { runVisionInference, lastText } = useVisionInference()
const lastResultText = ref('')
const lastResultAt = ref<number | null>(null)
const lastError = ref<string | null>(null)
const lastWorkloadId = ref<VisionWorkloadId>('screen:interpret')
async function processCapture(payload: VisionCapturePayload) {
if (!activeProvider.value || !activeModel.value)
throw new Error('Vision model is not configured')
lastWorkloadId.value = payload.workloadId
const text = await runVisionInference({
imageDataUrl: payload.imageDataUrl,
workloadId: payload.workloadId,
})
lastResultText.value = text
lastResultAt.value = Date.now()
lastError.value = null
if (payload.publishContext) {
const workload = getVisionWorkload(payload.workloadId)
const content: CommonContentPart[] = [
{ type: 'text', text },
{
type: 'image_url',
image_url: {
url: payload.imageDataUrl,
},
},
]
modsServerChannelStore.sendContextUpdate({
strategy: ContextUpdateStrategy.ReplaceSelf,
text,
content,
metadata: {
module: 'vision',
workload: workload.id,
workloadLabel: workload.label,
sourceId: payload.sourceId,
capturedAt: payload.capturedAt,
provider: activeProvider.value,
model: activeModel.value,
},
})
return { contextUpdates: 1, text }
}
return { contextUpdates: 0, text }
}
function recordError(error: unknown) {
lastError.value = error instanceof Error ? error.message : String(error)
}
return {
lastText,
lastResultText,
lastResultAt,
lastError,
lastWorkloadId,
processCapture,
recordError,
}
})
@@ -0,0 +1,204 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { createResettableLocalStorage } from '../../../utils/resettable'
export interface VisionTickOutcome {
capturedAt?: number
contextUpdates?: number
}
type VisionTickHandler = () => Promise<VisionTickOutcome | void> | VisionTickOutcome | void
const DEFAULT_CAPTURE_INTERVAL_MS = 3000
const HISTORY_MAX_AGE_MS = 5 * 60 * 1000
const PROCESSING_HISTORY_LIMIT = 240
function trimHistoryByAge(history: number[], maxAgeMs: number) {
const cutoff = Date.now() - maxAgeMs
while (history.length > 0 && history[0] < cutoff)
history.shift()
}
function countInWindow(history: number[], windowMs: number) {
const cutoff = Date.now() - windowMs
let count = 0
for (let index = history.length - 1; index >= 0; index -= 1) {
if (history[index] < cutoff)
break
count += 1
}
return count
}
export const useVisionProcessingStore = defineStore('vision-processing', () => {
const [captureIntervalMs, resetCaptureIntervalMs] = createResettableLocalStorage(
'settings/vision/capture-interval-ms',
DEFAULT_CAPTURE_INTERVAL_MS,
)
const isRunning = ref(false)
const isProcessing = ref(false)
const tickCount = ref(0)
const skippedTicks = ref(0)
const captureCount = ref(0)
const contextUpdateCount = ref(0)
const lastTickAt = ref<number | null>(null)
const lastCaptureAt = ref<number | null>(null)
const lastContextUpdateAt = ref<number | null>(null)
const lastProcessingDurationMs = ref<number | null>(null)
const lastError = ref<string | null>(null)
const processingHistoryMs = ref<number[]>([])
const captureHistory = ref<number[]>([])
const contextUpdateHistory = ref<number[]>([])
let intervalHandle: ReturnType<typeof setInterval> | null = null
const tickHandler = ref<VisionTickHandler | null>(null)
const captureRatePerMinute = computed(() => countInWindow(captureHistory.value, 60_000))
const contextUpdateRatePerMinute = computed(() => countInWindow(contextUpdateHistory.value, 60_000))
const averageProcessingMs = computed(() => {
if (processingHistoryMs.value.length === 0)
return 0
const total = processingHistoryMs.value.reduce((sum, value) => sum + value, 0)
return total / processingHistoryMs.value.length
})
function recordProcessingDuration(durationMs: number) {
lastProcessingDurationMs.value = durationMs
processingHistoryMs.value = [...processingHistoryMs.value, durationMs].slice(-PROCESSING_HISTORY_LIMIT)
}
function recordCapture(capturedAt = Date.now()) {
captureCount.value += 1
lastCaptureAt.value = capturedAt
captureHistory.value.push(capturedAt)
trimHistoryByAge(captureHistory.value, HISTORY_MAX_AGE_MS)
}
function recordContextUpdates(count = 1, updatedAt = Date.now()) {
if (count <= 0)
return
contextUpdateCount.value += count
lastContextUpdateAt.value = updatedAt
for (let index = 0; index < count; index += 1)
contextUpdateHistory.value.push(updatedAt)
trimHistoryByAge(contextUpdateHistory.value, HISTORY_MAX_AGE_MS)
}
async function runTick() {
if (!tickHandler.value)
return
if (isProcessing.value) {
skippedTicks.value += 1
return
}
isProcessing.value = true
lastTickAt.value = Date.now()
tickCount.value += 1
const start = performance.now()
try {
const outcome = await tickHandler.value()
lastError.value = null
if (outcome?.capturedAt)
recordCapture(outcome.capturedAt)
if (outcome?.contextUpdates)
recordContextUpdates(outcome.contextUpdates)
}
catch (error) {
lastError.value = error instanceof Error ? error.message : String(error)
}
finally {
recordProcessingDuration(performance.now() - start)
isProcessing.value = false
}
}
function startTicker(handler: VisionTickHandler) {
tickHandler.value = handler
if (isRunning.value)
return
isRunning.value = true
if (intervalHandle)
clearInterval(intervalHandle)
void runTick()
intervalHandle = setInterval(() => {
void runTick()
}, captureIntervalMs.value)
}
function stopTicker() {
isRunning.value = false
if (intervalHandle)
clearInterval(intervalHandle)
intervalHandle = null
}
function resetMetrics() {
tickCount.value = 0
skippedTicks.value = 0
captureCount.value = 0
contextUpdateCount.value = 0
lastTickAt.value = null
lastCaptureAt.value = null
lastContextUpdateAt.value = null
lastProcessingDurationMs.value = null
lastError.value = null
processingHistoryMs.value = []
captureHistory.value = []
contextUpdateHistory.value = []
}
function resetState() {
stopTicker()
resetMetrics()
resetCaptureIntervalMs()
}
watch(captureIntervalMs, (next, previous) => {
if (!isRunning.value)
return
if (next === previous)
return
if (intervalHandle)
clearInterval(intervalHandle)
intervalHandle = setInterval(() => {
void runTick()
}, next)
})
return {
captureIntervalMs,
isRunning,
isProcessing,
tickCount,
skippedTicks,
captureCount,
contextUpdateCount,
lastTickAt,
lastCaptureAt,
lastContextUpdateAt,
lastProcessingDurationMs,
lastError,
processingHistoryMs,
captureHistory,
contextUpdateHistory,
captureRatePerMinute,
contextUpdateRatePerMinute,
averageProcessingMs,
startTicker,
stopTicker,
resetMetrics,
resetState,
}
})
@@ -0,0 +1,93 @@
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { createResettableLocalStorage, createResettableRef } from '../../../utils/resettable'
import { useProvidersStore } from '../../providers'
export const useVisionStore = defineStore('vision', () => {
const providersStore = useProvidersStore()
const [activeProvider, resetActiveProvider] = createResettableLocalStorage('settings/vision/active-provider', '')
const [activeModel, resetActiveModel] = createResettableLocalStorage('settings/vision/active-model', '')
const [activeCustomModelName, resetActiveCustomModelName] = createResettableLocalStorage('settings/vision/active-custom-model', '')
const [modelSearchQuery, resetModelSearchQuery] = createResettableRef('')
const providerMetadata = computed(() => {
if (!activeProvider.value)
return null
return providersStore.providerMetadata[activeProvider.value] ?? null
})
const supportsModelListing = computed(() => {
return providerMetadata.value?.capabilities.listModels !== undefined
})
const providerModels = computed(() => {
if (!activeProvider.value)
return []
return providersStore.getModelsForProvider(activeProvider.value)
})
const isLoadingActiveProviderModels = computed(() => {
if (!activeProvider.value)
return false
return providersStore.isLoadingModels[activeProvider.value] || false
})
const activeProviderModelError = computed(() => {
if (!activeProvider.value)
return null
return providersStore.modelLoadError[activeProvider.value] || null
})
const configured = computed(() => {
return !!activeProvider.value && !!activeModel.value
})
function resetModelSelection() {
resetActiveModel()
resetActiveCustomModelName()
resetModelSearchQuery()
}
async function loadModelsForProvider(provider: string) {
if (provider && providerMetadata.value?.capabilities.listModels !== undefined) {
await providersStore.fetchModelsForProvider(provider)
}
}
async function getModelsForProvider(provider: string) {
if (provider && providerMetadata.value?.capabilities.listModels !== undefined) {
return providersStore.getModelsForProvider(provider)
}
return []
}
function resetState() {
resetActiveProvider()
resetModelSelection()
}
return {
activeProvider,
activeModel,
customModelName: activeCustomModelName,
modelSearchQuery,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
configured,
resetModelSelection,
loadModelsForProvider,
getModelsForProvider,
resetState,
}
})
@@ -0,0 +1,55 @@
export interface VisionModelInfo {
id: string
name: string
description: string
tags: string[]
recommendedFor: string[]
deprecated?: boolean
customizable?: boolean
}
export const VISION_MODEL_CATALOG: VisionModelInfo[] = [
{
id: 'gpt-4o-mini-vision',
name: 'GPT-4o mini (Vision)',
description: 'Fast and cost-efficient for screen UI understanding and lightweight OCR.',
tags: ['fast', 'ui', 'ocr'],
recommendedFor: ['UI navigation', 'Quick captions', 'Low-latency feedback'],
},
{
id: 'gpt-4o-vision',
name: 'GPT-4o (Vision)',
description: 'Stronger reasoning on complex layouts and multi-window scenes.',
tags: ['accurate', 'ui', 'reasoning'],
recommendedFor: ['Dense UIs', 'Multi-step analysis', 'Ambiguous screens'],
},
{
id: 'claude-3.5-sonnet-vision',
name: 'Claude 3.5 Sonnet (Vision)',
description: 'Balanced quality for diagrams, docs, and structured UI reading.',
tags: ['balanced', 'documents', 'charts'],
recommendedFor: ['Docs and dashboards', 'Diagram reading', 'Summaries'],
},
{
id: 'gemini-1.5-pro-vision',
name: 'Gemini 1.5 Pro (Vision)',
description: 'Great for longer-context screen stories and multi-step tasks.',
tags: ['long-context', 'reasoning'],
recommendedFor: ['Long sessions', 'Workflow tracking', 'Large screens'],
},
{
id: 'llava-1.6',
name: 'LLaVA 1.6 (Local)',
description: 'Local-friendly baseline for offline or privacy-focused setups.',
tags: ['local', 'offline'],
recommendedFor: ['Offline mode', 'On-device evaluation', 'Privacy-first use'],
},
{
id: 'custom',
name: 'Custom Model',
description: 'Use your own vision model ID from a provider or local runtime.',
tags: ['custom'],
recommendedFor: ['Self-hosted models', 'Experimental runtimes'],
customizable: true,
},
]