fix(websearch): improve settings UI and hide config when globally disabled
- API Key show/copy buttons moved inside input field (inline icons) - Proxy selector and test button on same row to save vertical space - Test opens a dialog modal instead of inline display - Hide all websearch config in channels/accounts when global toggle is off
This commit is contained in:
@@ -306,6 +306,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Web Search Emulation (Anthropic only, hidden when global disabled) -->
|
||||
<div v-if="section.platform === 'anthropic' && webSearchGlobalEnabled" class="border-t border-gray-200 pt-3 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-xs font-medium text-orange-600 dark:text-orange-400">
|
||||
{{ t('admin.channels.form.webSearchEmulation') }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-[11px] text-amber-500 dark:text-amber-400">
|
||||
{{ t('admin.channels.form.webSearchEmulationHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="section.web_search_emulation" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Mapping -->
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
@@ -560,6 +575,7 @@
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest, AccountStatsPricingRule } from '@/api/admin/channels'
|
||||
import type { PricingFormEntry } from '@/components/admin/channel/types'
|
||||
@@ -583,6 +599,18 @@ import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Web Search global enabled state (loaded once on mount)
|
||||
const webSearchGlobalEnabled = ref(false)
|
||||
async function loadWebSearchGlobalState() {
|
||||
try {
|
||||
const cfg = await adminAPI.settings.getWebSearchEmulationConfig()
|
||||
webSearchGlobalEnabled.value = cfg?.enabled === true && (cfg?.providers?.length ?? 0) > 0
|
||||
} catch (err: unknown) {
|
||||
console.warn('Failed to load web search global state:', err)
|
||||
webSearchGlobalEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Platform Section type ──
|
||||
interface PlatformSection {
|
||||
platform: GroupPlatform
|
||||
@@ -591,6 +619,7 @@ interface PlatformSection {
|
||||
group_ids: number[]
|
||||
model_mapping: Record<string, string>
|
||||
model_pricing: PricingFormEntry[]
|
||||
web_search_emulation: boolean
|
||||
}
|
||||
|
||||
// ── Table columns ──
|
||||
@@ -709,7 +738,8 @@ function addPlatformSection(platform: GroupPlatform) {
|
||||
collapsed: false,
|
||||
group_ids: [],
|
||||
model_mapping: {},
|
||||
model_pricing: []
|
||||
model_pricing: [],
|
||||
web_search_emulation: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -901,10 +931,14 @@ function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
|
||||
}
|
||||
|
||||
// ── Form ↔ API conversion ──
|
||||
function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record<string, Record<string, string>> } {
|
||||
function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record<string, Record<string, string>>, features_config: Record<string, unknown> } {
|
||||
const group_ids: number[] = []
|
||||
const model_pricing: ChannelModelPricing[] = []
|
||||
const model_mapping: Record<string, Record<string, string>> = {}
|
||||
// Preserve existing features_config fields not managed by the form
|
||||
const featuresConfig: Record<string, unknown> = editingChannel.value?.features_config
|
||||
? { ...editingChannel.value.features_config }
|
||||
: {}
|
||||
|
||||
for (const section of form.platforms) {
|
||||
if (!section.enabled) continue
|
||||
@@ -933,7 +967,19 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
|
||||
}
|
||||
}
|
||||
|
||||
return { group_ids, model_pricing, model_mapping }
|
||||
// Collect web_search_emulation (only anthropic platform supports it)
|
||||
const wsEmulation: Record<string, boolean> = {}
|
||||
for (const section of form.platforms) {
|
||||
if (!section.enabled) continue
|
||||
if (section.web_search_emulation && section.platform === 'anthropic') {
|
||||
wsEmulation[section.platform] = true
|
||||
}
|
||||
}
|
||||
if (Object.keys(wsEmulation).length > 0) {
|
||||
featuresConfig.web_search_emulation = wsEmulation
|
||||
}
|
||||
|
||||
return { group_ids, model_pricing, model_mapping, features_config: featuresConfig }
|
||||
}
|
||||
|
||||
function apiToForm(channel: Channel): PlatformSection[] {
|
||||
@@ -977,13 +1023,19 @@ function apiToForm(channel: Channel): PlatformSection[] {
|
||||
intervals: apiIntervalsToForm(p.intervals || [])
|
||||
} as PricingFormEntry))
|
||||
|
||||
// Read web_search_emulation from features_config
|
||||
const fc = channel.features_config
|
||||
const wsEmulation = fc?.web_search_emulation as Record<string, boolean> | undefined
|
||||
const webSearchEnabled = wsEmulation?.[platform] === true
|
||||
|
||||
sections.push({
|
||||
platform,
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
group_ids: groupIds,
|
||||
model_mapping: { ...mapping },
|
||||
model_pricing: pricing
|
||||
model_pricing: pricing,
|
||||
web_search_emulation: webSearchEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1008,10 +1060,10 @@ async function loadChannels() {
|
||||
if (ctrl.signal.aborted || abortController !== ctrl) return
|
||||
channels.value = response.items || []
|
||||
pagination.total = response.total
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return
|
||||
appStore.showError(t('admin.channels.loadError', 'Failed to load channels'))
|
||||
console.error('Error loading channels:', error)
|
||||
} catch (error: unknown) {
|
||||
const e = error as { name?: string; code?: string }
|
||||
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.channels.loadError', 'Failed to load channels')))
|
||||
} finally {
|
||||
if (abortController === ctrl) {
|
||||
loading.value = false
|
||||
@@ -1210,7 +1262,7 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
const { group_ids, model_pricing, model_mapping } = formToAPI()
|
||||
const { group_ids, model_pricing, model_mapping, features_config } = formToAPI()
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
@@ -1224,6 +1276,7 @@ async function handleSubmit() {
|
||||
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
|
||||
billing_model_source: form.billing_model_source,
|
||||
restrict_models: form.restrict_models,
|
||||
features_config,
|
||||
apply_pricing_to_account_stats: form.apply_pricing_to_account_stats,
|
||||
account_stats_pricing_rules: accountStatsRulesToAPI()
|
||||
}
|
||||
@@ -1238,6 +1291,7 @@ async function handleSubmit() {
|
||||
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
|
||||
billing_model_source: form.billing_model_source,
|
||||
restrict_models: form.restrict_models,
|
||||
features_config,
|
||||
apply_pricing_to_account_stats: form.apply_pricing_to_account_stats,
|
||||
account_stats_pricing_rules: accountStatsRulesToAPI()
|
||||
}
|
||||
@@ -1246,12 +1300,10 @@ async function handleSubmit() {
|
||||
}
|
||||
closeDialog()
|
||||
loadChannels()
|
||||
} catch (error: any) {
|
||||
const msg = error.response?.data?.detail || (editingChannel.value
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, editingChannel.value
|
||||
? t('admin.channels.updateError', 'Failed to update channel')
|
||||
: t('admin.channels.createError', 'Failed to create channel'))
|
||||
appStore.showError(msg)
|
||||
console.error('Error saving channel:', error)
|
||||
: t('admin.channels.createError', 'Failed to create channel')))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -1289,9 +1341,8 @@ async function confirmDelete() {
|
||||
showDeleteDialog.value = false
|
||||
deletingChannel.value = null
|
||||
loadChannels()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.channels.deleteError', 'Failed to delete channel'))
|
||||
console.error('Error deleting channel:', error)
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.channels.deleteError', 'Failed to delete channel')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1299,6 +1350,7 @@ async function confirmDelete() {
|
||||
onMounted(() => {
|
||||
loadChannels()
|
||||
loadGroups()
|
||||
loadWebSearchGlobalState()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -1789,40 +1789,42 @@
|
||||
|
||||
<!-- Expanded content -->
|
||||
<div v-if="expandedProviders[pIdx]" class="space-y-3 border-t border-gray-100 px-4 pb-4 pt-3 dark:border-dark-700">
|
||||
<!-- API Key with show/copy -->
|
||||
<!-- API Key with inline show/copy -->
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.apiKey') }}</label>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="provider.api_key"
|
||||
:type="apiKeyVisible[pIdx] ? 'text' : 'password'"
|
||||
class="input flex-1 text-sm"
|
||||
class="input w-full pr-16 text-sm"
|
||||
:placeholder="provider.api_key_configured ? '••••••••' : t('admin.settings.webSearchEmulation.apiKeyPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm px-2"
|
||||
:title="apiKeyVisible[pIdx] ? t('admin.settings.webSearchEmulation.hideApiKey') : t('admin.settings.webSearchEmulation.showApiKey')"
|
||||
@click="apiKeyVisible[pIdx] = !apiKeyVisible[pIdx]"
|
||||
>
|
||||
<svg v-if="!apiKeyVisible[pIdx]" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm px-2"
|
||||
:title="t('admin.settings.webSearchEmulation.copyApiKey')"
|
||||
@click="copyApiKey(pIdx)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
:title="apiKeyVisible[pIdx] ? t('admin.settings.webSearchEmulation.hideApiKey') : t('admin.settings.webSearchEmulation.showApiKey')"
|
||||
@click="apiKeyVisible[pIdx] = !apiKeyVisible[pIdx]"
|
||||
>
|
||||
<svg v-if="!apiKeyVisible[pIdx]" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
:title="t('admin.settings.webSearchEmulation.copyApiKey')"
|
||||
@click="copyApiKey(pIdx)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1858,44 +1860,19 @@
|
||||
<span class="text-xs text-gray-500">{{ provider.quota_used ?? 0 }} / {{ provider.quota_limit }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Proxy -->
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.proxy') }}</label>
|
||||
<ProxySelector v-model="provider.proxy_id" :proxies="webSearchProxies" />
|
||||
</div>
|
||||
|
||||
<!-- Test button -->
|
||||
<div class="border-t border-gray-100 pt-3 dark:border-dark-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="wsTestQuery"
|
||||
type="text"
|
||||
class="input flex-1 text-sm"
|
||||
:placeholder="t('admin.settings.webSearchEmulation.testDefaultQuery')"
|
||||
@keyup.enter="testWebSearchProvider()"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="wsTestLoading"
|
||||
@click="testWebSearchProvider()"
|
||||
>
|
||||
{{ wsTestLoading ? t('admin.settings.webSearchEmulation.testing') : t('admin.settings.webSearchEmulation.test') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Test results -->
|
||||
<div v-if="wsTestResult" class="mt-2 rounded-lg bg-gray-50 p-3 text-xs dark:bg-dark-700">
|
||||
<p class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.webSearchEmulation.testResultProvider') }}: {{ wsTestResult.provider }}
|
||||
</p>
|
||||
<div v-if="wsTestResult.results.length === 0" class="text-gray-400">
|
||||
{{ t('admin.settings.webSearchEmulation.testNoResults') }}
|
||||
</div>
|
||||
<div v-for="(r, rIdx) in wsTestResult.results.slice(0, 3)" :key="rIdx" class="mt-1">
|
||||
<a :href="r.url" target="_blank" class="font-medium text-blue-600 hover:underline dark:text-blue-400">{{ r.title }}</a>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ r.snippet && r.snippet.length > 120 ? r.snippet.slice(0, 120) + '...' : r.snippet }}</p>
|
||||
</div>
|
||||
<!-- Proxy + Test on same row -->
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.proxy') }}</label>
|
||||
<ProxySelector v-model="provider.proxy_id" :proxies="webSearchProxies" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm whitespace-nowrap"
|
||||
@click="openTestDialog()"
|
||||
>
|
||||
{{ t('admin.settings.webSearchEmulation.test') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1903,6 +1880,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Web Search Test Dialog -->
|
||||
<div v-if="wsTestDialogOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="wsTestDialogOpen = false">
|
||||
<div class="mx-4 w-full max-w-lg rounded-xl bg-white p-6 shadow-xl dark:bg-dark-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.webSearchEmulation.testResultTitle') }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="wsTestQuery"
|
||||
type="text"
|
||||
class="input flex-1 text-sm"
|
||||
:placeholder="t('admin.settings.webSearchEmulation.testDefaultQuery')"
|
||||
@keyup.enter="testWebSearchProvider()"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="wsTestLoading"
|
||||
@click="testWebSearchProvider()"
|
||||
>
|
||||
{{ wsTestLoading ? t('admin.settings.webSearchEmulation.testing') : t('admin.settings.webSearchEmulation.test') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Test results -->
|
||||
<div v-if="wsTestResult" class="mt-4 max-h-80 overflow-y-auto rounded-lg bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<p class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.webSearchEmulation.testResultProvider') }}: {{ wsTestResult.provider }}
|
||||
</p>
|
||||
<div v-if="wsTestResult.results.length === 0" class="text-sm text-gray-400">
|
||||
{{ t('admin.settings.webSearchEmulation.testNoResults') }}
|
||||
</div>
|
||||
<div v-for="(r, rIdx) in wsTestResult.results" :key="rIdx" class="mt-2 border-t border-gray-200 pt-2 first:mt-0 first:border-0 first:pt-0 dark:border-dark-600">
|
||||
<a :href="r.url" target="_blank" class="text-sm font-medium text-blue-600 hover:underline dark:text-blue-400">{{ r.title }}</a>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ r.snippet }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="wsTestDialogOpen = false">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /Tab: Gateway — Claude Code, Scheduling -->
|
||||
|
||||
<!-- Tab: General -->
|
||||
@@ -3016,6 +3037,12 @@ const apiKeyVisible = reactive<Record<number, boolean>>({})
|
||||
const wsTestQuery = ref('')
|
||||
const wsTestLoading = ref(false)
|
||||
const wsTestResult = ref<WebSearchTestResult | null>(null)
|
||||
const wsTestDialogOpen = ref(false)
|
||||
|
||||
function openTestDialog() {
|
||||
wsTestResult.value = null
|
||||
wsTestDialogOpen.value = true
|
||||
}
|
||||
|
||||
function toggleProviderExpand(idx: number) {
|
||||
expandedProviders[idx] = !expandedProviders[idx]
|
||||
|
||||
Reference in New Issue
Block a user