fix(stage-pages): add vision provider settings (#1987)

This commit is contained in:
Doji
2026-06-17 17:52:53 +08:00
committed by GitHub
parent bb9a6a9b37
commit 27569c94f2
15 changed files with 986 additions and 11 deletions
@@ -1006,6 +1006,9 @@ pages:
chat:
title: Chat
description: Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.
vision:
title: Vision
description: Vision model providers for image understanding.
speech:
title: Speech
description: Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.
@@ -960,6 +960,9 @@ pages:
chat:
title: 聊天
description: Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.
vision:
title: 视觉
description: 用于图像理解的视觉模型服务来源。
speech:
title: Speech
description: Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.
@@ -12,7 +12,7 @@ import { RouterLink } from 'vue-router'
const providersStore = useProvidersStore()
const visionStore = useVisionStore()
const visionProcessingStore = useVisionProcessingStore()
const { persistedChatProvidersMetadata, configuredProviders } = storeToRefs(providersStore)
const { persistedVisionProvidersMetadata, configuredProviders } = storeToRefs(providersStore)
const {
activeProvider,
activeModel,
@@ -61,7 +61,11 @@ function handleDeleteProvider(providerId: string) {
const formattedLastCapture = computed(() => formatRelativeTime(lastCaptureAt.value))
const formattedLastContextUpdate = computed(() => formatRelativeTime(lastContextUpdateAt.value))
const isOllamaVisionProvider = computed(() => activeProvider.value === 'ollama')
const isOllamaVisionProvider = computed(() => activeProvider.value === 'vision-ollama')
function canDeleteProvider(providerId: string) {
return !providerId.startsWith('official-provider') && !providerId.startsWith('vision-official-provider')
}
function formatRelativeTime(timestamp: number | null) {
if (!timestamp)
@@ -93,12 +97,12 @@ function formatRelativeTime(timestamp: number | null) {
</div>
<div :class="['max-w-full']">
<fieldset
v-if="persistedChatProvidersMetadata.length > 0"
v-if="persistedVisionProvidersMetadata.length > 0"
:class="['flex', 'min-w-0', 'flex-row', 'gap-4', 'of-x-auto', 'scroll-smooth']"
role="radiogroup"
>
<RadioCardSimple
v-for="metadata in persistedChatProvidersMetadata"
v-for="metadata in persistedVisionProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeProvider"
@@ -108,7 +112,7 @@ function formatRelativeTime(timestamp: number | null) {
:description="metadata.localizedDescription"
@click="trackProviderClick(metadata.id, 'vision')"
>
<template #topRight>
<template v-if="canDeleteProvider(metadata.id)" #topRight>
<button
type="button"
:class="[
@@ -147,7 +151,7 @@ function formatRelativeTime(timestamp: number | null) {
</template>
</RadioCardSimple>
<RouterLink
to="/settings/providers"
to="/settings/providers#vision"
:class="[
'relative',
'min-w-50',
@@ -178,7 +182,7 @@ function formatRelativeTime(timestamp: number | null) {
</fieldset>
<div v-else>
<RouterLink
to="/settings/providers"
to="/settings/providers#vision"
:class="[
'flex',
'items-center',
@@ -50,6 +50,7 @@ const {
allChatProvidersMetadata,
allAudioSpeechProvidersMetadata,
allAudioTranscriptionProvidersMetadata,
allVisionProvidersMetadata,
} = storeToRefs(providersStore)
const allArtistryProvidersMetadata = computed<ProviderSourceCard[]>((): ProviderSourceCard[] => {
@@ -115,6 +116,13 @@ const providerBlocksConfig: ProviderBlockConfig[] = [
description: t('settings.pages.providers.categories.chat.description'),
providersRef: allChatProvidersMetadata,
},
{
id: 'vision',
icon: 'i-solar:eye-bold-duotone',
title: t('settings.pages.providers.categories.vision.title'),
description: t('settings.pages.providers.categories.vision.description'),
providersRef: allVisionProvidersMetadata,
},
{
id: 'speech',
icon: 'i-solar:user-speak-rounded-bold-duotone',
@@ -0,0 +1,134 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
ProviderValidationAlerts,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { getDefinedProvider } from '@proj-airi/stage-ui/libs'
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const sourceProviderId = route.params.providerId as string
const providerId = `vision-${sourceProviderId}`
const providersStore = useProvidersStore()
const visionStore = useVisionStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
const { activeProvider } = storeToRefs(visionStore)
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
forceValid,
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
} = useProviderValidation(providerId)
const apiKeyPlaceholder = computed(() => {
const definition = getDefinedProvider(sourceProviderId)
if (!definition?.createProviderConfig)
return 'sk-...'
const schema = definition.createProviderConfig({ t }) as any
const shape = typeof schema?.shape === 'function' ? schema.shape() : schema?.shape
const apiKeySchema = shape?.apiKey
if (!apiKeySchema)
return 'sk-...'
const meta = typeof apiKeySchema.meta === 'function' ? apiKeySchema.meta() : undefined
return typeof meta?.placeholderLocalized === 'string' ? meta.placeholderLocalized : 'sk-...'
})
function goToModelSelection() {
activeProvider.value = providerId
router.push('/settings/modules/vision')
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
:placeholder="apiKeyPlaceholder"
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
:placeholder="providerMetadata?.defaultOptions?.().baseUrl as string || 'Base URL of your provider'"
/>
</ProviderAdvancedSettings>
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
:validation-message="validationMessage"
:has-manual-validators="hasManualValidators"
:is-manual-testing="isManualTesting"
:manual-test-passed="manualTestPassed"
:manual-test-message="manualTestMessage"
:on-run-test="runManualTest"
:on-force-valid="forceValid"
:on-go-to-model-selection="goToModelSelection"
/>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAccountIdInput,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'vision-amazon-bedrock'
const providersStore = useProvidersStore()
const visionStore = useVisionStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
const { activeProvider } = storeToRefs(visionStore)
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const region = computed({
get: () => providers.value[providerId]?.region || 'us-east-1',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].region = value
},
})
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
forceValid,
} = useProviderValidation(providerId)
function goToModelSelection() {
activeProvider.value = providerId
router.push('/settings/modules/vision')
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:label="t('settings.pages.providers.provider.amazon-bedrock.config.api-key.label')"
:provider-name="providerMetadata?.localizedName"
:description="t('settings.pages.providers.provider.amazon-bedrock.config.api-key.description')"
:placeholder="t('settings.pages.providers.provider.amazon-bedrock.config.api-key.placeholder')"
required
/>
<ProviderAccountIdInput
v-model="region"
:label="t('settings.pages.providers.provider.amazon-bedrock.config.region.label')"
:description="t('settings.pages.providers.provider.amazon-bedrock.config.region.description')"
placeholder="us-east-1"
/>
</ProviderBasicSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
<div class="w-full flex items-center justify-between">
<span>{{ t('settings.dialogs.onboarding.validationFailed') }}</span>
<button
type="button"
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
@click="forceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
<div class="w-full flex items-center justify-between">
<span>{{ t('settings.dialogs.onboarding.validationSuccess') }}</span>
<button
type="button"
:class="['ml-2 rounded px-2 py-0.5 text-xs font-medium transition-colors', 'bg-green-100 text-green-600 hover:bg-green-200', 'dark:bg-green-800/30 dark:text-green-300 dark:hover:bg-green-700/40']"
@click="goToModelSelection"
>
{{ t('settings.pages.providers.common.goToModelSelection') }}
</button>
</div>
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAccountIdInput,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
ProviderValidationAlerts,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'vision-azure-ai-foundry'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const resourceName = computed({
get: () => providers.value[providerId]?.resourceName || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].resourceName = value
},
})
const apiVersion = computed({
get: () => providers.value[providerId]?.apiVersion || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiVersion = value
},
})
const modelId = computed({
get: () => providers.value[providerId]?.modelId || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].modelId = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
forceValid,
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="..."
required
/>
<ProviderAccountIdInput
v-model="resourceName"
label="Resouce name"
placeholder="..."
description="Prefix used in https://<prefix>.services.ai.azure.com"
required
/>
<ProviderAccountIdInput
v-model="modelId"
label="Model id"
placeholder="..."
description="Model ID on Azure AI Foundry"
required
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderAccountIdInput
v-model="apiVersion"
label="API version"
placeholder="e.g. 2025-04-01-preview"
description="API version for snapshot of the models"
/>
</ProviderAdvancedSettings>
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
:validation-message="validationMessage"
:has-manual-validators="hasManualValidators"
:is-manual-testing="isManualTesting"
:manual-test-passed="manualTestPassed"
:manual-test-message="manualTestMessage"
:on-run-test="runManualTest"
:on-force-valid="forceValid"
:on-go-to-model-selection="() => router.push('/settings/modules/vision')"
/>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAccountIdInput,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
ProviderValidationAlerts,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'vision-cloudflare-workers-ai'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const accountId = computed({
get: () => providers.value[providerId]?.accountId || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].accountId = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
forceValid,
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
:placeholder="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.api-key.placeholder')"
/>
<ProviderAccountIdInput
v-model="accountId"
:label="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.label')"
:description="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.description')"
:placeholder="t('settings.pages.providers.provider.cloudflare-workers-ai.fields.field.account-id.placeholder')"
/>
</ProviderBasicSettings>
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
:validation-message="validationMessage"
:has-manual-validators="hasManualValidators"
:is-manual-testing="isManualTesting"
:manual-test-passed="manualTestPassed"
:manual-test-message="manualTestMessage"
:on-run-test="runManualTest"
:on-force-valid="forceValid"
:on-go-to-model-selection="() => router.push('/settings/modules/vision')"
/>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
ProviderValidationAlerts,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'vision-lm-studio'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
forceValid,
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
} = useProviderValidation(providerId)
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="http://localhost:1234/v1/"
/>
</ProviderBasicSettings>
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
:validation-message="validationMessage"
:has-manual-validators="hasManualValidators"
:is-manual-testing="isManualTesting"
:manual-test-passed="manualTestPassed"
:manual-test-message="manualTestMessage"
:on-run-test="runManualTest"
:on-force-valid="forceValid"
:on-go-to-model-selection="() => router.push('/settings/modules/vision')"
/>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { isFluxPurchaseDisabled } from '@proj-airi/stage-shared'
import {
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const providersStore = useProvidersStore()
const { isAuthenticated, credits, needsLogin } = storeToRefs(authStore)
const providerId = 'vision-official-provider'
const providerMetadata = providersStore.getProviderMetadata(providerId)
const fluxPurchaseDisabled = isFluxPurchaseDisabled()
function handleLogin() {
needsLogin.value = true
}
</script>
<template>
<ProviderSettingsLayout
v-if="providerMetadata"
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<div v-if="!isAuthenticated" flex flex-col gap-4>
<Callout theme="primary">
<template #label>
{{ t('settings.dialogs.onboarding.official.title') }}
</template>
<div flex flex-col gap-3>
<p>{{ t('settings.dialogs.onboarding.loginPrompt') }}</p>
<button
type="button"
class="w-fit rounded-lg bg-primary-500 px-4 py-2 text-white transition-colors active:scale-95 hover:bg-primary-600"
@click="handleLogin"
>
{{ t('settings.dialogs.onboarding.loginAction') }}
</button>
</div>
</Callout>
</div>
<div v-else flex flex-col gap-6>
<div class="rounded-xl bg-neutral-100/50 p-6 backdrop-blur-sm dark:bg-neutral-800/50">
<div flex items-center justify-between>
<div flex flex-col gap-1>
<span text="sm neutral-500 dark:neutral-400 font-medium uppercase tracking-wider">
{{ t('settings.dialogs.onboarding.flux') }}
</span>
<span text="3xl font-bold text-primary-600 dark:text-primary-400">
{{ credits }}
</span>
</div>
<button
v-if="!fluxPurchaseDisabled"
type="button"
class="rounded-full bg-primary-500/10 px-6 py-2 text-sm text-primary-600 font-semibold transition-all dark:bg-primary-400/10 hover:bg-primary-500 dark:text-primary-400 hover:text-white dark:hover:bg-primary-400 dark:hover:text-neutral-900"
@click="router.push('/settings/flux')"
>
{{ t('settings.dialogs.onboarding.buyFlux') }}
</button>
</div>
</div>
<div class="border border-neutral-200/50 rounded-xl p-4 dark:border-neutral-700/50">
<div flex items-center gap-3>
<div class="h-2 w-2 animate-pulse rounded-full bg-green-500" />
<span text="sm neutral-600 dark:neutral-300">
{{ t('settings.pages.providers.provider.common.status.valid') }}
</span>
</div>
</div>
</div>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
<div v-else class="p-8 text-center text-neutral-500">
Provider is not available.
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,205 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import {
ProviderAdvancedSettings,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
ProviderValidationAlerts,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldCombobox, FieldKeyValues } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
const providerId = 'vision-ollama'
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
// Define computed properties for credentials
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || 'http://localhost:11434/v1/',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Use the composable to get validation logic and state
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
forceValid,
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
} = useProviderValidation(providerId)
const headers = ref<{ key: string, value: string }[]>(Object.entries(providers.value[providerId]?.headers || {}).map(([key, value]) => ({ key, value } as { key: string, value: string })) || [{ key: '', value: '' }])
const thinkingMode = computed({
get: () => providers.value[providerId]?.thinkingMode || 'auto',
set: (value: string) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].thinkingMode = value
},
})
function addKeyValue(headers: { key: string, value: string }[], key: string, value: string) {
if (!headers)
return
headers.push({ key, value })
}
function removeKeyValue(index: number, headers: { key: string, value: string }[]) {
if (!headers)
return
if (headers.length === 1) {
headers[0].key = ''
headers[0].value = ''
}
else {
headers.splice(index, 1)
}
}
watch(headers, (headers) => {
if (headers.length > 0 && (headers.at(-1)!.key !== '' || headers.at(-1)!.value !== '')) {
headers.push({ key: '', value: '' })
}
if (!providers.value[providerId])
return
providers.value[providerId].headers = headers.filter(header => header.key !== '').reduce((acc, header) => {
acc[header.key] = header.value
return acc
}, {} as Record<string, string>)
}, {
deep: true,
immediate: true,
})
async function refetch() {
try {
const validationResult = await providerMetadata.value.validators.validateProviderConfig({
baseUrl: baseUrl.value,
thinkingMode: thinkingMode.value,
headers: headers.value.filter(header => header.key !== '').reduce((acc, header) => {
acc[header.key] = header.value
return acc
}, {} as Record<string, string>),
})
if (!validationResult.valid) {
validationMessage.value = t('settings.dialogs.onboarding.validationError', {
error: validationResult.reason,
})
}
}
catch (error) {
validationMessage.value = t('settings.dialogs.onboarding.validationError', {
error: errorMessageFromValue(error),
})
}
}
watch([baseUrl, thinkingMode, headers], refetch, { immediate: true, deep: true })
onMounted(() => {
providersStore.initializeProvider(providerId)
// Initialize refs with current values
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.().baseUrl || ''
// Initialize headers if not already set
if (!providers.value[providerId]?.headers) {
providers.value[providerId].headers = {}
}
if (headers.value.length === 0) {
headers.value = [{ key: '', value: '' }]
}
if (!providers.value[providerId].thinkingMode) {
providers.value[providerId].thinkingMode = 'auto'
}
})
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="http://localhost:11434/v1/"
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<FieldCombobox
v-model="thinkingMode"
:label="t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.label')"
:description="t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.description')"
:options="[
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.auto'), value: 'auto' },
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.disable'), value: 'disable' },
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.enable'), value: 'enable' },
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.low'), value: 'low' },
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.medium'), value: 'medium' },
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.high'), value: 'high' },
]"
/>
<FieldKeyValues
v-model="headers"
:label="t('settings.pages.providers.common.section.advanced.fields.field.headers.label')"
:description="t('settings.pages.providers.common.section.advanced.fields.field.headers.description')"
:key-placeholder="t('settings.pages.providers.common.section.advanced.fields.field.headers.key.placeholder')"
:value-placeholder="t('settings.pages.providers.common.section.advanced.fields.field.headers.value.placeholder')"
@add="(key: string, value: string) => addKeyValue(headers, key, value)"
@remove="(index: number) => removeKeyValue(index, headers)"
/>
</ProviderAdvancedSettings>
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
:validation-message="validationMessage"
:has-manual-validators="hasManualValidators"
:is-manual-testing="isManualTesting"
:manual-test-passed="manualTestPassed"
:manual-test-message="manualTestMessage"
:on-run-test="runManualTest"
:on-force-valid="forceValid"
:on-go-to-model-selection="() => router.push('/settings/modules/vision')"
/>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -6,14 +6,16 @@ import { useAuthStore } from '../stores/auth'
import { useConsciousnessStore } from '../stores/modules/consciousness'
import { useHearingStore } from '../stores/modules/hearing'
import { useSpeechStore } from '../stores/modules/speech'
import { useVisionStore } from '../stores/modules/vision'
import { useProvidersStore } from '../stores/providers'
/**
* Provider IDs to auto-activate on sign-in.
* Edit this list to enable/disable official providers.
*/
const AUTH_ACTIVATED_PROVIDERS: Array<{ id: string, module: 'consciousness' | 'speech' | 'hearing' }> = [
const AUTH_ACTIVATED_PROVIDERS: Array<{ id: string, module: 'consciousness' | 'speech' | 'hearing' | 'vision' }> = [
{ id: 'official-provider', module: 'consciousness' },
{ id: 'vision-official-provider', module: 'vision' },
{ id: 'official-provider-speech', module: 'speech' },
{ id: OFFICIAL_TRANSCRIPTION_PROVIDER_ID, module: 'hearing' },
]
@@ -36,6 +38,7 @@ export function useAuthProviderSync() {
const authStore = useAuthStore()
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
const visionStore = useVisionStore()
const speechStore = useSpeechStore()
const hearingStore = useHearingStore()
@@ -67,6 +70,12 @@ export function useAuthProviderSync() {
consciousnessStore.activeModel = 'auto'
}
break
case 'vision':
if (!visionStore.activeProvider) {
visionStore.activeProvider = id
visionStore.activeModel = 'auto'
}
break
case 'speech':
if (!speechStore.activeSpeechProvider || speechStore.activeSpeechProvider === 'speech-noop') {
speechStore.activeSpeechProvider = id
@@ -88,7 +97,9 @@ export function useAuthProviderSync() {
toActivate.map(({ id, module }) =>
module === 'consciousness'
? consciousnessStore.loadModelsForProvider(id)
: providersStore.fetchModelsForProvider(id),
: module === 'vision'
? visionStore.loadModelsForProvider(id)
: providersStore.fetchModelsForProvider(id),
),
)
}
@@ -168,6 +179,12 @@ export function useAuthProviderSync() {
consciousnessStore.activeModel = ''
}
break
case 'vision':
if (visionStore.activeProvider === id) {
visionStore.activeProvider = ''
visionStore.activeModel = ''
}
break
case 'speech':
if (speechStore.activeSpeechProvider === id) {
speechStore.activeSpeechProvider = ''
@@ -50,7 +50,7 @@ export function useVisionInference() {
const workload = getVisionWorkload(input.workloadId)
const prompt = input.promptOverride ?? workload.prompt
const { url } = parseDataUrl(input.imageDataUrl)
const visionProvider = activeProvider.value === 'ollama'
const visionProvider = activeProvider.value === 'vision-ollama'
? {
...provider,
chat(model: string) {
+36 -1
View File
@@ -81,8 +81,9 @@ function toListVoicesOptions<T>(provider: VoiceProviderWithExtraOptions<T>, opti
export interface ProviderMetadata {
id: string
to?: string
order?: number
category: 'chat' | 'embed' | 'speech' | 'transcription'
category: 'chat' | 'embed' | 'speech' | 'transcription' | 'vision'
tasks: string[]
nameKey: string // i18n key for provider name
name: string // Default name (fallback)
@@ -2240,6 +2241,18 @@ export const useProvidersStore = defineStore('providers', () => {
},
}
const VISION_PROVIDER_ID_PREFIX = 'vision-'
function createVisionProviderMetadata(metadata: ProviderMetadata): ProviderMetadata {
return {
...metadata,
id: `${VISION_PROVIDER_ID_PREFIX}${metadata.id}`,
to: `/settings/providers/vision/${metadata.id}`,
category: 'vision',
tasks: Array.from(new Set([...metadata.tasks, 'vision', 'image-understanding'])),
}
}
// Progressive migration bridge:
// translate unified provider definitions from libs/providers to legacy store metadata.
// Existing metadata remains as fallback for providers not yet migrated.
@@ -2260,6 +2273,7 @@ export const useProvidersStore = defineStore('providers', () => {
})
if (intervalMs && intervalMs > 0) {
providerValidationIntervalMsById.set(definition.id, intervalMs)
providerValidationIntervalMsById.set(`${VISION_PROVIDER_ID_PREFIX}${definition.id}`, intervalMs)
}
}
@@ -2273,6 +2287,12 @@ export const useProvidersStore = defineStore('providers', () => {
providerMetadata[providerId] = translated
}
for (const metadata of Object.values(providerMetadata)
.filter(metadata => metadata.category === 'chat')
.map(createVisionProviderMetadata)) {
providerMetadata[metadata.id] = metadata
}
for (const metadata of Object.values(providerMetadata)) {
if (definedProviderIds.has(metadata.id))
continue
@@ -2774,6 +2794,10 @@ export const useProvidersStore = defineStore('providers', () => {
return availableProvidersMetadata.value.filter(metadata => metadata.category === 'transcription')
})
const allVisionProvidersMetadata = computed(() => {
return availableProvidersMetadata.value.filter(metadata => metadata.category === 'vision')
})
const configuredChatProvidersMetadata = computed(() => {
return allChatProvidersMetadata.value.filter(metadata => configuredProviders.value[metadata.id])
})
@@ -2786,6 +2810,10 @@ export const useProvidersStore = defineStore('providers', () => {
return allAudioTranscriptionProvidersMetadata.value.filter(metadata => configuredProviders.value[metadata.id])
})
const configuredVisionProvidersMetadata = computed(() => {
return allVisionProvidersMetadata.value.filter(metadata => configuredProviders.value[metadata.id])
})
function isProviderConfigDirty(providerId: string) {
const config = providerCredentials.value[providerId]
if (!config)
@@ -2815,6 +2843,10 @@ export const useProvidersStore = defineStore('providers', () => {
return persistedProvidersMetadata.value.filter(metadata => metadata.category === 'transcription')
})
const persistedVisionProvidersMetadata = computed(() => {
return persistedProvidersMetadata.value.filter(metadata => metadata.category === 'vision')
})
function getProviderConfig(providerId: string) {
return providerCredentials.value[providerId]
}
@@ -2852,12 +2884,15 @@ export const useProvidersStore = defineStore('providers', () => {
allChatProvidersMetadata,
allAudioSpeechProvidersMetadata,
allAudioTranscriptionProvidersMetadata,
allVisionProvidersMetadata,
configuredChatProvidersMetadata,
configuredSpeechProvidersMetadata,
configuredTranscriptionProvidersMetadata,
configuredVisionProvidersMetadata,
persistedProvidersMetadata,
persistedChatProvidersMetadata,
persistedSpeechProvidersMetadata,
persistedTranscriptionProvidersMetadata,
persistedVisionProvidersMetadata,
}
})
@@ -10,6 +10,9 @@ import { CHAT_COMPLETIONS_VALIDATOR_ID, isModelProvider } from '../../libs/provi
import { getValidatorsOfProvider, validateProvider } from '../../libs/providers/validators/run'
function getCategoryFromTasks(tasks: string[]): ProviderMetadata['category'] {
if (tasks.some(task => ['vision', 'image-understanding', 'image-to-text', 'multimodal'].includes(task.toLowerCase()))) {
return 'vision'
}
if (tasks.some(task => ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'].includes(task.toLowerCase()))) {
return 'transcription'
}