1695 lines
49 KiB
Vue
1695 lines
49 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
CheckIcon,
|
|
CopyIcon,
|
|
ExternalIcon,
|
|
GridIcon,
|
|
KeyIcon,
|
|
LogOutIcon,
|
|
PlugIcon,
|
|
PlusIcon,
|
|
RefreshCwIcon,
|
|
SearchIcon,
|
|
SpinnerIcon,
|
|
TrashIcon,
|
|
} from '@modrinth/assets'
|
|
import {
|
|
Combobox,
|
|
defineMessages,
|
|
injectNotificationManager,
|
|
NewButton as Button,
|
|
StyledInput,
|
|
Tabs,
|
|
Toggle,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
import providerDescriptionsEn from '@/data/lobehub-provider-descriptions/en-US.json'
|
|
import providerDescriptionsZh from '@/data/lobehub-provider-descriptions/zh-CN.json'
|
|
import { sponsoredProviderWebsites } from '@/data/sponsoredProviders'
|
|
import {
|
|
type AIProviderConfig,
|
|
type AIProviderDefinition,
|
|
type AIState,
|
|
beginAIOAuth,
|
|
disconnectAIOAuth,
|
|
fetchAIModels,
|
|
getAICatalog,
|
|
getAIState,
|
|
type OAuthDeviceCode,
|
|
type OAuthPollStatus,
|
|
pollAIOAuth,
|
|
removeAIModel,
|
|
setAIProviderCredential,
|
|
setAIProviderKey,
|
|
sharedAIState,
|
|
testAIProvider,
|
|
updateAIModel,
|
|
updateAIProvider,
|
|
updateAISettings,
|
|
} from '@/helpers/ai'
|
|
|
|
import AIIcon from './AIIcon.vue'
|
|
|
|
const { formatMessage, locale } = useVIntl()
|
|
const { handleError } = injectNotificationManager()
|
|
const emptyState: AIState = {
|
|
settings: { enabled: true },
|
|
catalog_source: '',
|
|
providers: [],
|
|
}
|
|
const catalog = ref<AIProviderDefinition[]>([])
|
|
const state = computed(() => sharedAIState.value ?? emptyState)
|
|
const loading = ref(true)
|
|
const search = ref('')
|
|
const selectedId = ref('')
|
|
const apiKey = ref('')
|
|
const credentialValues = ref<Record<string, string>>({})
|
|
const customModelId = ref('')
|
|
const customModelName = ref('')
|
|
const modelSearch = ref('')
|
|
const status = ref('')
|
|
const busy = ref(false)
|
|
const masterBusy = ref(false)
|
|
const oauthInfo = ref<OAuthDeviceCode | null>(null)
|
|
const oauthStatus = ref<OAuthPollStatus | null>(null)
|
|
const oauthChecking = ref(false)
|
|
let oauthTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
const messages = defineMessages({
|
|
title: { id: 'app.ai-settings.title', defaultMessage: 'AI providers' },
|
|
description: {
|
|
id: 'app.ai-settings.description',
|
|
defaultMessage: 'Configure text models once, then use them across launcher features.',
|
|
},
|
|
masterSwitch: { id: 'app.ai-settings.master-switch', defaultMessage: 'Enable AI features' },
|
|
masterSwitchDescription: {
|
|
id: 'app.ai-settings.master-switch-description',
|
|
defaultMessage:
|
|
'When disabled, AI actions and provider choices are hidden across the launcher.',
|
|
},
|
|
search: { id: 'app.ai-settings.search', defaultMessage: 'Search providers' },
|
|
allProviders: { id: 'app.ai-settings.all-providers', defaultMessage: 'All' },
|
|
enabledProviders: {
|
|
id: 'app.ai-settings.enabled-providers',
|
|
defaultMessage: 'Enabled providers',
|
|
},
|
|
disabledProviders: {
|
|
id: 'app.ai-settings.disabled-providers',
|
|
defaultMessage: 'Disabled providers',
|
|
},
|
|
sponsoredProviders: {
|
|
id: 'app.ai-settings.sponsored-providers',
|
|
defaultMessage: 'Sponsored providers',
|
|
},
|
|
visitWebsite: {
|
|
id: 'app.ai-settings.visit-website',
|
|
defaultMessage: 'Visit website',
|
|
},
|
|
configuredModels: {
|
|
id: 'app.ai-settings.configured-models',
|
|
defaultMessage: '{count, plural, one {# text model} other {# text models}}',
|
|
},
|
|
noProviders: { id: 'app.ai-settings.no-providers', defaultMessage: 'No providers found.' },
|
|
enabled: { id: 'app.ai-settings.enabled', defaultMessage: 'Enabled' },
|
|
disabled: { id: 'app.ai-settings.disabled', defaultMessage: 'Disabled' },
|
|
providerEndpoint: { id: 'app.ai-settings.endpoint', defaultMessage: 'API endpoint' },
|
|
apiKey: { id: 'app.ai-settings.api-key', defaultMessage: 'API key' },
|
|
apiKeyConfigured: {
|
|
id: 'app.ai-settings.api-key-configured',
|
|
defaultMessage: 'A key is stored securely in the operating system credential store.',
|
|
},
|
|
apiKeyOptional: {
|
|
id: 'app.ai-settings.api-key-optional',
|
|
defaultMessage: 'No key is stored. Local providers can work without one.',
|
|
},
|
|
credentialConfigured: {
|
|
id: 'app.ai-settings.credential-configured',
|
|
defaultMessage: 'Stored securely in the operating system credential store.',
|
|
},
|
|
bedrockAuthentication: {
|
|
id: 'app.ai-settings.bedrock.authentication',
|
|
defaultMessage: 'Authentication',
|
|
},
|
|
bedrockApiKey: {
|
|
id: 'app.ai-settings.bedrock.api-key',
|
|
defaultMessage: 'API key',
|
|
},
|
|
bedrockAwsCredentials: {
|
|
id: 'app.ai-settings.bedrock.aws-credentials',
|
|
defaultMessage: 'AWS credentials',
|
|
},
|
|
awsAccessKeyId: {
|
|
id: 'app.ai-settings.bedrock.access-key-id',
|
|
defaultMessage: 'AWS access key ID',
|
|
},
|
|
awsSecretAccessKey: {
|
|
id: 'app.ai-settings.bedrock.secret-access-key',
|
|
defaultMessage: 'AWS secret access key',
|
|
},
|
|
awsSessionToken: {
|
|
id: 'app.ai-settings.bedrock.session-token',
|
|
defaultMessage: 'AWS session token (optional)',
|
|
},
|
|
vertexServiceAccount: {
|
|
id: 'app.ai-settings.vertex.service-account',
|
|
defaultMessage: 'Service-account JSON',
|
|
},
|
|
vertexServiceAccountDescription: {
|
|
id: 'app.ai-settings.vertex.service-account-description',
|
|
defaultMessage: 'Paste the complete Google Cloud service-account JSON document.',
|
|
},
|
|
saveCredential: {
|
|
id: 'app.ai-settings.save-credential',
|
|
defaultMessage: 'Save credential',
|
|
},
|
|
clearCredential: {
|
|
id: 'app.ai-settings.clear-credential',
|
|
defaultMessage: 'Clear credential',
|
|
},
|
|
saveKey: { id: 'app.ai-settings.save-key', defaultMessage: 'Save key' },
|
|
clearKey: { id: 'app.ai-settings.clear-key', defaultMessage: 'Clear key' },
|
|
saveProvider: { id: 'app.ai-settings.save-provider', defaultMessage: 'Save provider' },
|
|
saved: { id: 'app.ai-settings.saved', defaultMessage: 'Provider settings saved.' },
|
|
models: { id: 'app.ai-settings.models', defaultMessage: 'Text models' },
|
|
modelId: { id: 'app.ai-settings.model-id', defaultMessage: 'Model ID' },
|
|
modelName: { id: 'app.ai-settings.model-name', defaultMessage: 'Display name (optional)' },
|
|
addModel: { id: 'app.ai-settings.add-model', defaultMessage: 'Add model' },
|
|
refreshModels: { id: 'app.ai-settings.refresh-models', defaultMessage: 'Fetch models' },
|
|
refreshingModels: { id: 'app.ai-settings.refreshing-models', defaultMessage: 'Fetching…' },
|
|
noModels: {
|
|
id: 'app.ai-settings.no-models',
|
|
defaultMessage: 'No models are available. Add a model ID to continue.',
|
|
},
|
|
noMatchingModels: {
|
|
id: 'app.ai-settings.no-matching-models',
|
|
defaultMessage: 'No models match your search.',
|
|
},
|
|
searchModels: { id: 'app.ai-settings.search-models', defaultMessage: 'Search models' },
|
|
testModel: { id: 'app.ai-settings.test-model', defaultMessage: 'Test model' },
|
|
test: { id: 'app.ai-settings.test', defaultMessage: 'Test provider' },
|
|
testing: { id: 'app.ai-settings.testing', defaultMessage: 'Testing…' },
|
|
testSuccess: {
|
|
id: 'app.ai-settings.test-success',
|
|
defaultMessage: 'Connection succeeded: {response}',
|
|
},
|
|
connect: { id: 'app.ai-settings.oauth.connect', defaultMessage: 'Connect account' },
|
|
reconnect: { id: 'app.ai-settings.oauth.reconnect', defaultMessage: 'Reconnect account' },
|
|
disconnect: { id: 'app.ai-settings.oauth.disconnect', defaultMessage: 'Disconnect' },
|
|
connected: { id: 'app.ai-settings.oauth.connected', defaultMessage: 'Account connected' },
|
|
oauthCode: { id: 'app.ai-settings.oauth.code', defaultMessage: 'Authorization code' },
|
|
oauthPending: {
|
|
id: 'app.ai-settings.oauth.pending',
|
|
defaultMessage: 'Complete authorization in your browser. This page will update automatically.',
|
|
},
|
|
oauthExpired: {
|
|
id: 'app.ai-settings.oauth.expired',
|
|
defaultMessage: 'The authorization code expired. Start again.',
|
|
},
|
|
oauthDenied: {
|
|
id: 'app.ai-settings.oauth.denied',
|
|
defaultMessage: 'Authorization was denied.',
|
|
},
|
|
copyCode: { id: 'app.ai-settings.oauth.copy-code', defaultMessage: 'Copy authorization code' },
|
|
checkAuthorization: {
|
|
id: 'app.ai-settings.oauth.check',
|
|
defaultMessage: 'Check authorization status',
|
|
},
|
|
checkingAuthorization: {
|
|
id: 'app.ai-settings.oauth.checking',
|
|
defaultMessage: 'Checking authorization status',
|
|
},
|
|
openAuthorization: {
|
|
id: 'app.ai-settings.oauth.open',
|
|
defaultMessage: 'Open authorization page',
|
|
},
|
|
settingDeployment: { id: 'app.ai-settings.field.deployment', defaultMessage: 'Deployment' },
|
|
settingApiVersion: { id: 'app.ai-settings.field.api-version', defaultMessage: 'API version' },
|
|
settingAccountId: { id: 'app.ai-settings.field.account-id', defaultMessage: 'Account ID' },
|
|
settingRegion: { id: 'app.ai-settings.field.region', defaultMessage: 'Region' },
|
|
settingProject: { id: 'app.ai-settings.field.project', defaultMessage: 'Project ID' },
|
|
settingLocation: { id: 'app.ai-settings.field.location', defaultMessage: 'Location' },
|
|
})
|
|
|
|
const providerItems = computed(() =>
|
|
catalog.value
|
|
.map((definition) => ({
|
|
definition,
|
|
config: state.value.providers.find((provider) => provider.provider_id === definition.id),
|
|
}))
|
|
.filter(
|
|
(item): item is { definition: AIProviderDefinition; config: AIProviderConfig } =>
|
|
item.config !== undefined,
|
|
),
|
|
)
|
|
|
|
const filteredProviderItems = computed(() => {
|
|
const query = search.value.trim().toLocaleLowerCase()
|
|
if (!query) return providerItems.value
|
|
return providerItems.value.filter(
|
|
({ definition }) =>
|
|
definition.name.toLocaleLowerCase().includes(query) || definition.id.includes(query),
|
|
)
|
|
})
|
|
|
|
const enabledProviderItems = computed(() =>
|
|
filteredProviderItems.value.filter(
|
|
({ config, definition }) => config.enabled && !definition.sponsored,
|
|
),
|
|
)
|
|
const sponsoredProviderItems = computed(() =>
|
|
filteredProviderItems.value.filter(({ definition }) => definition.sponsored),
|
|
)
|
|
const disabledProviderItems = computed(() =>
|
|
filteredProviderItems.value.filter(
|
|
({ config, definition }) => !config.enabled && !definition.sponsored,
|
|
),
|
|
)
|
|
const allEnabledProviderItems = computed(() =>
|
|
providerItems.value.filter(({ config, definition }) => config.enabled && !definition.sponsored),
|
|
)
|
|
const allSponsoredProviderItems = computed(() =>
|
|
providerItems.value.filter(({ definition }) => definition.sponsored),
|
|
)
|
|
const allDisabledProviderItems = computed(() =>
|
|
providerItems.value.filter(({ config, definition }) => !config.enabled && !definition.sponsored),
|
|
)
|
|
const providerDescriptions = computed<Record<string, string>>(() =>
|
|
locale.value.toLocaleLowerCase().startsWith('zh')
|
|
? providerDescriptionsZh
|
|
: providerDescriptionsEn,
|
|
)
|
|
|
|
const selectedDefinition = computed<AIProviderDefinition | undefined>(() =>
|
|
catalog.value.find((provider) => provider.id === selectedId.value),
|
|
)
|
|
const selectedConfig = computed<AIProviderConfig | undefined>(() =>
|
|
state.value.providers.find((provider) => provider.provider_id === selectedId.value),
|
|
)
|
|
const enabledModels = computed(() =>
|
|
(selectedConfig.value?.models ?? []).filter((model) => model.enabled),
|
|
)
|
|
const selectedTestModel = ref('')
|
|
const modelOptions = computed(() =>
|
|
enabledModels.value.map((model) => ({ value: model.id, label: model.name || model.id })),
|
|
)
|
|
const filteredModels = computed(() => {
|
|
const query = modelSearch.value.trim().toLocaleLowerCase()
|
|
if (!query) return selectedConfig.value?.models ?? []
|
|
return (selectedConfig.value?.models ?? []).filter((model) =>
|
|
[model.id, model.name, model.source].some((value) =>
|
|
value?.toLocaleLowerCase().includes(query),
|
|
),
|
|
)
|
|
})
|
|
|
|
const bedrockAuthMode = computed({
|
|
get: () => {
|
|
const configured = selectedConfig.value?.configured_credentials ?? []
|
|
return (
|
|
selectedConfig.value?.settings.auth_mode ||
|
|
(configured.includes('aws-access-key-id') || configured.includes('aws-secret-access-key')
|
|
? 'aws-credentials'
|
|
: 'api-key')
|
|
)
|
|
},
|
|
set: (value: string | number) => {
|
|
if (selectedConfig.value) selectedConfig.value.settings.auth_mode = String(value)
|
|
},
|
|
})
|
|
const bedrockAuthTabs = computed(() => [
|
|
{ value: 'api-key', label: formatMessage(messages.bedrockApiKey) },
|
|
{ value: 'aws-credentials', label: formatMessage(messages.bedrockAwsCredentials) },
|
|
])
|
|
const bedrockCredentialFields = computed(() => [
|
|
{ name: 'aws-access-key-id', label: formatMessage(messages.awsAccessKeyId) },
|
|
{ name: 'aws-secret-access-key', label: formatMessage(messages.awsSecretAccessKey) },
|
|
{ name: 'aws-session-token', label: formatMessage(messages.awsSessionToken) },
|
|
])
|
|
const settingFieldMessages = {
|
|
deployment: messages.settingDeployment,
|
|
api_version: messages.settingApiVersion,
|
|
account_id: messages.settingAccountId,
|
|
region: messages.settingRegion,
|
|
project: messages.settingProject,
|
|
location: messages.settingLocation,
|
|
} as const
|
|
|
|
function selectProvider(id: string) {
|
|
selectedId.value = id
|
|
apiKey.value = ''
|
|
credentialValues.value = {}
|
|
modelSearch.value = ''
|
|
status.value = ''
|
|
oauthInfo.value = null
|
|
oauthStatus.value = null
|
|
selectedTestModel.value =
|
|
state.value.providers
|
|
.find((provider) => provider.provider_id === id)
|
|
?.models.find((model) => model.enabled)?.id ?? ''
|
|
}
|
|
|
|
function fieldName(field: string) {
|
|
const message = settingFieldMessages[field as keyof typeof settingFieldMessages]
|
|
return message ? formatMessage(message) : field
|
|
}
|
|
|
|
function providerDescription(providerId: string) {
|
|
return providerDescriptions.value[`${providerId}.description`] ?? ''
|
|
}
|
|
|
|
async function reloadState() {
|
|
await getAIState()
|
|
selectedTestModel.value =
|
|
selectedConfig.value?.models.find((model) => model.enabled)?.id ?? selectedTestModel.value
|
|
}
|
|
|
|
async function openSponsoredWebsite() {
|
|
if (!selectedDefinition.value?.sponsored) return
|
|
const url = sponsoredProviderWebsites[selectedDefinition.value.id]
|
|
if (!url) return
|
|
try {
|
|
await openUrl(url)
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function setMasterEnabled(enabled: boolean) {
|
|
const previous = state.value.settings.enabled
|
|
state.value.settings.enabled = enabled
|
|
masterBusy.value = true
|
|
try {
|
|
await updateAISettings(enabled)
|
|
} catch (error) {
|
|
state.value.settings.enabled = previous
|
|
handleError(error)
|
|
} finally {
|
|
masterBusy.value = false
|
|
}
|
|
}
|
|
|
|
async function saveProvider() {
|
|
const config = selectedConfig.value
|
|
if (!config) return false
|
|
busy.value = true
|
|
try {
|
|
await updateAIProvider({
|
|
provider_id: config.provider_id,
|
|
custom_name: config.custom_name,
|
|
enabled: config.enabled,
|
|
endpoint: config.endpoint,
|
|
settings: config.settings,
|
|
})
|
|
status.value = formatMessage(messages.saved)
|
|
return true
|
|
} catch (error) {
|
|
handleError(error)
|
|
return false
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function setProviderEnabled(enabled: boolean) {
|
|
if (!selectedConfig.value) return
|
|
await setProviderEnabledById(selectedConfig.value.provider_id, enabled)
|
|
}
|
|
|
|
async function setProviderEnabledById(providerId: string, enabled: boolean) {
|
|
const config = state.value.providers.find((provider) => provider.provider_id === providerId)
|
|
if (!config) return
|
|
const previous = config.enabled
|
|
config.enabled = enabled
|
|
busy.value = true
|
|
try {
|
|
await updateAIProvider({
|
|
provider_id: config.provider_id,
|
|
custom_name: config.custom_name,
|
|
enabled: config.enabled,
|
|
endpoint: config.endpoint,
|
|
settings: config.settings,
|
|
})
|
|
} catch (error) {
|
|
config.enabled = previous
|
|
handleError(error)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function saveApiKey() {
|
|
if (!selectedConfig.value || !apiKey.value.trim()) return
|
|
busy.value = true
|
|
try {
|
|
if (!(await saveProvider())) return
|
|
await setAIProviderKey(selectedConfig.value.provider_id, apiKey.value)
|
|
apiKey.value = ''
|
|
await reloadState()
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function clearApiKey() {
|
|
if (!selectedConfig.value) return
|
|
try {
|
|
await setAIProviderKey(selectedConfig.value.provider_id, null)
|
|
await reloadState()
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
function credentialConfigured(name: string) {
|
|
return selectedConfig.value?.configured_credentials.includes(name) ?? false
|
|
}
|
|
|
|
async function saveCredential(name: string) {
|
|
if (!selectedConfig.value || !credentialValues.value[name]?.trim()) return
|
|
busy.value = true
|
|
try {
|
|
if (!(await saveProvider())) return
|
|
await setAIProviderCredential(
|
|
selectedConfig.value.provider_id,
|
|
name,
|
|
credentialValues.value[name],
|
|
)
|
|
credentialValues.value[name] = ''
|
|
await reloadState()
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function clearCredential(name: string) {
|
|
if (!selectedConfig.value) return
|
|
try {
|
|
await setAIProviderCredential(selectedConfig.value.provider_id, name, null)
|
|
await reloadState()
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function saveCredentials(names: string[]) {
|
|
if (!selectedConfig.value) return
|
|
const values = names
|
|
.map((name) => [name, credentialValues.value[name]?.trim()] as const)
|
|
.filter((entry): entry is readonly [string, string] => !!entry[1])
|
|
if (!values.length) return
|
|
busy.value = true
|
|
try {
|
|
if (!(await saveProvider())) return
|
|
await Promise.all(
|
|
values.map(([name, secret]) =>
|
|
setAIProviderCredential(selectedConfig.value!.provider_id, name, secret),
|
|
),
|
|
)
|
|
for (const [name] of values) credentialValues.value[name] = ''
|
|
await reloadState()
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function clearCredentials(names: string[]) {
|
|
if (!selectedConfig.value) return
|
|
busy.value = true
|
|
try {
|
|
await Promise.all(
|
|
names.map((name) => setAIProviderCredential(selectedConfig.value!.provider_id, name, null)),
|
|
)
|
|
await reloadState()
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function copyOAuthCode() {
|
|
if (!oauthInfo.value) return
|
|
try {
|
|
await navigator.clipboard.writeText(oauthInfo.value.user_code)
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function openOAuthAuthorization() {
|
|
if (!oauthInfo.value) return
|
|
try {
|
|
await openUrl(oauthInfo.value.verification_uri_complete ?? oauthInfo.value.verification_uri)
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function setModelEnabled(modelId: string, enabled: boolean) {
|
|
const config = selectedConfig.value
|
|
const model = config?.models.find((item) => item.id === modelId)
|
|
if (!config || !model) return
|
|
model.enabled = enabled
|
|
try {
|
|
await updateAIModel({
|
|
provider_id: config.provider_id,
|
|
model_id: model.id,
|
|
display_name: model.name,
|
|
enabled,
|
|
})
|
|
if (enabled && !selectedTestModel.value) selectedTestModel.value = model.id
|
|
} catch (error) {
|
|
model.enabled = !enabled
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function addModel() {
|
|
const config = selectedConfig.value
|
|
if (!config || !customModelId.value.trim()) return
|
|
try {
|
|
await updateAIModel({
|
|
provider_id: config.provider_id,
|
|
model_id: customModelId.value,
|
|
display_name: customModelName.value,
|
|
enabled: true,
|
|
})
|
|
customModelId.value = ''
|
|
customModelName.value = ''
|
|
await reloadState()
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function removeModel(modelId: string) {
|
|
if (!selectedConfig.value) return
|
|
try {
|
|
await removeAIModel(selectedConfig.value.provider_id, modelId)
|
|
await reloadState()
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function refreshModels() {
|
|
if (!selectedConfig.value) return
|
|
busy.value = true
|
|
try {
|
|
await fetchAIModels(selectedConfig.value.provider_id)
|
|
await reloadState()
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function testProvider() {
|
|
if (!selectedConfig.value || !selectedTestModel.value) return
|
|
busy.value = true
|
|
status.value = ''
|
|
try {
|
|
if (!(await saveProvider())) return
|
|
const response = await testAIProvider(selectedConfig.value.provider_id, selectedTestModel.value)
|
|
status.value = formatMessage(messages.testSuccess, { response })
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
function stopOAuthPolling() {
|
|
if (oauthTimer) clearTimeout(oauthTimer)
|
|
oauthTimer = undefined
|
|
}
|
|
|
|
async function pollOAuth(info: OAuthDeviceCode, interval = info.interval) {
|
|
if (oauthChecking.value) return
|
|
oauthChecking.value = true
|
|
try {
|
|
const result = await pollAIOAuth(info.flow_id)
|
|
oauthStatus.value = result
|
|
if (result === 'success') {
|
|
stopOAuthPolling()
|
|
oauthInfo.value = null
|
|
await reloadState()
|
|
return
|
|
}
|
|
if (result === 'expired' || result === 'denied') {
|
|
stopOAuthPolling()
|
|
return
|
|
}
|
|
oauthTimer = setTimeout(
|
|
() => void pollOAuth(info, result === 'slow_down' ? interval + 5 : interval),
|
|
(result === 'slow_down' ? interval + 5 : interval) * 1000,
|
|
)
|
|
} catch (error) {
|
|
stopOAuthPolling()
|
|
handleError(error)
|
|
} finally {
|
|
oauthChecking.value = false
|
|
}
|
|
}
|
|
|
|
async function checkOAuthStatus() {
|
|
if (!oauthInfo.value) return
|
|
const info = oauthInfo.value
|
|
stopOAuthPolling()
|
|
await pollOAuth(info)
|
|
}
|
|
|
|
function checkOAuthOnFocus() {
|
|
if (oauthInfo.value && (oauthStatus.value === 'pending' || oauthStatus.value === 'slow_down')) {
|
|
void checkOAuthStatus()
|
|
}
|
|
}
|
|
|
|
async function connectOAuth() {
|
|
if (!selectedConfig.value) return
|
|
stopOAuthPolling()
|
|
busy.value = true
|
|
try {
|
|
const info = await beginAIOAuth(selectedConfig.value.provider_id)
|
|
oauthInfo.value = info
|
|
oauthStatus.value = 'pending'
|
|
await openUrl(info.verification_uri_complete ?? info.verification_uri)
|
|
oauthTimer = setTimeout(() => void pollOAuth(info), 2000)
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function disconnectOAuth() {
|
|
if (!selectedConfig.value) return
|
|
try {
|
|
await disconnectAIOAuth(selectedConfig.value.provider_id)
|
|
await reloadState()
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
stopOAuthPolling()
|
|
window.removeEventListener('focus', checkOAuthOnFocus)
|
|
})
|
|
onMounted(async () => {
|
|
window.addEventListener('focus', checkOAuthOnFocus)
|
|
try {
|
|
const [loadedCatalog] = await Promise.all([getAICatalog(), getAIState()])
|
|
catalog.value = loadedCatalog
|
|
selectedId.value = 'all'
|
|
selectProvider(selectedId.value)
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="loading" class="flex h-full min-h-48 items-center justify-center">
|
|
<SpinnerIcon class="size-6 animate-spin text-secondary" />
|
|
</div>
|
|
|
|
<div v-else id="settings-target-ai-providers" tabindex="-1" class="ai-provider-layout">
|
|
<aside class="ai-provider-sidebar">
|
|
<div class="ai-provider-search">
|
|
<StyledInput
|
|
v-model="search"
|
|
:icon="SearchIcon"
|
|
:placeholder="formatMessage(messages.search)"
|
|
clearable
|
|
wrapper-class="w-full"
|
|
/>
|
|
</div>
|
|
<div class="ai-provider-list">
|
|
<button
|
|
v-if="!search.trim()"
|
|
type="button"
|
|
class="ai-provider-item mb-1"
|
|
:class="{ selected: selectedId === 'all' }"
|
|
@click="selectProvider('all')"
|
|
>
|
|
<GridIcon class="size-[22px] shrink-0" />
|
|
<span class="min-w-0 flex-1 truncate text-left text-sm font-semibold">
|
|
{{ formatMessage(messages.allProviders) }}
|
|
</span>
|
|
</button>
|
|
|
|
<div v-if="enabledProviderItems.length" class="flex flex-none flex-col gap-1">
|
|
<p class="m-0 flex items-center justify-between px-2 pb-1 pt-2 text-xs font-semibold text-secondary">
|
|
<span>{{ formatMessage(messages.enabledProviders) }}</span>
|
|
<span>{{ enabledProviderItems.length }}</span>
|
|
</p>
|
|
<button
|
|
v-for="{ definition: provider } in enabledProviderItems"
|
|
:key="provider.id"
|
|
type="button"
|
|
class="ai-provider-item"
|
|
:class="{ selected: selectedId === provider.id }"
|
|
@click="selectProvider(provider.id)"
|
|
>
|
|
<AIIcon kind="provider-avatar" :value="provider.id" :size="22" />
|
|
<span class="min-w-0 flex-1 truncate text-left text-sm font-semibold">
|
|
{{ provider.name }}
|
|
</span>
|
|
<span class="size-2 shrink-0 rounded-full bg-green" />
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="sponsoredProviderItems.length" class="flex flex-none flex-col gap-1">
|
|
<p class="m-0 flex items-center justify-between px-2 pb-1 pt-2 text-xs font-semibold text-secondary">
|
|
<span>{{ formatMessage(messages.sponsoredProviders) }}</span>
|
|
<span>{{ sponsoredProviderItems.length }}</span>
|
|
</p>
|
|
<button
|
|
v-for="{ definition: provider, config } in sponsoredProviderItems"
|
|
:key="provider.id"
|
|
type="button"
|
|
class="ai-provider-item"
|
|
:class="{ selected: selectedId === provider.id }"
|
|
@click="selectProvider(provider.id)"
|
|
>
|
|
<AIIcon kind="provider-avatar" :value="provider.id" :size="22" />
|
|
<span class="min-w-0 flex-1 truncate text-left text-sm font-semibold">
|
|
{{ provider.name }}
|
|
</span>
|
|
<span v-if="config.enabled" class="size-2 shrink-0 rounded-full bg-green" />
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="disabledProviderItems.length" class="flex flex-none flex-col gap-1">
|
|
<p class="m-0 flex items-center justify-between px-2 pb-1 pt-2 text-xs font-semibold text-secondary">
|
|
<span>{{ formatMessage(messages.disabledProviders) }}</span>
|
|
<span>{{ disabledProviderItems.length }}</span>
|
|
</p>
|
|
<button
|
|
v-for="{ definition: provider } in disabledProviderItems"
|
|
:key="provider.id"
|
|
type="button"
|
|
class="ai-provider-item"
|
|
:class="{ selected: selectedId === provider.id }"
|
|
@click="selectProvider(provider.id)"
|
|
>
|
|
<AIIcon kind="provider-avatar" :value="provider.id" :size="22" />
|
|
<span class="min-w-0 flex-1 truncate text-left text-sm font-semibold">
|
|
{{ provider.name }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<p v-if="filteredProviderItems.length === 0" class="m-2 text-sm text-secondary">
|
|
{{ formatMessage(messages.noProviders) }}
|
|
</p>
|
|
</div>
|
|
<div class="ai-master-switch">
|
|
<label for="ai-master-switch" class="min-w-0 text-sm font-semibold text-contrast">
|
|
{{ formatMessage(messages.masterSwitch) }}
|
|
</label>
|
|
<Toggle
|
|
id="ai-master-switch"
|
|
:model-value="state.settings.enabled"
|
|
:disabled="masterBusy"
|
|
small
|
|
@update:model-value="setMasterEnabled"
|
|
/>
|
|
</div>
|
|
</aside>
|
|
|
|
<section v-if="selectedId === 'all'" class="ai-provider-overview">
|
|
<div v-if="allEnabledProviderItems.length" class="ai-overview-group">
|
|
<div class="ai-overview-heading">
|
|
<h2>{{ formatMessage(messages.enabledProviders) }}</h2>
|
|
<span>{{ allEnabledProviderItems.length }}</span>
|
|
</div>
|
|
<div class="ai-provider-grid grid grid-cols-2 gap-3">
|
|
<article
|
|
v-for="{ definition, config } in allEnabledProviderItems"
|
|
:key="definition.id"
|
|
class="ai-provider-card"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="ai-provider-card-main"
|
|
@click="selectProvider(definition.id)"
|
|
>
|
|
<span class="ai-provider-card-title">
|
|
<template v-if="definition.id === 'chatgpt'">
|
|
<AIIcon kind="provider-avatar" :value="definition.id" :size="24" />
|
|
<strong>{{ definition.name }}</strong>
|
|
</template>
|
|
<AIIcon v-else kind="provider-combine" :value="definition.id" :size="24" />
|
|
</span>
|
|
<span class="ai-provider-card-description">
|
|
{{ providerDescription(definition.id) }}
|
|
</span>
|
|
</button>
|
|
<div class="mt-auto flex min-h-11 items-center justify-between gap-3 bg-transparent px-4 text-xs font-semibold text-secondary">
|
|
<span>
|
|
<span class="capitalize">{{ definition.protocol }}</span>
|
|
·
|
|
{{ formatMessage(messages.configuredModels, { count: config.models.length }) }}
|
|
</span>
|
|
<Toggle
|
|
:id="`ai-overview-provider-${definition.id}`"
|
|
:model-value="config.enabled"
|
|
:disabled="busy"
|
|
small
|
|
@update:model-value="setProviderEnabledById(definition.id, $event)"
|
|
/>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="allSponsoredProviderItems.length" class="ai-overview-group">
|
|
<div class="ai-overview-heading">
|
|
<h2>{{ formatMessage(messages.sponsoredProviders) }}</h2>
|
|
<span>{{ allSponsoredProviderItems.length }}</span>
|
|
</div>
|
|
<div class="ai-provider-grid grid grid-cols-2 gap-3">
|
|
<article
|
|
v-for="{ definition, config } in allSponsoredProviderItems"
|
|
:key="definition.id"
|
|
class="ai-provider-card"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="ai-provider-card-main"
|
|
@click="selectProvider(definition.id)"
|
|
>
|
|
<span class="ai-provider-card-title">
|
|
<template v-if="definition.sponsored">
|
|
<AIIcon kind="provider-wordmark" :value="definition.id" :size="28" />
|
|
</template>
|
|
<template v-else-if="definition.id === 'chatgpt'">
|
|
<AIIcon kind="provider-avatar" :value="definition.id" :size="24" />
|
|
<strong>{{ definition.name }}</strong>
|
|
</template>
|
|
<AIIcon v-else kind="provider-combine" :value="definition.id" :size="24" />
|
|
</span>
|
|
<span class="ai-provider-card-description">
|
|
{{ providerDescription(definition.id) }}
|
|
</span>
|
|
</button>
|
|
<div class="mt-auto flex min-h-11 items-center justify-between gap-3 bg-transparent px-4 text-xs font-semibold text-secondary">
|
|
<span>
|
|
<span class="capitalize">{{ definition.protocol }}</span>
|
|
·
|
|
{{ formatMessage(messages.configuredModels, { count: config.models.length }) }}
|
|
</span>
|
|
<Toggle
|
|
:id="`ai-overview-provider-${definition.id}`"
|
|
:model-value="config.enabled"
|
|
:disabled="busy"
|
|
small
|
|
@update:model-value="setProviderEnabledById(definition.id, $event)"
|
|
/>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ai-overview-group">
|
|
<div class="ai-overview-heading">
|
|
<h2>{{ formatMessage(messages.disabledProviders) }}</h2>
|
|
<span>{{ allDisabledProviderItems.length }}</span>
|
|
</div>
|
|
<div class="ai-provider-grid grid grid-cols-2 gap-3">
|
|
<article
|
|
v-for="{ definition, config } in allDisabledProviderItems"
|
|
:key="definition.id"
|
|
class="ai-provider-card"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="ai-provider-card-main"
|
|
@click="selectProvider(definition.id)"
|
|
>
|
|
<span class="ai-provider-card-title">
|
|
<template v-if="definition.id === 'chatgpt'">
|
|
<AIIcon kind="provider-avatar" :value="definition.id" :size="24" />
|
|
<strong>{{ definition.name }}</strong>
|
|
</template>
|
|
<AIIcon v-else kind="provider-combine" :value="definition.id" :size="24" />
|
|
</span>
|
|
<span class="ai-provider-card-description">
|
|
{{ providerDescription(definition.id) }}
|
|
</span>
|
|
</button>
|
|
<div class="mt-auto flex min-h-11 items-center justify-between gap-3 bg-transparent px-4 text-xs font-semibold text-secondary">
|
|
<span>
|
|
<span class="capitalize">{{ definition.protocol }}</span>
|
|
·
|
|
{{ formatMessage(messages.configuredModels, { count: config.models.length }) }}
|
|
</span>
|
|
<Toggle
|
|
:id="`ai-overview-provider-${definition.id}`"
|
|
:model-value="config.enabled"
|
|
:disabled="busy"
|
|
small
|
|
@update:model-value="setProviderEnabledById(definition.id, $event)"
|
|
/>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-else-if="selectedDefinition && selectedConfig" class="ai-provider-detail">
|
|
<header class="ai-provider-detail-header">
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<AIIcon kind="provider-avatar" :value="selectedDefinition.id" :size="40" />
|
|
<div class="min-w-0">
|
|
<h3 class="m-0 truncate text-base font-semibold text-contrast">
|
|
{{ selectedDefinition.name }}
|
|
</h3>
|
|
<p class="m-0 mt-0.5 text-xs text-secondary">
|
|
{{ selectedDefinition.protocol }} · {{ selectedDefinition.id }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex shrink-0 items-center gap-2 text-sm font-semibold text-secondary">
|
|
<Button v-if="selectedDefinition.sponsored" type="quiet" @click="openSponsoredWebsite">
|
|
<ExternalIcon />{{ formatMessage(messages.visitWebsite) }}
|
|
</Button>
|
|
{{ formatMessage(selectedConfig.enabled ? messages.enabled : messages.disabled) }}
|
|
<Toggle
|
|
:id="`ai-provider-${selectedDefinition.id}`"
|
|
:model-value="selectedConfig.enabled"
|
|
:disabled="busy"
|
|
@update:model-value="setProviderEnabled"
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="ai-provider-detail-scroll">
|
|
<div
|
|
v-if="
|
|
!['bedrock', 'vertexai'].includes(selectedDefinition.id) ||
|
|
selectedDefinition.required_settings.length
|
|
"
|
|
class="ai-provider-connection-fields grid grid-cols-1 gap-4 lg:grid-cols-2"
|
|
>
|
|
<label
|
|
v-if="!['bedrock', 'vertexai'].includes(selectedDefinition.id)"
|
|
class="flex flex-col gap-1.5 text-sm font-semibold text-contrast lg:col-span-2"
|
|
>
|
|
{{ formatMessage(messages.providerEndpoint) }}
|
|
<StyledInput v-model="selectedConfig.endpoint" type="url" wrapper-class="w-full" />
|
|
</label>
|
|
<label
|
|
v-for="field in selectedDefinition.required_settings"
|
|
:key="field"
|
|
class="flex flex-col gap-1.5 text-sm font-semibold text-contrast"
|
|
>
|
|
{{ fieldName(field) }}
|
|
<StyledInput v-model="selectedConfig.settings[field]" wrapper-class="w-full" />
|
|
</label>
|
|
</div>
|
|
|
|
<div
|
|
v-if="
|
|
selectedDefinition.id === 'bedrock' ||
|
|
selectedDefinition.id === 'vertexai' ||
|
|
selectedDefinition.auth_type === 'apiKey' ||
|
|
selectedDefinition.auth_type === 'oauthDeviceFlow'
|
|
"
|
|
class="ai-provider-credentials"
|
|
>
|
|
<div v-if="selectedDefinition.id === 'bedrock'" class="flex flex-col gap-3">
|
|
<div class="flex flex-col gap-1.5 text-sm font-semibold text-contrast">
|
|
{{ formatMessage(messages.bedrockAuthentication) }}
|
|
<Tabs v-model:value="bedrockAuthMode" :tabs="bedrockAuthTabs" />
|
|
</div>
|
|
<template v-if="bedrockAuthMode === 'api-key'">
|
|
<label class="flex flex-col gap-1.5 text-sm font-semibold text-contrast">
|
|
{{ formatMessage(messages.apiKey) }}
|
|
<StyledInput
|
|
v-model="apiKey"
|
|
:icon="KeyIcon"
|
|
type="password"
|
|
autocomplete="off"
|
|
wrapper-class="w-full"
|
|
/>
|
|
</label>
|
|
<p v-if="selectedConfig.has_api_key" class="m-0 text-xs text-secondary">
|
|
{{ formatMessage(messages.credentialConfigured) }}
|
|
</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button type="base" :disabled="busy || !apiKey.trim()" @click="saveApiKey">
|
|
<CheckIcon />{{ formatMessage(messages.saveKey) }}
|
|
</Button>
|
|
<Button
|
|
v-if="selectedConfig.has_api_key"
|
|
type="outlined"
|
|
color="red"
|
|
:disabled="busy"
|
|
@click="clearApiKey"
|
|
>
|
|
<TrashIcon />{{ formatMessage(messages.clearKey) }}
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<label
|
|
v-for="field in bedrockCredentialFields"
|
|
:key="field.name"
|
|
class="flex flex-col gap-1.5 text-sm font-semibold text-contrast"
|
|
>
|
|
{{ field.label }}
|
|
<StyledInput
|
|
v-model="credentialValues[field.name]"
|
|
:icon="KeyIcon"
|
|
type="password"
|
|
autocomplete="off"
|
|
wrapper-class="w-full"
|
|
/>
|
|
<span
|
|
v-if="credentialConfigured(field.name)"
|
|
class="font-normal text-xs text-secondary"
|
|
>
|
|
{{ formatMessage(messages.credentialConfigured) }}
|
|
</span>
|
|
</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button
|
|
type="base"
|
|
:disabled="
|
|
busy ||
|
|
!bedrockCredentialFields.some((field) => credentialValues[field.name]?.trim())
|
|
"
|
|
@click="saveCredentials(bedrockCredentialFields.map((field) => field.name))"
|
|
>
|
|
<CheckIcon />{{ formatMessage(messages.saveCredential) }}
|
|
</Button>
|
|
<Button
|
|
v-if="bedrockCredentialFields.some((field) => credentialConfigured(field.name))"
|
|
type="outlined"
|
|
color="red"
|
|
:disabled="busy"
|
|
@click="clearCredentials(bedrockCredentialFields.map((field) => field.name))"
|
|
>
|
|
<TrashIcon />{{ formatMessage(messages.clearCredential) }}
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-else-if="selectedDefinition.id === 'vertexai'" class="flex flex-col gap-2">
|
|
<label class="flex flex-col gap-1.5 text-sm font-semibold text-contrast">
|
|
{{ formatMessage(messages.vertexServiceAccount) }}
|
|
<StyledInput
|
|
v-model="credentialValues['vertex-service-account']"
|
|
:icon="KeyIcon"
|
|
type="password"
|
|
autocomplete="off"
|
|
wrapper-class="w-full"
|
|
/>
|
|
<span class="font-normal text-xs text-secondary">
|
|
{{ formatMessage(messages.vertexServiceAccountDescription) }}
|
|
</span>
|
|
<span
|
|
v-if="credentialConfigured('vertex-service-account')"
|
|
class="font-normal text-xs text-secondary"
|
|
>
|
|
{{ formatMessage(messages.credentialConfigured) }}
|
|
</span>
|
|
</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button
|
|
type="base"
|
|
:disabled="busy || !credentialValues['vertex-service-account']?.trim()"
|
|
@click="saveCredential('vertex-service-account')"
|
|
>
|
|
<CheckIcon />{{ formatMessage(messages.saveCredential) }}
|
|
</Button>
|
|
<Button
|
|
v-if="credentialConfigured('vertex-service-account')"
|
|
type="outlined"
|
|
color="red"
|
|
:disabled="busy"
|
|
@click="clearCredential('vertex-service-account')"
|
|
>
|
|
<TrashIcon />{{ formatMessage(messages.clearCredential) }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="selectedDefinition.auth_type === 'apiKey'" class="flex flex-col gap-2">
|
|
<label class="flex flex-col gap-1.5 text-sm font-semibold text-contrast">
|
|
{{ formatMessage(messages.apiKey) }}
|
|
<StyledInput
|
|
v-model="apiKey"
|
|
:icon="KeyIcon"
|
|
type="password"
|
|
autocomplete="off"
|
|
wrapper-class="w-full"
|
|
/>
|
|
</label>
|
|
<p class="m-0 text-xs text-secondary">
|
|
{{
|
|
formatMessage(
|
|
selectedConfig.has_api_key ? messages.apiKeyConfigured : messages.apiKeyOptional,
|
|
)
|
|
}}
|
|
</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button type="base" :disabled="busy || !apiKey.trim()" @click="saveApiKey">
|
|
<CheckIcon />{{ formatMessage(messages.saveKey) }}
|
|
</Button>
|
|
<Button
|
|
v-if="selectedConfig.has_api_key"
|
|
type="outlined"
|
|
color="red"
|
|
:disabled="busy"
|
|
@click="clearApiKey"
|
|
>
|
|
<TrashIcon />{{ formatMessage(messages.clearKey) }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="selectedDefinition.auth_type === 'oauthDeviceFlow'"
|
|
class="flex flex-col gap-3"
|
|
>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span v-if="selectedConfig.oauth_connected" class="text-sm font-semibold text-green">
|
|
{{ formatMessage(messages.connected) }}
|
|
</span>
|
|
<Button type="colored" color="brand" :disabled="busy" @click="connectOAuth">
|
|
<ExternalIcon />
|
|
{{
|
|
formatMessage(
|
|
selectedConfig.oauth_connected ? messages.reconnect : messages.connect,
|
|
)
|
|
}}
|
|
</Button>
|
|
<Button
|
|
v-if="selectedConfig.oauth_connected"
|
|
type="outlined"
|
|
color="red"
|
|
:disabled="busy"
|
|
@click="disconnectOAuth"
|
|
>
|
|
<LogOutIcon />{{ formatMessage(messages.disconnect) }}
|
|
</Button>
|
|
</div>
|
|
<div v-if="oauthInfo" class="oauth-code-row">
|
|
<div class="min-w-0 flex-1">
|
|
<p class="m-0 text-xs font-semibold text-secondary">
|
|
{{ formatMessage(messages.oauthCode) }}
|
|
</p>
|
|
<p class="m-0 mt-1 font-mono text-lg font-semibold text-contrast">
|
|
{{ oauthInfo.user_code }}
|
|
</p>
|
|
</div>
|
|
<Button type="quiet" :title="formatMessage(messages.copyCode)" @click="copyOAuthCode">
|
|
<CopyIcon />
|
|
</Button>
|
|
<Button
|
|
type="quiet"
|
|
:disabled="oauthChecking"
|
|
:title="
|
|
formatMessage(
|
|
oauthChecking ? messages.checkingAuthorization : messages.checkAuthorization,
|
|
)
|
|
"
|
|
@click="checkOAuthStatus"
|
|
>
|
|
<RefreshCwIcon :class="{ 'animate-spin': oauthChecking }" />
|
|
</Button>
|
|
<Button
|
|
type="quiet"
|
|
:title="formatMessage(messages.openAuthorization)"
|
|
@click="openOAuthAuthorization"
|
|
>
|
|
<ExternalIcon />
|
|
</Button>
|
|
</div>
|
|
<p
|
|
v-if="oauthStatus === 'pending' || oauthStatus === 'slow_down'"
|
|
class="m-0 text-sm text-secondary"
|
|
>
|
|
{{ formatMessage(messages.oauthPending) }}
|
|
</p>
|
|
<p v-else-if="oauthStatus === 'expired'" class="m-0 text-sm text-red">
|
|
{{ formatMessage(messages.oauthExpired) }}
|
|
</p>
|
|
<p v-else-if="oauthStatus === 'denied'" class="m-0 text-sm text-red">
|
|
{{ formatMessage(messages.oauthDenied) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ai-provider-models-section">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<h4 class="m-0 text-sm font-semibold text-contrast">
|
|
{{ formatMessage(messages.models) }}
|
|
</h4>
|
|
<div class="ai-model-actions">
|
|
<StyledInput
|
|
v-model="modelSearch"
|
|
:icon="SearchIcon"
|
|
:placeholder="formatMessage(messages.searchModels)"
|
|
clearable
|
|
wrapper-class="w-full sm:w-64"
|
|
/>
|
|
<Button
|
|
v-if="selectedDefinition.show_model_fetcher"
|
|
type="outlined"
|
|
:disabled="busy"
|
|
@click="refreshModels"
|
|
>
|
|
<RefreshCwIcon :class="{ 'animate-spin': busy }" />
|
|
{{ formatMessage(busy ? messages.refreshingModels : messages.refreshModels) }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div v-if="filteredModels.length" class="ai-model-list">
|
|
<div v-for="model in filteredModels" :key="model.id" class="ai-model-row">
|
|
<AIIcon kind="model" :value="model.id" :size="32" />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="m-0 truncate text-sm font-semibold text-contrast">
|
|
{{ model.name || model.id }}
|
|
</p>
|
|
<p class="m-0 mt-0.5 truncate text-xs text-secondary">
|
|
{{ model.id }} · {{ model.source }}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
v-if="model.source === 'custom'"
|
|
type="quiet"
|
|
:title="formatMessage(messages.clearKey)"
|
|
@click="removeModel(model.id)"
|
|
>
|
|
<TrashIcon />
|
|
</Button>
|
|
<Toggle
|
|
:id="`ai-model-${selectedDefinition.id}-${model.id}`"
|
|
:model-value="model.enabled"
|
|
:disabled="busy"
|
|
small
|
|
@update:model-value="setModelEnabled(model.id, $event)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p v-else class="m-0 text-sm text-secondary">
|
|
{{
|
|
formatMessage(
|
|
selectedConfig.models.length ? messages.noMatchingModels : messages.noModels,
|
|
)
|
|
}}
|
|
</p>
|
|
<div class="grid grid-cols-1 gap-2 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
|
<StyledInput
|
|
v-model="customModelId"
|
|
:placeholder="formatMessage(messages.modelId)"
|
|
wrapper-class="w-full"
|
|
/>
|
|
<StyledInput
|
|
v-model="customModelName"
|
|
:placeholder="formatMessage(messages.modelName)"
|
|
wrapper-class="w-full"
|
|
/>
|
|
<Button type="base" :disabled="!customModelId.trim()" @click="addModel">
|
|
<PlusIcon />{{ formatMessage(messages.addModel) }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ai-provider-actions">
|
|
<label class="flex min-w-48 flex-1 flex-col gap-1.5 text-sm font-semibold text-contrast">
|
|
{{ formatMessage(messages.testModel) }}
|
|
<Combobox v-model="selectedTestModel" :options="modelOptions" />
|
|
</label>
|
|
<Button type="base" :disabled="busy" @click="saveProvider">
|
|
<CheckIcon />{{ formatMessage(messages.saveProvider) }}
|
|
</Button>
|
|
<Button
|
|
type="colored"
|
|
color="brand"
|
|
:disabled="busy || !selectedTestModel"
|
|
@click="testProvider"
|
|
>
|
|
<PlugIcon />{{ formatMessage(busy ? messages.testing : messages.test) }}
|
|
</Button>
|
|
</div>
|
|
<p v-if="status" class="m-0 text-sm text-secondary">{{ status }}</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.ai-provider-layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(14rem, 16rem) minmax(0, 1fr);
|
|
grid-template-rows: minmax(0, 1fr);
|
|
height: 100%;
|
|
min-height: 0;
|
|
width: 100%;
|
|
overflow: hidden;
|
|
box-sizing: border-box;
|
|
border-radius: var(--radius-lg);
|
|
}
|
|
|
|
.ai-provider-sidebar {
|
|
display: flex;
|
|
height: 100%;
|
|
min-height: 0;
|
|
min-width: 0;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
border-right: 1px solid
|
|
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
|
|
}
|
|
|
|
.ai-provider-search {
|
|
flex: none;
|
|
padding: var(--gap-lg);
|
|
border-bottom: 1px solid
|
|
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
|
|
}
|
|
|
|
.ai-provider-list,
|
|
.ai-provider-overview,
|
|
.ai-model-list {
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.ai-provider-list {
|
|
display: flex;
|
|
flex: 1 1 0;
|
|
flex-direction: column;
|
|
gap: var(--gap-sm);
|
|
overscroll-behavior: contain;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
padding: 0 var(--gap-md) var(--gap-md);
|
|
scrollbar-gutter: stable;
|
|
touch-action: pan-y;
|
|
}
|
|
|
|
.ai-provider-item {
|
|
display: flex;
|
|
width: 100%;
|
|
min-height: 2.25rem;
|
|
flex: none;
|
|
align-items: center;
|
|
gap: var(--gap-sm);
|
|
box-sizing: border-box;
|
|
padding: 0 var(--gap-sm);
|
|
border: 0;
|
|
border-radius: var(--radius-sm);
|
|
background: transparent;
|
|
color: var(--color-secondary);
|
|
cursor: pointer;
|
|
transition:
|
|
background-color 120ms ease,
|
|
color 120ms ease;
|
|
}
|
|
|
|
.ai-provider-item:hover {
|
|
background: var(--surface-3);
|
|
color: var(--color-contrast);
|
|
}
|
|
|
|
.ai-provider-item.selected {
|
|
background: var(--color-button-bg-selected);
|
|
color: var(--color-button-text-selected);
|
|
}
|
|
|
|
.ai-master-switch {
|
|
display: flex;
|
|
min-height: 3.5rem;
|
|
flex: none;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--gap-md);
|
|
padding: var(--gap-md) var(--gap-lg);
|
|
border-top: 1px solid
|
|
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
|
|
}
|
|
|
|
.ai-provider-overview {
|
|
display: flex;
|
|
height: 100%;
|
|
min-width: 0;
|
|
box-sizing: border-box;
|
|
flex-direction: column;
|
|
gap: var(--gap-xl);
|
|
padding: var(--gap-xl);
|
|
scrollbar-gutter: stable;
|
|
}
|
|
|
|
.ai-overview-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--gap-md);
|
|
}
|
|
|
|
.ai-overview-heading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--gap-sm);
|
|
}
|
|
|
|
.ai-overview-heading h2 {
|
|
margin: 0;
|
|
color: var(--color-contrast);
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.ai-overview-heading span {
|
|
display: inline-flex;
|
|
min-width: 1.5rem;
|
|
height: 1.5rem;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
padding: 0 0.4rem;
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface-3);
|
|
color: var(--color-secondary);
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.ai-provider-card {
|
|
display: flex;
|
|
min-width: 0;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
border: 1px solid
|
|
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
|
|
border-radius: var(--radius-md);
|
|
background: var(--surface-2);
|
|
transition:
|
|
border-color 150ms ease,
|
|
background-color 150ms ease;
|
|
}
|
|
|
|
.ai-provider-card:hover {
|
|
border-color: var(--surface-5);
|
|
background: var(--surface-3);
|
|
}
|
|
|
|
.ai-provider-card-main {
|
|
display: flex;
|
|
min-height: 8.5rem;
|
|
min-width: 0;
|
|
box-sizing: border-box;
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 0.875rem;
|
|
padding: 1rem;
|
|
border: 0;
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.ai-provider-card-title {
|
|
display: flex;
|
|
min-width: 0;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.ai-provider-card-title strong {
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.ai-provider-card-title strong {
|
|
color: var(--color-contrast);
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.ai-provider-card-description {
|
|
display: -webkit-box;
|
|
overflow: hidden;
|
|
-webkit-box-orient: vertical;
|
|
-webkit-line-clamp: 3;
|
|
color: var(--color-secondary);
|
|
font-size: 0.8125rem;
|
|
line-height: 1.45;
|
|
text-align: left;
|
|
white-space: pre-line;
|
|
}
|
|
|
|
.ai-provider-detail {
|
|
display: flex;
|
|
height: 100%;
|
|
min-width: 0;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.ai-provider-detail-header {
|
|
display: flex;
|
|
min-height: 5.25rem;
|
|
flex: none;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--gap-lg);
|
|
padding: var(--gap-lg) var(--gap-xl);
|
|
border-bottom: 1px solid
|
|
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
|
|
background: transparent;
|
|
}
|
|
|
|
.ai-provider-detail-scroll {
|
|
display: flex;
|
|
min-height: 0;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
gap: var(--gap-xl);
|
|
overflow-y: auto;
|
|
padding: var(--gap-xl);
|
|
scrollbar-gutter: stable;
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.ai-provider-connection-fields > :first-child:last-child {
|
|
grid-column: span 2 / span 2;
|
|
}
|
|
}
|
|
|
|
.ai-provider-credentials {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.875rem;
|
|
}
|
|
|
|
.ai-provider-actions {
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
align-items: end;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.ai-model-actions {
|
|
display: flex;
|
|
min-width: 0;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.ai-model-list {
|
|
display: flex;
|
|
max-height: 15rem;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.ai-model-row {
|
|
display: flex;
|
|
min-height: 3.25rem;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
box-sizing: border-box;
|
|
padding: 0.625rem 0.75rem;
|
|
border-radius: var(--radius-sm);
|
|
transition: background-color 120ms ease;
|
|
}
|
|
|
|
.ai-model-row:hover {
|
|
background: var(--surface-3);
|
|
}
|
|
|
|
.oauth-code-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem;
|
|
border: 1px solid
|
|
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface-2);
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.ai-provider-layout {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.ai-provider-sidebar {
|
|
max-height: 16rem;
|
|
border-right: 0;
|
|
border-bottom: 1px solid
|
|
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
|
|
}
|
|
|
|
.ai-provider-detail {
|
|
height: auto;
|
|
overflow: visible;
|
|
}
|
|
|
|
.ai-provider-detail-scroll {
|
|
overflow: visible;
|
|
}
|
|
|
|
.ai-provider-overview {
|
|
height: auto;
|
|
overflow: visible;
|
|
}
|
|
|
|
.ai-provider-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.ai-model-actions {
|
|
width: 100%;
|
|
justify-content: flex-start;
|
|
margin-left: 0;
|
|
}
|
|
}
|
|
</style>
|