2025-12-18 13:50:39 +08:00
< template >
< AppLayout >
2025-12-25 08:41:36 -08:00
< div class = "mx-auto max-w-4xl space-y-6" >
2025-12-18 13:50:39 +08:00
<!-- Loading State -- >
< div v-if = "loading" class="flex items-center justify-center py-12" >
2025-12-25 08:41:36 -08:00
< div class = "h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600" > < / div >
2025-12-18 13:50:39 +08:00
< / div >
<!-- Settings Form -- >
2026-03-21 15:03:18 +08:00
< form v-else @submit.prevent ="saveSettings" class = "space-y-6" novalidate >
2026-03-04 16:59:57 +08:00
<!-- Tab Navigation -- >
2026-03-14 20:22:39 +08:00
< div class = "sticky top-0 z-10 overflow-x-auto settings-tabs-scroll" >
2026-03-04 16:59:57 +08:00
< nav class = "settings-tabs" >
< button
v - for = "tab in settingsTabs"
: key = "tab.key"
type = "button"
: class = "['settings-tab', activeTab === tab.key && 'settings-tab-active']"
@ click = "activeTab = tab.key"
>
< span class = "settings-tab-icon" >
< Icon :name = "tab.icon" size = "sm" / >
< / span >
< span > { { t ( ` admin.settings.tabs. ${ tab . key } ` ) } } < / span >
< / button >
< / nav >
< / div >
<!-- Tab : Security — Admin API Key -- >
< div v-show = "activeTab === 'security'" class="space-y-6" >
2025-12-20 15:11:43 +08:00
<!-- Admin API Key Settings -- >
< div class = "card" >
2025-12-25 08:41:36 -08:00
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.adminApiKey.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.adminApiKey.description' ) } }
< / p >
2025-12-20 15:11:43 +08:00
< / div >
2025-12-25 08:41:36 -08:00
< div class = "space-y-4 p-6" >
2025-12-20 15:11:43 +08:00
<!-- Security Warning -- >
2025-12-25 08:41:36 -08:00
< div
class = "rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
2025-12-20 15:11:43 +08:00
< div class = "flex items-start" >
refactor(frontend): 完成所有组件的内联SVG统一替换为Icon组件
- 扩展 Icon.vue 组件,新增 60+ 图标路径
- 导航类: arrowRight, arrowLeft, arrowUp, arrowDown, chevronUp, externalLink
- 状态类: checkCircle, xCircle, exclamationCircle, exclamationTriangle, infoCircle
- 用户类: user, userCircle, userPlus, users
- 文档类: document, clipboard, copy, inbox
- 操作类: download, upload, filter, sort
- 安全类: key, lock, shield
- UI类: menu, calendar, home, terminal, gift, creditCard, mail
- 数据类: chartBar, trendingUp, database, cube
- 其他: bolt, sparkles, cloud, server, sun, moon, book 等
- 重构 56 个 Vue 组件,用 Icon 组件替换内联 SVG
- 净减少约 2200 行代码
- 提升代码可维护性和一致性
- 统一图标样式和尺寸管理
2026-01-05 20:22:48 +08:00
< Icon
name = "exclamationTriangle"
size = "md"
class = "mt-0.5 flex-shrink-0 text-amber-500"
/ >
2025-12-20 15:11:43 +08:00
< p class = "ml-3 text-sm text-amber-700 dark:text-amber-300" >
{ { t ( 'admin.settings.adminApiKey.securityWarning' ) } }
< / p >
< / div >
< / div >
<!-- Loading State -- >
< div v-if = "adminApiKeyLoading" class="flex items-center gap-2 text-gray-500" >
2025-12-25 08:41:36 -08:00
< div class = "h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600" > < / div >
2025-12-20 15:11:43 +08:00
{ { t ( 'common.loading' ) } }
< / div >
<!-- No Key Configured -- >
< div v-else-if = "!adminApiKeyExists" class="flex items-center justify-between" >
< span class = "text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.adminApiKey.notConfigured' ) } }
< / span >
< button
type = "button"
@ click = "createAdminApiKey"
: disabled = "adminApiKeyOperating"
class = "btn btn-primary btn-sm"
>
2025-12-25 08:41:36 -08:00
< svg
v - if = "adminApiKeyOperating"
class = "mr-1 h-4 w-4 animate-spin"
fill = "none"
viewBox = "0 0 24 24"
>
< circle
class = "opacity-25"
cx = "12"
cy = "12"
r = "10"
stroke = "currentColor"
stroke - width = "4"
> < / circle >
< path
class = "opacity-75"
fill = "currentColor"
d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
> < / path >
2025-12-20 15:11:43 +08:00
< / svg >
2025-12-25 08:41:36 -08:00
{ {
adminApiKeyOperating
? t ( 'admin.settings.adminApiKey.creating' )
: t ( 'admin.settings.adminApiKey.create' )
} }
2025-12-20 15:11:43 +08:00
< / button >
< / div >
<!-- Key Exists -- >
< div v-else class = "space-y-4" >
< div class = "flex items-center justify-between" >
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-20 15:11:43 +08:00
{ { t ( 'admin.settings.adminApiKey.currentKey' ) } }
< / label >
2025-12-25 08:41:36 -08:00
< code
class = "rounded bg-gray-100 px-2 py-1 font-mono text-sm text-gray-900 dark:bg-dark-700 dark:text-gray-100"
>
2025-12-20 15:11:43 +08:00
{ { adminApiKeyMasked } }
< / code >
< / div >
< div class = "flex gap-2" >
< button
type = "button"
@ click = "regenerateAdminApiKey"
: disabled = "adminApiKeyOperating"
class = "btn btn-secondary btn-sm"
>
2025-12-25 08:41:36 -08:00
{ {
adminApiKeyOperating
? t ( 'admin.settings.adminApiKey.regenerating' )
: t ( 'admin.settings.adminApiKey.regenerate' )
} }
2025-12-20 15:11:43 +08:00
< / button >
< button
type = "button"
@ click = "deleteAdminApiKey"
: disabled = "adminApiKeyOperating"
class = "btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
>
{ { t ( 'admin.settings.adminApiKey.delete' ) } }
< / button >
< / div >
< / div >
<!-- Newly Generated Key Display -- >
2025-12-25 08:41:36 -08:00
< div
v - if = "newAdminApiKey"
class = "space-y-3 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20"
>
2025-12-20 15:11:43 +08:00
< p class = "text-sm font-medium text-green-700 dark:text-green-300" >
{ { t ( 'admin.settings.adminApiKey.keyWarning' ) } }
< / p >
< div class = "flex items-center gap-2" >
2025-12-25 08:41:36 -08:00
< code
class = "flex-1 select-all break-all rounded border border-green-300 bg-white px-3 py-2 font-mono text-sm dark:border-green-700 dark:bg-dark-800"
>
2025-12-20 15:11:43 +08:00
{ { newAdminApiKey } }
< / code >
< button
type = "button"
@ click = "copyNewKey"
class = "btn btn-primary btn-sm flex-shrink-0"
>
{ { t ( 'admin.settings.adminApiKey.copyKey' ) } }
< / button >
< / div >
< p class = "text-xs text-green-600 dark:text-green-400" >
{ { t ( 'admin.settings.adminApiKey.usage' ) } }
< / p >
< / div >
< / div >
< / div >
< / div >
2026-03-04 16:59:57 +08:00
< / div > <!-- / Tab : Security — Admin API Key -- >
2025-12-20 15:11:43 +08:00
2026-03-18 16:22:19 +08:00
<!-- Tab : Gateway -- >
2026-03-04 16:59:57 +08:00
< div v-show = "activeTab === 'gateway'" class="space-y-6" >
2026-03-18 16:22:19 +08:00
<!-- Overload Cooldown ( 529 ) Settings -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.overloadCooldown.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.overloadCooldown.description' ) } }
< / p >
< / div >
< div class = "space-y-5 p-6" >
< div v-if = "overloadCooldownLoading" class="flex items-center gap-2 text-gray-500" >
< div class = "h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600" > < / div >
{ { t ( 'common.loading' ) } }
< / div >
< template v-else >
< div class = "flex items-center justify-between" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.overloadCooldown.enabled' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.overloadCooldown.enabledHint' ) } }
< / p >
< / div >
< Toggle v-model = "overloadCooldownForm.enabled" / >
< / div >
< div
v - if = "overloadCooldownForm.enabled"
class = "space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700"
>
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.overloadCooldown.cooldownMinutes' ) } }
< / label >
< input
v - model . number = "overloadCooldownForm.cooldown_minutes"
type = "number"
min = "1"
max = "120"
class = "input w-32"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.overloadCooldown.cooldownMinutesHint' ) } }
< / p >
< / div >
< / div >
< div class = "flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700" >
< button
type = "button"
@ click = "saveOverloadCooldownSettings"
: disabled = "overloadCooldownSaving"
class = "btn btn-primary btn-sm"
>
< svg
v - if = "overloadCooldownSaving"
class = "mr-1 h-4 w-4 animate-spin"
fill = "none"
viewBox = "0 0 24 24"
>
< circle
class = "opacity-25"
cx = "12"
cy = "12"
r = "10"
stroke = "currentColor"
stroke - width = "4"
> < / circle >
< path
class = "opacity-75"
fill = "currentColor"
d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
> < / path >
< / svg >
{ { overloadCooldownSaving ? t ( 'common.saving' ) : t ( 'common.save' ) } }
< / button >
< / div >
< / template >
< / div >
< / div >
2026-01-11 21:54:52 -08:00
<!-- Stream Timeout Settings -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.streamTimeout.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.streamTimeout.description' ) } }
< / p >
< / div >
< div class = "space-y-5 p-6" >
<!-- Loading State -- >
< div v-if = "streamTimeoutLoading" class="flex items-center gap-2 text-gray-500" >
< div class = "h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600" > < / div >
{ { t ( 'common.loading' ) } }
< / div >
< template v-else >
<!-- Enable Stream Timeout -- >
< div class = "flex items-center justify-between" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.streamTimeout.enabled' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.streamTimeout.enabledHint' ) } }
< / p >
< / div >
< Toggle v-model = "streamTimeoutForm.enabled" / >
< / div >
<!-- Settings - Only show when enabled -- >
< div
v - if = "streamTimeoutForm.enabled"
class = "space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<!-- Action -- >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.streamTimeout.action' ) } }
< / label >
< select v-model = "streamTimeoutForm.action" class="input w-64" >
< option value = "temp_unsched" > { { t ( 'admin.settings.streamTimeout.actionTempUnsched' ) } } < / option >
< option value = "error" > { { t ( 'admin.settings.streamTimeout.actionError' ) } } < / option >
< option value = "none" > { { t ( 'admin.settings.streamTimeout.actionNone' ) } } < / option >
< / select >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.streamTimeout.actionHint' ) } }
< / p >
< / div >
<!-- Temp Unsched Minutes ( only show when action is temp _unsched ) -- >
< div v-if = "streamTimeoutForm.action === 'temp_unsched'" >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.streamTimeout.tempUnschedMinutes' ) } }
< / label >
< input
v - model . number = "streamTimeoutForm.temp_unsched_minutes"
type = "number"
min = "1"
max = "60"
class = "input w-32"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.streamTimeout.tempUnschedMinutesHint' ) } }
< / p >
< / div >
<!-- Threshold Count -- >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.streamTimeout.thresholdCount' ) } }
< / label >
< input
v - model . number = "streamTimeoutForm.threshold_count"
type = "number"
min = "1"
max = "10"
class = "input w-32"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.streamTimeout.thresholdCountHint' ) } }
< / p >
< / div >
<!-- Threshold Window Minutes -- >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.streamTimeout.thresholdWindowMinutes' ) } }
< / label >
< input
v - model . number = "streamTimeoutForm.threshold_window_minutes"
type = "number"
min = "1"
max = "60"
class = "input w-32"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.streamTimeout.thresholdWindowMinutesHint' ) } }
< / p >
< / div >
< / div >
<!-- Save Button -- >
< div class = "flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700" >
< button
type = "button"
@ click = "saveStreamTimeoutSettings"
: disabled = "streamTimeoutSaving"
class = "btn btn-primary btn-sm"
>
< svg
v - if = "streamTimeoutSaving"
class = "mr-1 h-4 w-4 animate-spin"
fill = "none"
viewBox = "0 0 24 24"
>
< circle
class = "opacity-25"
cx = "12"
cy = "12"
r = "10"
stroke = "currentColor"
stroke - width = "4"
> < / circle >
< path
class = "opacity-75"
fill = "currentColor"
d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
> < / path >
< / svg >
{ { streamTimeoutSaving ? t ( 'common.saving' ) : t ( 'common.save' ) } }
< / button >
< / div >
< / template >
< / div >
< / div >
2026-03-07 21:45:18 +08:00
<!-- Request Rectifier Settings -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.rectifier.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.rectifier.description' ) } }
< / p >
< / div >
< div class = "space-y-5 p-6" >
<!-- Loading State -- >
< div v-if = "rectifierLoading" class="flex items-center gap-2 text-gray-500" >
< div class = "h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600" > < / div >
{ { t ( 'common.loading' ) } }
< / div >
< template v-else >
<!-- Master Toggle -- >
< div class = "flex items-center justify-between" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.rectifier.enabled' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.rectifier.enabledHint' ) } }
< / p >
< / div >
< Toggle v-model = "rectifierForm.enabled" / >
< / div >
<!-- Sub - toggles ( only show when master is enabled ) -- >
< div
v - if = "rectifierForm.enabled"
class = "space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<!-- Thinking Signature Rectifier -- >
< div class = "flex items-center justify-between" >
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" > { {
t ( 'admin.settings.rectifier.thinkingSignature' )
} } < / label >
< p class = "text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.rectifier.thinkingSignatureHint' ) } }
< / p >
< / div >
< Toggle v-model = "rectifierForm.thinking_signature_enabled" / >
< / div >
<!-- Thinking Budget Rectifier -- >
< div class = "flex items-center justify-between" >
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" > { {
t ( 'admin.settings.rectifier.thinkingBudget' )
} } < / label >
< p class = "text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.rectifier.thinkingBudgetHint' ) } }
< / p >
< / div >
< Toggle v-model = "rectifierForm.thinking_budget_enabled" / >
< / div >
2026-03-26 16:43:38 +08:00
<!-- API Key Signature Rectifier -- >
< div class = "flex items-center justify-between" >
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" > { {
t ( 'admin.settings.rectifier.apikeySignature' )
} } < / label >
< p class = "text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.rectifier.apikeySignatureHint' ) } }
< / p >
< / div >
< Toggle v-model = "rectifierForm.apikey_signature_enabled" / >
< / div >
<!-- Custom Patterns ( only when apikey _signature _enabled ) -- >
< div
v - if = "rectifierForm.apikey_signature_enabled"
class = "ml-4 space-y-3 border-l-2 border-gray-200 pl-4 dark:border-dark-600"
>
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" > { {
t ( 'admin.settings.rectifier.apikeyPatterns' )
} } < / label >
< p class = "text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.rectifier.apikeyPatternsHint' ) } }
< / p >
< / div >
< div
v - for = "(_, index) in rectifierForm.apikey_signature_patterns"
: key = "index"
class = "flex items-center gap-2"
>
< input
v - model = "rectifierForm.apikey_signature_patterns[index]"
type = "text"
class = "input input-sm flex-1"
: placeholder = "t('admin.settings.rectifier.apikeyPatternPlaceholder')"
/ >
< button
type = "button"
@ click = "rectifierForm.apikey_signature_patterns.splice(index, 1)"
class = "btn btn-ghost btn-xs text-red-500 hover:text-red-700"
>
< svg
class = "h-4 w-4"
fill = "none"
stroke = "currentColor"
viewBox = "0 0 24 24"
>
< path
stroke - linecap = "round"
stroke - linejoin = "round"
stroke - width = "2"
d = "M6 18L18 6M6 6l12 12"
/ >
< / svg >
< / button >
< / div >
< button
type = "button"
@ click = "rectifierForm.apikey_signature_patterns.push('')"
class = "btn btn-ghost btn-xs text-primary-600 dark:text-primary-400"
>
+ { { t ( 'admin.settings.rectifier.addPattern' ) } }
< / button >
< / div >
2026-03-07 21:45:18 +08:00
< / div >
<!-- Save Button -- >
< div class = "flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700" >
< button
type = "button"
@ click = "saveRectifierSettings"
: disabled = "rectifierSaving"
class = "btn btn-primary btn-sm"
>
< svg
v - if = "rectifierSaving"
class = "mr-1 h-4 w-4 animate-spin"
fill = "none"
viewBox = "0 0 24 24"
>
< circle
class = "opacity-25"
cx = "12"
cy = "12"
r = "10"
stroke = "currentColor"
stroke - width = "4"
> < / circle >
< path
class = "opacity-75"
fill = "currentColor"
d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
> < / path >
< / svg >
{ { rectifierSaving ? t ( 'common.saving' ) : t ( 'common.save' ) } }
< / button >
< / div >
< / template >
< / div >
< / div >
2026-03-10 11:14:17 +08:00
<!-- Beta Policy Settings -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.betaPolicy.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.betaPolicy.description' ) } }
< / p >
< / div >
< div class = "space-y-5 p-6" >
<!-- Loading State -- >
< div v-if = "betaPolicyLoading" class="flex items-center gap-2 text-gray-500" >
< div class = "h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600" > < / div >
{ { t ( 'common.loading' ) } }
< / div >
< template v-else >
<!-- Rule Cards -- >
< div
v - for = "rule in betaPolicyForm.rules"
: key = "rule.beta_token"
class = "rounded-lg border border-gray-200 p-4 dark:border-dark-600"
>
< div class = "mb-3 flex items-center gap-2" >
< span class = "text-sm font-medium text-gray-900 dark:text-white" >
{ { getBetaDisplayName ( rule . beta _token ) } }
< / span >
< span class = "rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-dark-700 dark:text-gray-400" >
{ { rule . beta _token } }
< / span >
< / div >
< div class = "grid grid-cols-2 gap-4" >
<!-- Action -- >
< div >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.betaPolicy.action' ) } }
< / label >
< Select
: modelValue = "rule.action"
@ update : modelValue = "rule.action = $event as any"
: options = "betaPolicyActionOptions"
/ >
< / div >
<!-- Scope -- >
< div >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.betaPolicy.scope' ) } }
< / label >
< Select
: modelValue = "rule.scope"
@ update : modelValue = "rule.scope = $event as any"
: options = "betaPolicyScopeOptions"
/ >
< / div >
< / div >
<!-- Error Message ( only when action = block ) -- >
< div v-if = "rule.action === 'block'" class="mt-3" >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.betaPolicy.errorMessage' ) } }
< / label >
< input
v - model = "rule.error_message"
type = "text"
class = "input"
: placeholder = "t('admin.settings.betaPolicy.errorMessagePlaceholder')"
/ >
< p class = "mt-1 text-xs text-gray-400 dark:text-gray-500" >
{ { t ( 'admin.settings.betaPolicy.errorMessageHint' ) } }
< / p >
< / div >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
<!-- Quick Presets ( only for tokens with presets ) -- >
< div v-if = "betaPresets[rule.beta_token]?.length" class="mt-3" >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.betaPolicy.quickPresets' ) } }
< / label >
< div class = "flex flex-wrap gap-2" >
< button
v - for = "preset in betaPresets[rule.beta_token]"
: key = "preset.label"
type = "button"
class = "inline-flex items-center gap-1 rounded-md border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 transition-colors hover:bg-primary-100 dark:border-primary-800 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/50"
@ click = "applyBetaPreset(rule, preset)"
: title = "preset.description"
>
{ { preset . label } }
< / button >
< / div >
< / div >
<!-- Model Whitelist -- >
< div class = "mt-3" >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.betaPolicy.modelWhitelist' ) } }
< / label >
< p class = "mb-2 text-xs text-gray-400 dark:text-gray-500" >
{ { t ( 'admin.settings.betaPolicy.modelWhitelistHint' ) } }
< / p >
<!-- Existing patterns -- >
< div
v - for = "(_, index) in (rule.model_whitelist || [])"
: key = "index"
class = "mb-1.5 flex items-center gap-2"
>
< input
v - model = "rule.model_whitelist![index]"
type = "text"
class = "input input-sm flex-1"
: placeholder = "t('admin.settings.betaPolicy.modelPatternPlaceholder')"
/ >
< button
type = "button"
@ click = "rule.model_whitelist!.splice(index, 1)"
class = "shrink-0 rounded p-1 text-red-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
< svg class = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" stroke -width = " 2 " >
< path stroke -linecap = " round " stroke -linejoin = " round " d = "M6 18L18 6M6 6l12 12" / >
< / svg >
< / button >
< / div >
<!-- Add pattern button -- >
< button
type = "button"
@ click = "if (!rule.model_whitelist) rule.model_whitelist = []; rule.model_whitelist.push('')"
class = "mb-2 inline-flex items-center gap-1 text-xs text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
< svg class = "h-3.5 w-3.5" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" stroke -width = " 2 " >
< path stroke -linecap = " round " stroke -linejoin = " round " d = "M12 4v16m8-8H4" / >
< / svg >
{ { t ( 'admin.settings.betaPolicy.addModelPattern' ) } }
< / button >
<!-- Common pattern chips -- >
< div class = "flex flex-wrap items-center gap-1.5" >
< span class = "text-xs text-gray-400 dark:text-gray-500" > { { t ( 'admin.settings.betaPolicy.commonPatterns' ) } } : < / span >
< button
v - for = "pattern in commonModelPatterns"
: key = "pattern"
type = "button"
class = "rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-600 transition-colors hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 dark:border-dark-600 dark:text-gray-400 dark:hover:border-primary-700 dark:hover:bg-primary-900/30 dark:hover:text-primary-300"
@ click = "addQuickPattern(rule, pattern)"
>
{ { pattern } }
< / button >
< / div >
< / div >
<!-- Fallback Action ( only when model _whitelist is non - empty ) -- >
< div v-if = "rule.model_whitelist && rule.model_whitelist.length > 0" class="mt-3" >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.betaPolicy.fallbackAction' ) } }
< / label >
< Select
: modelValue = "rule.fallback_action || 'pass'"
@ update : modelValue = "rule.fallback_action = $event as any"
: options = "betaPolicyActionOptions"
/ >
< p class = "mt-1 text-xs text-gray-400 dark:text-gray-500" >
{ { t ( 'admin.settings.betaPolicy.fallbackActionHint' ) } }
< / p >
<!-- Fallback Error Message ( only when fallback _action = block ) -- >
< div v-if = "rule.fallback_action === 'block'" class="mt-2" >
< input
v - model = "rule.fallback_error_message"
type = "text"
class = "input"
: placeholder = "t('admin.settings.betaPolicy.fallbackErrorMessagePlaceholder')"
/ >
< p class = "mt-1 text-xs text-gray-400 dark:text-gray-500" >
{ { t ( 'admin.settings.betaPolicy.errorMessageHint' ) } }
< / p >
< / div >
< / div >
2026-03-10 11:14:17 +08:00
< / div >
<!-- Save Button -- >
< div class = "flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700" >
< button
type = "button"
@ click = "saveBetaPolicySettings"
: disabled = "betaPolicySaving"
class = "btn btn-primary btn-sm"
>
< svg
v - if = "betaPolicySaving"
class = "mr-1 h-4 w-4 animate-spin"
fill = "none"
viewBox = "0 0 24 24"
>
< circle
class = "opacity-25"
cx = "12"
cy = "12"
r = "10"
stroke = "currentColor"
stroke - width = "4"
> < / circle >
< path
class = "opacity-75"
fill = "currentColor"
d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
> < / path >
< / svg >
{ { betaPolicySaving ? t ( 'common.saving' ) : t ( 'common.save' ) } }
< / button >
< / div >
< / template >
< / div >
< / div >
2026-03-07 21:45:18 +08:00
< / div > <!-- / Tab : Gateway -- >
2026-01-11 21:54:52 -08:00
2026-03-04 16:59:57 +08:00
<!-- Tab : Security — Registration , Turnstile , LinuxDo -- >
< div v-show = "activeTab === 'security'" class="space-y-6" >
2025-12-18 13:50:39 +08:00
<!-- Registration Settings -- >
< div class = "card" >
2025-12-25 08:41:36 -08:00
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.registration.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.registration.description' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
2025-12-25 08:41:36 -08:00
< div class = "space-y-5 p-6" >
2025-12-18 13:50:39 +08:00
<!-- Enable Registration -- >
< div class = "flex items-center justify-between" >
< div >
2025-12-25 08:41:36 -08:00
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.registration.enableRegistration' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.registration.enableRegistrationHint' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
< Toggle v-model = "form.registration_enabled" / >
< / div >
<!-- Email Verification -- >
2025-12-25 08:41:36 -08:00
< div
class = "flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
2025-12-18 13:50:39 +08:00
< div >
2025-12-25 08:41:36 -08:00
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.registration.emailVerification' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.registration.emailVerificationHint' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
< Toggle v-model = "form.email_verify_enabled" / >
< / div >
2026-02-02 22:13:50 +08:00
2026-03-02 23:13:39 +08:00
<!-- Email Suffix Whitelist -- >
< div class = "border-t border-gray-100 pt-4 dark:border-dark-700" >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.registration.emailSuffixWhitelist' )
} } < / label >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.registration.emailSuffixWhitelistHint' ) } }
< / p >
< div
class = "mt-3 rounded-lg border border-gray-300 bg-white p-2 dark:border-dark-500 dark:bg-dark-700"
>
< div class = "flex flex-wrap items-center gap-2" >
< span
v - for = "suffix in registrationEmailSuffixWhitelistTags"
: key = "suffix"
class = "inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-700 dark:bg-dark-600 dark:text-gray-200"
>
< span class = "text-gray-400 dark:text-gray-500" > @ < / span >
< span > { { suffix } } < / span >
< button
type = "button"
class = "rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-dark-500 dark:hover:text-white"
@ click = "removeRegistrationEmailSuffixWhitelistTag(suffix)"
>
< Icon name = "x" size = "xs" class = "h-3.5 w-3.5" :stroke-width = "2" / >
< / button >
< / span >
< div
class = "flex min-w-[220px] flex-1 items-center gap-1 rounded border border-transparent px-2 py-1 focus-within:border-primary-300 dark:focus-within:border-primary-700"
>
< span class = "font-mono text-sm text-gray-400 dark:text-gray-500" > @ < / span >
< input
v - model = "registrationEmailSuffixWhitelistDraft"
type = "text"
class = "w-full bg-transparent text-sm font-mono text-gray-900 outline-none placeholder:text-gray-400 dark:text-white dark:placeholder:text-gray-500"
: placeholder = "t('admin.settings.registration.emailSuffixWhitelistPlaceholder')"
@ input = "handleRegistrationEmailSuffixWhitelistDraftInput"
@ keydown = "handleRegistrationEmailSuffixWhitelistDraftKeydown"
@ blur = "commitRegistrationEmailSuffixWhitelistDraft"
@ paste = "handleRegistrationEmailSuffixWhitelistPaste"
/ >
< / div >
< / div >
< / div >
< p class = "mt-2 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.registration.emailSuffixWhitelistInputHint' ) } }
< / p >
< / div >
2026-02-02 22:13:50 +08:00
<!-- Promo Code -- >
< div
class = "flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.registration.promoCode' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.registration.promoCodeHint' ) } }
< / p >
< / div >
< Toggle v-model = "form.promo_code_enabled" / >
< / div >
2026-01-29 16:29:59 +08:00
<!-- Invitation Code -- >
< div
class = "flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.registration.invitationCode' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.registration.invitationCodeHint' ) } }
< / p >
< / div >
< Toggle v-model = "form.invitation_code_enabled" / >
< / div >
2026-02-02 22:13:50 +08:00
<!-- Password Reset - Only show when email verification is enabled -- >
< div
v - if = "form.email_verify_enabled"
class = "flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.registration.passwordReset' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.registration.passwordResetHint' ) } }
< / p >
< / div >
< Toggle v-model = "form.password_reset_enabled" / >
< / div >
2026-03-15 17:52:29 +08:00
<!-- Frontend URL - Only show when password reset is enabled -- >
< div
v - if = "form.email_verify_enabled && form.password_reset_enabled"
class = "border-t border-gray-100 pt-4 dark:border-dark-700"
>
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.registration.frontendUrl' ) } }
< / label >
< input
v - model = "form.frontend_url"
type = "url"
class = "input"
: placeholder = "t('admin.settings.registration.frontendUrlPlaceholder')"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.registration.frontendUrlHint' ) } }
< / p >
< / div >
2026-02-02 22:13:50 +08:00
<!-- TOTP 2 FA -- >
< div
class = "flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.registration.totp' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.registration.totpHint' ) } }
< / p >
<!-- Warning when encryption key not configured -- >
< p
v - if = "!form.totp_encryption_key_configured"
class = "mt-2 text-sm text-amber-600 dark:text-amber-400"
>
{ { t ( 'admin.settings.registration.totpKeyNotConfigured' ) } }
< / p >
< / div >
< Toggle
v - model = "form.totp_enabled"
: disabled = "!form.totp_encryption_key_configured"
/ >
< / div >
2025-12-18 13:50:39 +08:00
< / div >
< / div >
<!-- Cloudflare Turnstile Settings -- >
< div class = "card" >
2025-12-25 08:41:36 -08:00
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.turnstile.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.turnstile.description' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
2025-12-25 08:41:36 -08:00
< div class = "space-y-5 p-6" >
2025-12-18 13:50:39 +08:00
<!-- Enable Turnstile -- >
< div class = "flex items-center justify-between" >
< div >
2025-12-25 08:41:36 -08:00
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.turnstile.enableTurnstile' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.turnstile.enableTurnstileHint' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
< Toggle v-model = "form.turnstile_enabled" / >
< / div >
<!-- Turnstile Keys - Only show when enabled -- >
2025-12-25 08:41:36 -08:00
< div
v - if = "form.turnstile_enabled"
class = "border-t border-gray-100 pt-4 dark:border-dark-700"
>
2025-12-18 13:50:39 +08:00
< div class = "grid grid-cols-1 gap-6" >
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-18 13:50:39 +08:00
{ { t ( 'admin.settings.turnstile.siteKey' ) } }
< / label >
< input
v - model = "form.turnstile_site_key"
type = "text"
class = "input font-mono text-sm"
placeholder = "0x4AAAAAAA..."
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.turnstile.siteKeyHint' ) } }
2025-12-25 08:41:36 -08:00
< a
href = "https://dash.cloudflare.com/"
target = "_blank"
class = "text-primary-600 hover:text-primary-500"
2026-01-03 06:35:50 -08:00
> { { t ( 'admin.settings.turnstile.cloudflareDashboard' ) } } < / a
2025-12-25 08:41:36 -08:00
>
2025-12-18 13:50:39 +08:00
< / p >
< / div >
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-18 13:50:39 +08:00
{ { t ( 'admin.settings.turnstile.secretKey' ) } }
< / label >
< input
v - model = "form.turnstile_secret_key"
type = "password"
class = "input font-mono text-sm"
placeholder = "0x4AAAAAAA..."
/ >
2025-12-25 08:41:36 -08:00
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
2026-01-02 17:40:57 +08:00
{ {
form . turnstile _secret _key _configured
? t ( 'admin.settings.turnstile.secretKeyConfiguredHint' )
: t ( 'admin.settings.turnstile.secretKeyHint' )
} }
2025-12-25 08:41:36 -08:00
< / p >
2025-12-18 13:50:39 +08:00
< / div >
< / div >
< / div >
< / div >
< / div >
2026-01-12 09:14:32 +08:00
<!-- LinuxDo Connect OAuth 登录 -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.linuxdo.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.linuxdo.description' ) } }
< / p >
< / div >
< div class = "space-y-5 p-6" >
< div class = "flex items-center justify-between" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.linuxdo.enable' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.linuxdo.enableHint' ) } }
< / p >
< / div >
< Toggle v-model = "form.linuxdo_connect_enabled" / >
< / div >
< div
v - if = "form.linuxdo_connect_enabled"
class = "border-t border-gray-100 pt-4 dark:border-dark-700"
>
< div class = "grid grid-cols-1 gap-6" >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.linuxdo.clientId' ) } }
< / label >
< input
v - model = "form.linuxdo_connect_client_id"
type = "text"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.linuxdo.clientIdPlaceholder')"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.linuxdo.clientIdHint' ) } }
< / p >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.linuxdo.clientSecret' ) } }
< / label >
< input
v - model = "form.linuxdo_connect_client_secret"
type = "password"
class = "input font-mono text-sm"
: placeholder = "
form . linuxdo _connect _client _secret _configured
? t ( 'admin.settings.linuxdo.clientSecretConfiguredPlaceholder' )
: t ( 'admin.settings.linuxdo.clientSecretPlaceholder' )
"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ {
form . linuxdo _connect _client _secret _configured
? t ( 'admin.settings.linuxdo.clientSecretConfiguredHint' )
: t ( 'admin.settings.linuxdo.clientSecretHint' )
} }
< / p >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.linuxdo.redirectUrl' ) } }
< / label >
< input
v - model = "form.linuxdo_connect_redirect_url"
type = "url"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.linuxdo.redirectUrlPlaceholder')"
/ >
< div class = "mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3" >
< button
type = "button"
class = "btn btn-secondary btn-sm w-fit"
@ click = "setAndCopyLinuxdoRedirectUrl"
>
{ { t ( 'admin.settings.linuxdo.quickSetCopy' ) } }
< / button >
< code
v - if = "linuxdoRedirectUrlSuggestion"
class = "select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
>
{ { linuxdoRedirectUrlSuggestion } }
< / code >
< / div >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.linuxdo.redirectUrlHint' ) } }
< / p >
< / div >
< / div >
< / div >
< / div >
< / div >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
<!-- Generic OIDC OAuth 登录 -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.oidc.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.oidc.description' ) } }
< / p >
< / div >
< div class = "space-y-5 p-6" >
< div class = "flex items-center justify-between" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.oidc.enable' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.oidc.enableHint' ) } }
< / p >
< / div >
< Toggle v-model = "form.oidc_connect_enabled" / >
< / div >
< div
v - if = "form.oidc_connect_enabled"
class = "space-y-6 border-t border-gray-100 pt-4 dark:border-dark-700"
>
< div class = "grid grid-cols-1 gap-6 lg:grid-cols-3" >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.providerName' ) } }
< / label >
< input
v - model = "form.oidc_connect_provider_name"
type = "text"
class = "input"
: placeholder = "t('admin.settings.oidc.providerNamePlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.clientId' ) } }
< / label >
< input
v - model = "form.oidc_connect_client_id"
type = "text"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.clientIdPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.clientSecret' ) } }
< / label >
< input
v - model = "form.oidc_connect_client_secret"
type = "password"
class = "input font-mono text-sm"
: placeholder = "
form . oidc _connect _client _secret _configured
? t ( 'admin.settings.oidc.clientSecretConfiguredPlaceholder' )
: t ( 'admin.settings.oidc.clientSecretPlaceholder' )
"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ {
form . oidc _connect _client _secret _configured
? t ( 'admin.settings.oidc.clientSecretConfiguredHint' )
: t ( 'admin.settings.oidc.clientSecretHint' )
} }
< / p >
< / div >
< / div >
< div class = "grid grid-cols-1 gap-6 lg:grid-cols-2" >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.issuerUrl' ) } }
< / label >
< input
v - model = "form.oidc_connect_issuer_url"
type = "url"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.issuerUrlPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.discoveryUrl' ) } }
< / label >
< input
v - model = "form.oidc_connect_discovery_url"
type = "url"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.discoveryUrlPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.authorizeUrl' ) } }
< / label >
< input
v - model = "form.oidc_connect_authorize_url"
type = "url"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.authorizeUrlPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.tokenUrl' ) } }
< / label >
< input
v - model = "form.oidc_connect_token_url"
type = "url"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.tokenUrlPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.userinfoUrl' ) } }
< / label >
< input
v - model = "form.oidc_connect_userinfo_url"
type = "url"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.userinfoUrlPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.jwksUrl' ) } }
< / label >
< input
v - model = "form.oidc_connect_jwks_url"
type = "url"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.jwksUrlPlaceholder')"
/ >
< / div >
< / div >
< div class = "grid grid-cols-1 gap-6 lg:grid-cols-2" >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.scopes' ) } }
< / label >
< input
v - model = "form.oidc_connect_scopes"
type = "text"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.scopesPlaceholder')"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.oidc.scopesHint' ) } }
< / p >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.redirectUrl' ) } }
< / label >
< input
v - model = "form.oidc_connect_redirect_url"
type = "url"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.redirectUrlPlaceholder')"
/ >
< div class = "mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3" >
< button
type = "button"
class = "btn btn-secondary btn-sm w-fit"
@ click = "setAndCopyOIDCRedirectUrl"
>
{ { t ( 'admin.settings.oidc.quickSetCopy' ) } }
< / button >
< code
v - if = "oidcRedirectUrlSuggestion"
class = "select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
>
{ { oidcRedirectUrlSuggestion } }
< / code >
< / div >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.oidc.redirectUrlHint' ) } }
< / p >
< / div >
< div class = "lg:col-span-2" >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.frontendRedirectUrl' ) } }
< / label >
< input
v - model = "form.oidc_connect_frontend_redirect_url"
type = "text"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.frontendRedirectUrlPlaceholder')"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.oidc.frontendRedirectUrlHint' ) } }
< / p >
< / div >
< / div >
< div class = "grid grid-cols-1 gap-6 lg:grid-cols-3" >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.tokenAuthMethod' ) } }
< / label >
< select v-model = "form.oidc_connect_token_auth_method" class="input font-mono text-sm" >
< option value = "client_secret_post" > client _secret _post < / option >
< option value = "client_secret_basic" > client _secret _basic < / option >
< option value = "none" > none < / option >
< / select >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.clockSkewSeconds' ) } }
< / label >
< input
v - model . number = "form.oidc_connect_clock_skew_seconds"
type = "number"
min = "0"
max = "600"
class = "input"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.allowedSigningAlgs' ) } }
< / label >
< input
v - model = "form.oidc_connect_allowed_signing_algs"
type = "text"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.allowedSigningAlgsPlaceholder')"
/ >
< / div >
< / div >
< div class = "grid grid-cols-1 gap-6 lg:grid-cols-3" >
< div class = "flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.oidc.usePkce' ) } }
< / label >
< / div >
2026-04-20 16:23:42 +08:00
< Toggle v-model = "form.oidc_connect_use_pkce" :disabled="true" / >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
< / div >
< div class = "flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.oidc.validateIdToken' ) } }
< / label >
< / div >
2026-04-20 16:23:42 +08:00
< Toggle v-model = "form.oidc_connect_validate_id_token" :disabled="true" / >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
< / div >
< div class = "flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.oidc.requireEmailVerified' ) } }
< / label >
< / div >
< Toggle v-model = "form.oidc_connect_require_email_verified" / >
< / div >
< / div >
< div class = "grid grid-cols-1 gap-6 lg:grid-cols-3" >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.userinfoEmailPath' ) } }
< / label >
< input
v - model = "form.oidc_connect_userinfo_email_path"
type = "text"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.userinfoEmailPathPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.userinfoIdPath' ) } }
< / label >
< input
v - model = "form.oidc_connect_userinfo_id_path"
type = "text"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.userinfoIdPathPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.oidc.userinfoUsernamePath' ) } }
< / label >
< input
v - model = "form.oidc_connect_userinfo_username_path"
type = "text"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.oidc.userinfoUsernamePathPlaceholder')"
/ >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div > <!-- / Tab : Security — Registration , Turnstile , LinuxDo , OIDC -- >
2026-01-12 09:14:32 +08:00
2026-03-04 16:59:57 +08:00
<!-- Tab : Users -- >
< div v-show = "activeTab === 'users'" class="space-y-6" >
2025-12-18 13:50:39 +08:00
<!-- Default Settings -- >
< div class = "card" >
2025-12-25 08:41:36 -08:00
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.defaults.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.defaults.description' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
2026-03-02 03:41:50 +08:00
< div class = "space-y-6 p-6" >
2025-12-25 08:41:36 -08:00
< div class = "grid grid-cols-1 gap-6 md:grid-cols-2" >
2025-12-18 13:50:39 +08:00
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-18 13:50:39 +08:00
{ { t ( 'admin.settings.defaults.defaultBalance' ) } }
< / label >
< input
v - model . number = "form.default_balance"
type = "number"
step = "0.01"
min = "0"
class = "input"
placeholder = "0.00"
/ >
2025-12-25 08:41:36 -08:00
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.defaults.defaultBalanceHint' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-18 13:50:39 +08:00
{ { t ( 'admin.settings.defaults.defaultConcurrency' ) } }
< / label >
< input
v - model . number = "form.default_concurrency"
type = "number"
min = "1"
class = "input"
placeholder = "1"
/ >
2025-12-25 08:41:36 -08:00
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.defaults.defaultConcurrencyHint' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
< / div >
2026-03-02 03:41:50 +08:00
< div class = "border-t border-gray-100 pt-4 dark:border-dark-700" >
< div class = "mb-3 flex items-center justify-between" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.defaults.defaultSubscriptions' ) } }
< / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.defaults.defaultSubscriptionsHint' ) } }
< / p >
< / div >
< button
type = "button"
class = "btn btn-secondary btn-sm"
@ click = "addDefaultSubscription"
: disabled = "subscriptionGroups.length === 0"
>
{ { t ( 'admin.settings.defaults.addDefaultSubscription' ) } }
< / button >
< / div >
< div
v - if = "form.default_subscriptions.length === 0"
class = "rounded border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400"
>
{ { t ( 'admin.settings.defaults.defaultSubscriptionsEmpty' ) } }
< / div >
< div v-else class = "space-y-3" >
< div
v - for = "(item, index) in form.default_subscriptions"
: key = "`default-sub-${index}`"
class = "grid grid-cols-1 gap-3 rounded border border-gray-200 p-3 md:grid-cols-[1fr_160px_auto] dark:border-dark-600"
>
< div >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.defaults.subscriptionGroup' ) } }
< / label >
< Select
v - model = "item.group_id"
class = "default-sub-group-select"
: options = "defaultSubscriptionGroupOptions"
: placeholder = "t('admin.settings.defaults.subscriptionGroup')"
>
< template # selected = "{ option }" >
< GroupBadge
v - if = "option"
: name = "(option as unknown as DefaultSubscriptionGroupOption).label"
: platform = "(option as unknown as DefaultSubscriptionGroupOption).platform"
: subscription - type = "(option as unknown as DefaultSubscriptionGroupOption).subscriptionType"
: rate - multiplier = "(option as unknown as DefaultSubscriptionGroupOption).rate"
/ >
< span v-else class = "text-gray-400" >
{ { t ( 'admin.settings.defaults.subscriptionGroup' ) } }
< / span >
< / template >
< template # option = "{ option, selected }" >
< GroupOptionItem
: name = "(option as unknown as DefaultSubscriptionGroupOption).label"
: platform = "(option as unknown as DefaultSubscriptionGroupOption).platform"
: subscription - type = "(option as unknown as DefaultSubscriptionGroupOption).subscriptionType"
: rate - multiplier = "(option as unknown as DefaultSubscriptionGroupOption).rate"
: description = "(option as unknown as DefaultSubscriptionGroupOption).description"
: selected = "selected"
/ >
< / template >
< / Select >
< / div >
< div >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.defaults.subscriptionValidityDays' ) } }
< / label >
< input
v - model . number = "item.validity_days"
type = "number"
min = "1"
max = "36500"
class = "input h-[42px]"
/ >
< / div >
< div class = "flex items-end" >
< button
type = "button"
class = "btn btn-secondary default-sub-delete-btn w-full text-red-600 hover:text-red-700 dark:text-red-400"
@ click = "removeDefaultSubscription(index)"
>
{ { t ( 'common.delete' ) } }
< / button >
< / div >
< / div >
< / div >
< / div >
2025-12-18 13:50:39 +08:00
< / div >
< / div >
2026-04-20 17:39:57 +08:00
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { localText ( '认证来源默认值' , 'Auth Source Defaults' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ {
localText (
'按注册来源配置新用户默认余额、并发、订阅与授权策略。' ,
'Configure per-source default balance, concurrency, subscriptions, and grant rules.'
)
} }
< / p >
< / div >
< div class = "space-y-6 p-6" >
< div class = "flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" >
{ { localText ( '第三方注册强制补充邮箱' , 'Require email on third-party signup' ) } }
< / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ {
localText (
'启用后, Linux DO、OIDC、微信注册缺少邮箱时必须先补充邮箱地址。' ,
'When enabled, Linux DO, OIDC, and WeChat signups must provide an email before account creation.'
)
} }
< / p >
< / div >
< Toggle v-model = "form.force_email_on_third_party_signup" / >
< / div >
< div class = "grid grid-cols-1 gap-4 xl:grid-cols-2" >
< div
v - for = "authSource in authSourceDefaultsMeta"
: key = "authSource.source"
class = "rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
< div class = "mb-4" >
< div class = "font-medium text-gray-900 dark:text-white" > { { authSource . title } } < / div >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { authSource . description } }
< / p >
< / div >
< div class = "grid grid-cols-1 gap-4 md:grid-cols-2" >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.defaults.defaultBalance' ) } }
< / label >
< input
v - model . number = "authSourceDefaults[authSource.source].balance"
type = "number"
step = "0.01"
min = "0"
class = "input"
placeholder = "0.00"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.defaults.defaultConcurrency' ) } }
< / label >
< input
v - model . number = "authSourceDefaults[authSource.source].concurrency"
type = "number"
min = "1"
class = "input"
placeholder = "5"
/ >
< / div >
< / div >
< div class = "mt-4 grid grid-cols-1 gap-3 md:grid-cols-2" >
< div class = "flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" >
{ { localText ( '注册即授权' , 'Grant on signup' ) } }
< / label >
< p class = "mt-0.5 text-xs text-gray-500 dark:text-gray-400" >
{ {
localText (
'来源首次注册成功后立即发放默认权益。' ,
'Grant default entitlements immediately after signup.'
)
} }
< / p >
< / div >
< Toggle v-model = "authSourceDefaults[authSource.source].grant_on_signup" / >
< / div >
< div class = "flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" >
{ { localText ( '首次绑定时授权' , 'Grant on first bind' ) } }
< / label >
< p class = "mt-0.5 text-xs text-gray-500 dark:text-gray-400" >
{ {
localText (
'来源首次绑定到现有账号时发放默认权益。' ,
'Grant default entitlements when the source is first bound to an existing user.'
)
} }
< / p >
< / div >
< Toggle v-model = "authSourceDefaults[authSource.source].grant_on_first_bind" / >
< / div >
< / div >
< div class = "mt-4 border-t border-gray-100 pt-4 dark:border-dark-700" >
< div class = "mb-3 flex items-center justify-between" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" >
{ { localText ( '默认订阅' , 'Default subscriptions' ) } }
< / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ {
localText (
'仅对当前认证来源生效,未配置时不追加来源专属订阅。' ,
'Applies only to this auth source. Leave empty to skip source-specific subscriptions.'
)
} }
< / p >
< / div >
< button
type = "button"
class = "btn btn-secondary btn-sm"
@ click = "addAuthSourceDefaultSubscription(authSource.source)"
: disabled = "subscriptionGroups.length === 0"
>
{ { t ( 'admin.settings.defaults.addDefaultSubscription' ) } }
< / button >
< / div >
< div
v - if = "authSourceDefaults[authSource.source].subscriptions.length === 0"
class = "rounded border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400"
>
{ {
localText (
'当前来源未配置专属默认订阅。' ,
'No source-specific default subscriptions configured.'
)
} }
< / div >
< div v-else class = "space-y-3" >
< div
v - for = "(item, index) in authSourceDefaults[authSource.source].subscriptions"
: key = "`${authSource.source}-sub-${index}`"
class = "grid grid-cols-1 gap-3 rounded border border-gray-200 p-3 md:grid-cols-[1fr_160px_auto] dark:border-dark-600"
>
< div >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.defaults.subscriptionGroup' ) } }
< / label >
< Select
v - model = "item.group_id"
class = "default-sub-group-select"
: options = "defaultSubscriptionGroupOptions"
: placeholder = "t('admin.settings.defaults.subscriptionGroup')"
>
< template # selected = "{ option }" >
< GroupBadge
v - if = "option"
: name = "(option as unknown as DefaultSubscriptionGroupOption).label"
: platform = "(option as unknown as DefaultSubscriptionGroupOption).platform"
: subscription - type = "(option as unknown as DefaultSubscriptionGroupOption).subscriptionType"
: rate - multiplier = "(option as unknown as DefaultSubscriptionGroupOption).rate"
/ >
< span v-else class = "text-gray-400" >
{ { t ( 'admin.settings.defaults.subscriptionGroup' ) } }
< / span >
< / template >
< template # option = "{ option, selected }" >
< GroupOptionItem
: name = "(option as unknown as DefaultSubscriptionGroupOption).label"
: platform = "(option as unknown as DefaultSubscriptionGroupOption).platform"
: subscription - type = "(option as unknown as DefaultSubscriptionGroupOption).subscriptionType"
: rate - multiplier = "(option as unknown as DefaultSubscriptionGroupOption).rate"
: description = "(option as unknown as DefaultSubscriptionGroupOption).description"
: selected = "selected"
/ >
< / template >
< / Select >
< / div >
< div >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.defaults.subscriptionValidityDays' ) } }
< / label >
< input
v - model . number = "item.validity_days"
type = "number"
min = "1"
max = "36500"
class = "input h-[42px]"
/ >
< / div >
< div class = "flex items-end" >
< button
type = "button"
class = "btn btn-secondary w-full text-red-600 hover:text-red-700 dark:text-red-400"
@ click = "removeAuthSourceDefaultSubscription(authSource.source, index)"
>
{ { t ( 'common.delete' ) } }
< / button >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
2026-03-04 16:59:57 +08:00
< / div > <!-- / Tab : Users -- >
2025-12-18 13:50:39 +08:00
2026-03-04 16:59:57 +08:00
<!-- Tab : Gateway — Claude Code , Scheduling -- >
< div v-show = "activeTab === 'gateway'" class="space-y-6" >
2026-03-01 15:35:46 +08:00
<!-- Claude Code Settings -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.claudeCode.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.claudeCode.description' ) } }
< / p >
< / div >
< div class = "p-6" >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.claudeCode.minVersion' ) } }
< / label >
< input
v - model = "form.min_claude_code_version"
type = "text"
class = "input max-w-xs font-mono text-sm"
: placeholder = "t('admin.settings.claudeCode.minVersionPlaceholder')"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.claudeCode.minVersionHint' ) } }
< / p >
< / div >
2026-03-20 09:10:01 +08:00
< div class = "mt-4" >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.claudeCode.maxVersion' ) } }
< / label >
< input
v - model = "form.max_claude_code_version"
type = "text"
class = "input max-w-xs font-mono text-sm"
: placeholder = "t('admin.settings.claudeCode.maxVersionPlaceholder')"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.claudeCode.maxVersionHint' ) } }
< / p >
< / div >
2026-03-01 15:35:46 +08:00
< / div >
< / div >
2026-03-03 19:56:27 +08:00
<!-- Gateway Scheduling Settings -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.scheduling.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.scheduling.description' ) } }
< / p >
< / div >
< div class = "p-6" >
2026-04-20 17:39:57 +08:00
< div class = "space-y-4" >
< div class = "flex items-center justify-between" >
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.scheduling.allowUngroupedKey' ) } }
< / label >
< p class = "mt-0.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.scheduling.allowUngroupedKeyHint' ) } }
< / p >
< / div >
< label class = "toggle" >
< input v-model = "form.allow_ungrouped_key_scheduling" type="checkbox" / >
< span class = "toggle-slider" > < / span >
2026-03-03 19:56:27 +08:00
< / label >
< / div >
2026-04-20 17:39:57 +08:00
< div class = "flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700" >
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
2026-04-21 13:07:40 +08:00
{ { localText ( 'OpenAI 实验调度策略' , 'OpenAI experimental scheduler policy' ) } }
2026-04-20 17:39:57 +08:00
< / label >
< p class = "mt-0.5 text-xs text-gray-500 dark:text-gray-400" >
{ {
localText (
2026-04-21 13:07:40 +08:00
'默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。' ,
'Disabled by default. When enabled, this only changes the gateway\'s experimental account-selection policy for OpenAI traffic; it does not indicate an upstream OpenAI capability.'
2026-04-20 17:39:57 +08:00
)
} }
< / p >
< / div >
< Toggle v-model = "form.openai_advanced_scheduler_enabled" / >
< / div >
2026-03-03 19:56:27 +08:00
< / div >
< / div >
< / div >
2026-03-26 10:22:03 +08:00
<!-- Gateway Forwarding Behavior -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.gatewayForwarding.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.gatewayForwarding.description' ) } }
< / p >
< / div >
< div class = "space-y-5 p-6" >
<!-- Fingerprint Unification -- >
< div class = "flex items-center justify-between" >
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.gatewayForwarding.fingerprintUnification' ) } }
< / label >
< p class = "mt-0.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.gatewayForwarding.fingerprintUnificationHint' ) } }
< / p >
< / div >
< Toggle v-model = "form.enable_fingerprint_unification" / >
< / div >
<!-- Metadata Passthrough -- >
< div class = "flex items-center justify-between" >
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.gatewayForwarding.metadataPassthrough' ) } }
< / label >
< p class = "mt-0.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.gatewayForwarding.metadataPassthroughHint' ) } }
< / p >
< / div >
< Toggle v-model = "form.enable_metadata_passthrough" / >
< / div >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
<!-- CCH Signing -- >
< div class = "flex items-center justify-between" >
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.gatewayForwarding.cchSigning' ) } }
< / label >
< p class = "mt-0.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.gatewayForwarding.cchSigningHint' ) } }
< / p >
< / div >
< Toggle v-model = "form.enable_cch_signing" / >
< / div >
2026-03-26 10:22:03 +08:00
< / div >
< / div >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
<!-- Web Search Emulation -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.webSearchEmulation.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.webSearchEmulation.description' ) } }
< / p >
< / div >
< div class = "space-y-5 p-6" >
<!-- Global Toggle -- >
< div class = "flex items-center justify-between" >
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.webSearchEmulation.enabled' ) } }
< / label >
< p class = "mt-0.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.webSearchEmulation.enabledHint' ) } }
< / p >
< / div >
< Toggle v-model = "webSearchConfig.enabled" / >
< / div >
<!-- Providers -- >
< div v-if = "webSearchConfig.enabled" class="space-y-4" >
< div class = "flex items-center justify-between" >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.webSearchEmulation.providers' ) } }
< / label >
< button type = "button" class = "btn btn-secondary btn-sm" @click ="addWebSearchProvider" >
{ { t ( 'admin.settings.webSearchEmulation.addProvider' ) } }
< / button >
< / div >
< div v-if = "webSearchConfig.providers.length === 0" class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-400 dark:border-dark-600" >
{ { t ( 'admin.settings.webSearchEmulation.noProviders' ) } }
< / div >
< div v-for = "(provider, pIdx) in webSearchConfig.providers" :key="pIdx"
2026-04-12 13:11:46 +08:00
class = "rounded-lg border border-gray-200 dark:border-dark-600" >
<!-- Collapsible header -- >
< div
class = "flex cursor-pointer items-center justify-between px-4 py-3"
@ click = "toggleProviderExpand(pIdx)"
>
< div class = "flex items-center gap-3" >
< svg
class = "h-4 w-4 text-gray-400 transition-transform"
: class = "{ 'rotate-90': expandedProviders[pIdx] }"
fill = "none" viewBox = "0 0 24 24" stroke = "currentColor"
>
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M9 5l7 7-7 7" / >
< / svg >
< Select
v - model = "provider.type"
: options = " [
{ value : 'brave' , label : 'Brave Search' } ,
{ value : 'tavily' , label : 'Tavily' } ,
] "
class = "w-36"
@ click . stop
/ >
2026-04-14 08:03:27 +08:00
<!-- Quota summary ( always visible ) -- >
< span class = "text-xs text-gray-400" >
{ { provider . quota _used ? ? 0 } } / { { provider . quota _limit != null && provider . quota _limit > 0 ? provider . quota _limit : '∞' } }
2026-04-12 13:11:46 +08:00
< / span >
< span v-if = "!expandedProviders[pIdx] && provider.api_key_configured" class="text-xs text-green-500" >
{ { t ( 'admin.settings.webSearchEmulation.apiKeyConfigured' ) } }
< / span >
< / div >
< button type = "button" class = "text-red-500 hover:text-red-700 text-xs" @click.stop ="removeWebSearchProvider(pIdx)" >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
{ { t ( 'admin.settings.webSearchEmulation.removeProvider' ) } }
< / button >
< / div >
2026-04-12 13:11:46 +08:00
<!-- 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" >
2026-04-12 15:59:45 +08:00
<!-- API Key with inline show / copy -- >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
< div >
< label class = "text-xs text-gray-500" > { { t ( 'admin.settings.webSearchEmulation.apiKey' ) } } < / label >
2026-04-12 15:59:45 +08:00
< div class = "relative" >
2026-04-12 13:11:46 +08:00
< input
v - model = "provider.api_key"
: type = "apiKeyVisible[pIdx] ? 'text' : 'password'"
2026-04-12 19:46:26 +08:00
class = "input w-full text-sm"
2026-04-14 07:22:22 +08:00
: class = "(provider.api_key || provider.api_key_configured) ? 'pr-16' : ''"
2026-04-12 13:11:46 +08:00
: placeholder = "provider.api_key_configured ? '••••••••' : t('admin.settings.webSearchEmulation.apiKeyPlaceholder')"
/ >
2026-04-14 07:22:22 +08:00
< div v-if = "provider.api_key || provider.api_key_configured" class="absolute inset-y-0 right-0 flex items-center pr-1.5" >
2026-04-12 15:59:45 +08:00
< 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"
2026-04-14 07:22:22 +08:00
: class = "{ 'opacity-30 cursor-not-allowed': !provider.api_key }"
2026-04-12 15:59:45 +08:00
: title = "t('admin.settings.webSearchEmulation.copyApiKey')"
2026-04-14 07:22:22 +08:00
: disabled = "!provider.api_key"
2026-04-12 15:59:45 +08:00
@ 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 >
2026-04-12 13:11:46 +08:00
< / div >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
< / div >
2026-04-12 13:11:46 +08:00
<!-- Quota + Subscription in compact row -- >
< div class = "grid grid-cols-2 gap-3" >
< div >
< label class = "text-xs text-gray-500" > { { t ( 'admin.settings.webSearchEmulation.quotaLimit' ) } } < / label >
2026-04-14 08:03:27 +08:00
< input v-model = "provider.quota_limit" type="number" min="1" class="input text-sm" :placeholder="'∞'" / >
2026-04-12 13:11:46 +08:00
< p class = "mt-0.5 text-xs text-gray-400" > { { t ( 'admin.settings.webSearchEmulation.quotaLimitHint' ) } } < / p >
< / div >
< div >
< label class = "text-xs text-gray-500" > { { t ( 'admin.settings.webSearchEmulation.subscribedAt' ) } } < / label >
< input
: value = "formatSubscribedAt(provider.subscribed_at)"
type = "date"
class = "input text-sm"
@ input = "provider.subscribed_at = parseSubscribedAt(($event.target as HTMLInputElement).value)"
/ >
< p class = "mt-0.5 text-xs text-gray-400" > { { t ( 'admin.settings.webSearchEmulation.subscribedAtHint' ) } } < / p >
< / div >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
< / div >
2026-04-12 13:11:46 +08:00
<!-- Usage display -- >
2026-04-14 07:22:22 +08:00
< div class = "flex items-center gap-2" >
2026-04-12 13:11:46 +08:00
< span class = "text-xs text-gray-500" > { { t ( 'admin.settings.webSearchEmulation.quotaUsage' ) } } : < / span >
2026-04-14 08:03:27 +08:00
< div v-if = "provider.quota_limit != null && provider.quota_limit > 0" class="flex-1 rounded-full bg-gray-200 dark:bg-dark-600" style="height: 6px" >
2026-04-12 13:11:46 +08:00
< div
class = "h-full rounded-full transition-all"
: class = "quotaPercentage(provider) > 90 ? 'bg-red-500' : quotaPercentage(provider) > 70 ? 'bg-yellow-500' : 'bg-green-500'"
: style = "{ width: Math.min(quotaPercentage(provider), 100) + '%' }"
/ >
< / div >
2026-04-14 07:22:22 +08:00
< div v-else class = "flex-1" / >
2026-04-14 08:03:27 +08:00
< span class = "text-xs text-gray-500" > { { provider . quota _used ? ? 0 } } / { { provider . quota _limit != null && provider . quota _limit > 0 ? provider . quota _limit : '∞' } } < / span >
< button
v - if = "(provider.quota_used ?? 0) > 0"
type = "button"
class = "text-xs text-primary-600 hover:text-primary-700"
@ click = "resetWebSearchUsage(pIdx)"
>
{ { t ( 'admin.settings.webSearchEmulation.resetUsage' ) } }
< / button >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
< / div >
2026-04-12 13:11:46 +08:00
2026-04-12 15:59:45 +08:00
<!-- 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" / >
2026-04-12 13:11:46 +08:00
< / div >
2026-04-12 15:59:45 +08:00
< button
type = "button"
class = "btn btn-secondary btn-sm whitespace-nowrap"
@ click = "openTestDialog()"
>
{ { t ( 'admin.settings.webSearchEmulation.test' ) } }
< / button >
2026-04-12 13:11:46 +08:00
< / div >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
< / div >
< / div >
< / div >
< / div >
< / div >
2026-04-12 15:59:45 +08:00
<!-- 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 >
2026-03-04 16:59:57 +08:00
< / div > <!-- / Tab : Gateway — Claude Code , Scheduling -- >
2026-03-03 19:56:27 +08:00
2026-03-04 16:59:57 +08:00
<!-- Tab : General -- >
< div v-show = "activeTab === 'general'" class="space-y-6" >
2025-12-18 13:50:39 +08:00
<!-- Site Settings -- >
< div class = "card" >
2025-12-25 08:41:36 -08:00
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.site.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.description' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
2025-12-25 08:41:36 -08:00
< div class = "space-y-6 p-6" >
2026-03-12 02:42:57 +03:00
<!-- Backend Mode -- >
< div
class = "flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
< div >
< h3 class = "text-sm font-medium text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.site.backendMode' ) } }
< / h3 >
< p class = "mt-1 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.backendModeDescription' ) } }
< / p >
< / div >
< Toggle v-model = "form.backend_mode_enabled" / >
< / div >
2025-12-25 08:41:36 -08:00
< div class = "grid grid-cols-1 gap-6 md:grid-cols-2" >
2025-12-18 13:50:39 +08:00
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-18 13:50:39 +08:00
{ { t ( 'admin.settings.site.siteName' ) } }
< / label >
2025-12-27 10:50:25 +08:00
< input
v - model = "form.site_name"
type = "text"
class = "input"
: placeholder = "t('admin.settings.site.siteNamePlaceholder')"
/ >
2025-12-25 08:41:36 -08:00
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.siteNameHint' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-18 13:50:39 +08:00
{ { t ( 'admin.settings.site.siteSubtitle' ) } }
< / label >
< input
v - model = "form.site_subtitle"
type = "text"
class = "input"
2025-12-27 10:50:25 +08:00
: placeholder = "t('admin.settings.site.siteSubtitlePlaceholder')"
2025-12-18 13:50:39 +08:00
/ >
2025-12-25 08:41:36 -08:00
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.siteSubtitleHint' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
< / div >
<!-- API Base URL -- >
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-18 13:50:39 +08:00
{ { t ( 'admin.settings.site.apiBaseUrl' ) } }
< / label >
< input
v - model = "form.api_base_url"
type = "text"
class = "input font-mono text-sm"
2025-12-27 10:50:25 +08:00
: placeholder = "t('admin.settings.site.apiBaseUrlPlaceholder')"
2025-12-18 13:50:39 +08:00
/ >
2025-12-25 08:41:36 -08:00
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.apiBaseUrlHint' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
<!-- Global Table Preferences -- >
< div class = "border-t border-gray-100 pt-4 dark:border-dark-700" >
< h3 class = "text-sm font-medium text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.site.tablePreferencesTitle' ) } }
< / h3 >
< p class = "mt-1 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.tablePreferencesDescription' ) } }
< / p >
< div class = "mt-4 grid grid-cols-1 gap-6 md:grid-cols-2" >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.site.tableDefaultPageSize' ) } }
< / label >
< input
v - model . number = "form.table_default_page_size"
type = "number"
min = "5"
max = "1000"
step = "1"
class = "input w-40"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.tableDefaultPageSizeHint' ) } }
< / p >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.site.tablePageSizeOptions' ) } }
< / label >
< input
v - model = "tablePageSizeOptionsInput"
type = "text"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.site.tablePageSizeOptionsPlaceholder')"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.tablePageSizeOptionsHint' ) } }
< / p >
< / div >
< / div >
< / div >
2026-03-24 10:13:28 +08:00
<!-- Custom Endpoints -- >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.site.customEndpoints.title' ) } }
< / label >
< p class = "mb-3 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.customEndpoints.description' ) } }
< / p >
< div class = "space-y-3" >
< div
v - for = "(ep, index) in form.custom_endpoints"
: key = "index"
class = "rounded-lg border border-gray-200 p-4 dark:border-dark-600"
>
< div class = "mb-3 flex items-center justify-between" >
< span class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.site.customEndpoints.itemLabel' , { n : index + 1 } ) } }
< / span >
< button
type = "button"
class = "rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@ click = "removeEndpoint(index)"
>
< svg class = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" stroke -width = " 2 " > < path stroke -linecap = " round " stroke -linejoin = " round " d = "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" / > < / svg >
< / button >
< / div >
< div class = "grid grid-cols-1 gap-3 sm:grid-cols-2" >
< div >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.site.customEndpoints.name' ) } }
< / label >
< input
v - model = "ep.name"
type = "text"
class = "input text-sm"
: placeholder = "t('admin.settings.site.customEndpoints.namePlaceholder')"
/ >
< / div >
< div >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.site.customEndpoints.endpointUrl' ) } }
< / label >
< input
v - model = "ep.endpoint"
type = "url"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.site.customEndpoints.endpointUrlPlaceholder')"
/ >
< / div >
< div class = "sm:col-span-2" >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.site.customEndpoints.descriptionLabel' ) } }
< / label >
< input
v - model = "ep.description"
type = "text"
class = "input text-sm"
: placeholder = "t('admin.settings.site.customEndpoints.descriptionPlaceholder')"
/ >
< / div >
< / div >
< / div >
< / div >
< button
type = "button"
class = "mt-3 flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 px-4 py-2.5 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-dark-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@ click = "addEndpoint"
>
< svg class = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" stroke -width = " 2 " > < path stroke -linecap = " round " stroke -linejoin = " round " d = "M12 4v16m8-8H4" / > < / svg >
{ { t ( 'admin.settings.site.customEndpoints.add' ) } }
< / button >
< / div >
2025-12-18 13:50:39 +08:00
<!-- Contact Info -- >
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-18 13:50:39 +08:00
{ { t ( 'admin.settings.site.contactInfo' ) } }
< / label >
< input
v - model = "form.contact_info"
type = "text"
class = "input"
: placeholder = "t('admin.settings.site.contactInfoPlaceholder')"
/ >
2025-12-25 08:41:36 -08:00
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.contactInfoHint' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
2025-12-24 21:30:19 +08:00
<!-- Doc URL -- >
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-24 21:30:19 +08:00
{ { t ( 'admin.settings.site.docUrl' ) } }
< / label >
< input
v - model = "form.doc_url"
type = "url"
class = "input font-mono text-sm"
2025-12-27 10:50:25 +08:00
: placeholder = "t('admin.settings.site.docUrlPlaceholder')"
2025-12-24 21:30:19 +08:00
/ >
2025-12-25 08:41:36 -08:00
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.docUrlHint' ) } }
< / p >
2025-12-24 21:30:19 +08:00
< / div >
2025-12-18 13:50:39 +08:00
<!-- Site Logo Upload -- >
< div >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-18 13:50:39 +08:00
{ { t ( 'admin.settings.site.siteLogo' ) } }
< / label >
2026-03-03 06:20:10 +08:00
< ImageUpload
v - model = "form.site_logo"
mode = "image"
: upload - label = "t('admin.settings.site.uploadImage')"
: remove - label = "t('admin.settings.site.remove')"
: hint = "t('admin.settings.site.logoHint')"
: max - size = "300 * 1024"
/ >
2025-12-18 13:50:39 +08:00
< / div >
2026-01-10 18:37:44 +08:00
<!-- Home Content -- >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.site.homeContent' ) } }
< / label >
< textarea
v - model = "form.home_content"
rows = "6"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.site.homeContentPlaceholder')"
> < / textarea >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.homeContentHint' ) } }
< / p >
2026-01-10 22:35:33 +08:00
<!-- iframe CSP Warning -- >
< p class = "mt-2 text-xs text-amber-600 dark:text-amber-400" >
{ { t ( 'admin.settings.site.homeContentIframeWarning' ) } }
< / p >
2026-01-10 18:37:44 +08:00
< / div >
2026-02-02 22:13:50 +08:00
<!-- Hide CCS Import Button -- >
< div
class = "flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.site.hideCcsImportButton' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.site.hideCcsImportButtonHint' ) } }
< / p >
< / div >
< Toggle v-model = "form.hide_ccs_import_button" / >
< / div >
2025-12-18 13:50:39 +08:00
< / div >
< / div >
2026-02-02 22:13:50 +08:00
2026-03-02 19:37:40 +08:00
<!-- Custom Menu Items -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.customMenu.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.customMenu.description' ) } }
< / p >
< / div >
< div class = "space-y-4 p-6" >
<!-- Existing menu items -- >
< div
v - for = "(item, index) in form.custom_menu_items"
: key = "item.id || index"
class = "rounded-lg border border-gray-200 p-4 dark:border-dark-600"
>
< div class = "mb-3 flex items-center justify-between" >
< span class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.customMenu.itemLabel' , { n : index + 1 } ) } }
< / span >
< div class = "flex items-center gap-2" >
<!-- Move up -- >
< button
v - if = "index > 0"
type = "button"
class = "rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700"
: title = "t('admin.settings.customMenu.moveUp')"
@ click = "moveMenuItem(index, -1)"
>
< svg class = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" stroke -width = " 2 " > < path stroke -linecap = " round " stroke -linejoin = " round " d = "M5 15l7-7 7 7" / > < / svg >
< / button >
<!-- Move down -- >
< button
v - if = "index < form.custom_menu_items.length - 1"
type = "button"
class = "rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700"
: title = "t('admin.settings.customMenu.moveDown')"
@ click = "moveMenuItem(index, 1)"
>
< svg class = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" stroke -width = " 2 " > < path stroke -linecap = " round " stroke -linejoin = " round " d = "M19 9l-7 7-7-7" / > < / svg >
< / button >
<!-- Delete -- >
< button
type = "button"
class = "rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
: title = "t('admin.settings.customMenu.remove')"
@ click = "removeMenuItem(index)"
>
< svg class = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" stroke -width = " 2 " > < path stroke -linecap = " round " stroke -linejoin = " round " d = "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" / > < / svg >
< / button >
< / div >
< / div >
< div class = "grid grid-cols-1 gap-3 sm:grid-cols-2" >
<!-- Label -- >
< div >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.customMenu.name' ) } }
< / label >
< input
v - model = "item.label"
type = "text"
class = "input text-sm"
: placeholder = "t('admin.settings.customMenu.namePlaceholder')"
/ >
< / div >
<!-- Visibility -- >
< div >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.customMenu.visibility' ) } }
< / label >
< select v-model = "item.visibility" class="input text-sm" >
< option value = "user" > { { t ( 'admin.settings.customMenu.visibilityUser' ) } } < / option >
< option value = "admin" > { { t ( 'admin.settings.customMenu.visibilityAdmin' ) } } < / option >
< / select >
< / div >
<!-- URL ( full width ) -- >
< div class = "sm:col-span-2" >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.customMenu.url' ) } }
< / label >
< input
v - model = "item.url"
type = "url"
class = "input font-mono text-sm"
: placeholder = "t('admin.settings.customMenu.urlPlaceholder')"
/ >
< / div >
<!-- SVG Icon ( full width ) -- >
< div class = "sm:col-span-2" >
< label class = "mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400" >
{ { t ( 'admin.settings.customMenu.iconSvg' ) } }
< / label >
2026-03-03 06:20:10 +08:00
< ImageUpload
: model - value = "item.icon_svg"
mode = "svg"
size = "sm"
: upload - label = "t('admin.settings.customMenu.uploadSvg')"
: remove - label = "t('admin.settings.customMenu.removeSvg')"
@ update : model - value = "(v: string) => item.icon_svg = v"
/ >
2026-03-02 19:37:40 +08:00
< / div >
< / div >
< / div >
<!-- Add button -- >
< button
type = "button"
class = "flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 py-3 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-dark-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@ click = "addMenuItem"
>
< svg class = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" stroke -width = " 2 " > < path stroke -linecap = " round " stroke -linejoin = " round " d = "M12 4v16m8-8H4" / > < / svg >
{ { t ( 'admin.settings.customMenu.add' ) } }
< / button >
< / div >
< / div >
2026-03-04 16:59:57 +08:00
< / div > <!-- / Tab : General -- >
<!-- Tab : Email -- >
2026-04-10 21:08:51 +08:00
<!-- Tab : Payment -- >
< div v-show = "activeTab === 'payment'" class="space-y-6" >
<!-- Payment System Settings -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" > { { t ( 'admin.settings.payment.title' ) } } < / h2 >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.payment.description' ) } }
< a : href = "locale === 'zh' ? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md' : 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md'" target = "_blank" rel = "noopener noreferrer" class = "ml-2 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" >
< svg class = "mr-0.5 h-3.5 w-3.5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" > < path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" / > < / svg >
{ { t ( 'admin.settings.payment.configGuide' ) } }
< / a >
< / p >
2026-04-10 21:08:51 +08:00
< / div >
< div class = "space-y-4 p-6" >
<!-- Enable toggle -- >
< div class = "flex items-center justify-between" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { { t ( 'admin.settings.payment.enabled' ) } } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" > { { t ( 'admin.settings.payment.enabledHint' ) } } < / p >
< / div >
< Toggle v-model = "form.payment_enabled" / >
< / div >
< template v-if = "form.payment_enabled" >
<!-- Row 1 : Product name -- >
< div class = "grid grid-cols-3 gap-3" >
< div > < label class = "input-label" > { { t ( 'admin.settings.payment.productNamePrefix' ) } } < / label > < input v-model = "form.payment_product_name_prefix" type="text" class="input" placeholder="Sub2API" / > < / div >
< div > < label class = "input-label" > { { t ( 'admin.settings.payment.productNameSuffix' ) } } < / label > < input v-model = "form.payment_product_name_suffix" type="text" class="input" placeholder="CNY" / > < / div >
< div > < label class = "input-label" > { { t ( 'admin.settings.payment.preview' ) } } < / label > < div class = "rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300" > { { ( form . payment _product _name _prefix || 'Sub2API' ) + ' 100 ' + ( form . payment _product _name _suffix || 'CNY' ) } } < / div > < / div >
< / div >
<!-- Row 2 : Balance toggle + amounts -- >
2026-04-15 00:14:57 +08:00
< div class = "grid grid-cols-2 gap-3 sm:grid-cols-5" >
2026-04-10 21:08:51 +08:00
< div > < label class = "input-label" > { { t ( 'admin.settings.payment.minAmount' ) } } < / label > < input : value = "form.payment_min_amount || ''" @input ="form.payment_min_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type = "number" step = "0.01" min = "0" class = "input" :placeholder = "t('admin.settings.payment.noLimit')" / > < / div >
< div > < label class = "input-label" > { { t ( 'admin.settings.payment.maxAmount' ) } } < / label > < input : value = "form.payment_max_amount || ''" @input ="form.payment_max_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type = "number" step = "0.01" min = "0" class = "input" :placeholder = "t('admin.settings.payment.noLimit')" / > < / div >
< div > < label class = "input-label" > { { t ( 'admin.settings.payment.dailyLimit' ) } } < / label > < input : value = "form.payment_daily_limit || ''" @input ="form.payment_daily_limit = parseFloat(($event.target as HTMLInputElement).value) || 0" type = "number" step = "0.01" min = "0" class = "input" :placeholder = "t('admin.settings.payment.noLimit')" / > < / div >
2026-04-15 00:14:57 +08:00
< div >
< label class = "input-label" > { { t ( 'admin.settings.payment.balanceRechargeMultiplier' ) } } < / label >
< input : value = "form.payment_balance_recharge_multiplier || ''" @input ="form.payment_balance_recharge_multiplier = parseFloat(($event.target as HTMLInputElement).value) || 1" type = "number" step = "0.01" min = "0.01" class = "input" / >
< p class = "mt-0.5 text-xs text-gray-400" > { { t ( 'admin.settings.payment.balanceRechargeMultiplierHint' ) } } < / p >
< p class = "mt-1 text-xs font-medium text-primary-600 dark:text-primary-400" > { { t ( 'admin.settings.payment.balanceRechargePreview' , { usd : ( Number ( form . payment _balance _recharge _multiplier ) || 1 ) . toFixed ( 2 ) } ) } } < / p >
< / div >
2026-04-15 00:41:33 +08:00
< div >
< label class = "input-label" > { { t ( 'admin.settings.payment.rechargeFeeRate' ) } } < / label >
2026-04-15 01:11:49 +08:00
< div class = "relative" >
< input : value = "form.payment_recharge_fee_rate ?? ''" @input ="form.payment_recharge_fee_rate = Math.min(100, Math.max(0, Math.round(parseFloat(($event.target as HTMLInputElement).value || '0') * 100) / 100))" type = "number" step = "0.01" min = "0" max = "100" class = "input pr-8" / >
< span class = "pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400" > % < / span >
< / div >
2026-04-15 00:41:33 +08:00
< p class = "mt-0.5 text-xs text-gray-400" > { { t ( 'admin.settings.payment.rechargeFeeRateHint' ) } } < / p >
< p v-if = "(Number(form.payment_recharge_fee_rate) || 0) > 0" class="mt-1 text-xs font-medium text-primary-600 dark:text-primary-400" > {{ t ( ' admin.settings.payment.rechargeFeePreview ' , { fee : ( Number ( form.payment_recharge_fee_rate ) | | 0 ) .toFixed ( 2 ) } ) }} < / p >
< / div >
2026-04-10 21:08:51 +08:00
< div > < label class = "input-label" > { { t ( 'admin.settings.payment.orderTimeout' ) } } < span class = "text-red-500" > * < / span > < / label > < input v -model .number = " form.payment_order_timeout_minutes " type = "number" min = "1" class = "input" required / > < p class = "mt-0.5 text-xs text-gray-400" > { { t ( 'admin.settings.payment.orderTimeoutHint' ) } } < / p > < / div >
< / div >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
<!-- Row 3 : Pending orders + load balance + cancel rate limit ( all in one row ) -- >
2026-04-10 21:08:51 +08:00
< div class = "flex flex-wrap items-end gap-4" >
< div class = "w-28" > < label class = "input-label" > { { t ( 'admin.settings.payment.maxPendingOrders' ) } } < / label > < input v -model .number = " form.payment_max_pending_orders " type = "number" min = "1" class = "input" / > < / div >
< div >
< label class = "input-label" > { { t ( 'admin.settings.payment.loadBalanceStrategy' ) } } < / label >
< Select v-model = "form.payment_load_balance_strategy" :options="loadBalanceOptions" class="w-40" / >
< / div >
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
< div >
< label class = "input-label" > { { t ( 'admin.settings.payment.cancelRateLimit' ) } } < / label >
< div class = "flex items-center gap-2" >
< button
type = "button"
: class = " [
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2' ,
form . payment _cancel _rate _limit _enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
] "
@ click = "form.payment_cancel_rate_limit_enabled = !form.payment_cancel_rate_limit_enabled"
>
< span : class = " [
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out' ,
form . payment _cancel _rate _limit _enabled ? 'translate-x-5' : 'translate-x-0'
] " / >
< / button >
< Select v-model = "form.payment_cancel_rate_limit_window_mode" :options="cancelRateLimitModeOptions" class="w-24" :disabled="!form.payment_cancel_rate_limit_enabled" / >
< span : class = "['text-sm whitespace-nowrap', form.payment_cancel_rate_limit_enabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600']" > { { t ( 'admin.settings.payment.cancelRateLimitEvery' ) } } < / span >
< input v -model .number = " form.payment_cancel_rate_limit_window " type = "number" min = "1" required class = "input w-14 text-center" :disabled = "!form.payment_cancel_rate_limit_enabled" / >
< Select v-model = "form.payment_cancel_rate_limit_unit" :options="cancelRateLimitUnitOptions" class="w-28" :disabled="!form.payment_cancel_rate_limit_enabled" / >
< span : class = "['text-sm whitespace-nowrap', form.payment_cancel_rate_limit_enabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600']" > { { t ( 'admin.settings.payment.cancelRateLimitAllowMax' ) } } < / span >
< input v -model .number = " form.payment_cancel_rate_limit_max " type = "number" min = "1" required class = "input w-14 text-center" :disabled = "!form.payment_cancel_rate_limit_enabled" / >
< span : class = "['text-sm whitespace-nowrap', form.payment_cancel_rate_limit_enabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600']" > { { t ( 'admin.settings.payment.cancelRateLimitTimes' ) } } < / span >
2026-04-10 21:08:51 +08:00
< / div >
< / div >
< / div >
<!-- Row 4 : Enabled payment types ( provider badges like sub2apipay ) -- >
< div >
< label class = "input-label" > { { t ( 'admin.settings.payment.enabledPaymentTypes' ) } } < / label >
< div class = "mt-1.5 flex flex-wrap gap-2" >
< button
v - for = "pt in allPaymentTypes"
: key = "pt.value"
type = "button"
@ click = "togglePaymentType(pt.value)"
: class = " [
'rounded-lg border px-3 py-1.5 text-sm font-medium transition-all' ,
isPaymentTypeEnabled ( pt . value )
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500' ,
] "
> { { pt . label } } < / button >
< / div >
2026-04-12 13:11:46 +08:00
< p class = "mt-2 text-xs text-gray-400 dark:text-gray-500" >
{ { t ( 'admin.settings.payment.enabledPaymentTypesHint' ) } }
< a : href = "locale === 'zh' ? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E6%94%AF%E4%BB%98%E6%96%B9%E5%BC%8F' : 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods'" target = "_blank" rel = "noopener noreferrer" class = "ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300" >
{ { t ( 'admin.settings.payment.findProvider' ) } }
< svg class = "mb-0.5 ml-0.5 inline h-3 w-3" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" > < path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" / > < / svg >
< / a >
< / p >
2026-04-10 21:08:51 +08:00
< / div >
2026-04-20 17:39:57 +08:00
< div class = "grid grid-cols-1 gap-3 lg:grid-cols-2" >
< div
v - for = "visibleMethod in paymentVisibleMethodCards"
: key = "visibleMethod.key"
class = "rounded-lg border border-gray-200 p-4 dark:border-dark-700"
>
< div class = "flex items-center justify-between" >
< div >
< label class = "font-medium text-gray-900 dark:text-white" >
{ {
localText (
` ${ visibleMethod . title } 可见方式 ` ,
` ${ visibleMethod . title } visible method `
)
} }
< / label >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ {
localText (
'控制前台结算页是否展示该方式,以及展示时使用的来源键。' ,
'Controls whether checkout shows this method and which source key it exposes.'
)
} }
< / p >
< / div >
< Toggle
: model - value = "getPaymentVisibleMethodEnabled(visibleMethod.key)"
@ update : model - value = "setPaymentVisibleMethodEnabled(visibleMethod.key, $event)"
/ >
< / div >
< div class = "mt-4" >
< label class = "input-label" >
2026-04-21 00:03:27 +08:00
{ { localText ( '支付来源' , 'Payment source' ) } }
2026-04-20 17:39:57 +08:00
< / label >
2026-04-21 00:03:27 +08:00
< Select
: model - value = "getPaymentVisibleMethodSource(visibleMethod.key)"
: options = "getPaymentVisibleMethodSourceSelectOptions(visibleMethod.key)"
@ update : model - value = "setPaymentVisibleMethodSource(visibleMethod.key, $event)"
2026-04-20 17:39:57 +08:00
: placeholder = "visibleMethod.key"
/ >
< p class = "mt-1.5 text-xs text-gray-400" >
{ {
localText (
2026-04-21 00:41:29 +08:00
'启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。' ,
'Choose an explicit source before enabling the method. Not configured methods are not exposed.'
2026-04-20 17:39:57 +08:00
)
} }
< / p >
< / div >
< / div >
< / div >
2026-04-10 21:08:51 +08:00
<!-- Row 5 : Help image + text -- >
< div class = "grid grid-cols-2 gap-3" >
< div >
< label class = "input-label" > { { t ( 'admin.settings.payment.helpImage' ) } } < / label >
< ImageUpload v-model = "form.payment_help_image_url" :placeholder="t('admin.settings.payment.helpImagePlaceholder')" / >
< / div >
< div >
< label class = "input-label" > { { t ( 'admin.settings.payment.helpText' ) } } < / label >
< textarea v-model = "form.payment_help_text" rows="3" class="input" :placeholder="t('admin.settings.payment.helpTextPlaceholder')" > < / textarea >
< / div >
< / div >
< / template >
< / div >
< / div >
<!-- Provider Management -- >
< PaymentProviderList
v - if = "form.payment_enabled"
: providers = "providers"
: loading = "providersLoading"
: can - create = "hasAnyPaymentTypeEnabled"
: enabled - payment - types = "form.payment_enabled_types"
: all - payment - types = "allPaymentTypes"
: redirect - label = "t('admin.settings.payment.easypayRedirect')"
@ refresh = "loadProviders"
@ create = "openCreateProvider"
@ edit = "openEditProvider"
@ delete = "confirmDeleteProvider"
@ toggle - field = "handleToggleField"
@ toggle - type = "handleToggleType"
@ reorder = "handleReorderProviders"
/ >
< / div >
2026-03-04 16:59:57 +08:00
< div v-show = "activeTab === 'email'" class="space-y-6" >
<!-- Email disabled hint - show when email _verify _enabled is off -- >
< div v-if = "!form.email_verify_enabled" class="card" >
< div class = "p-6" >
< div class = "flex items-start gap-3" >
< Icon name = "mail" size = "md" class = "mt-0.5 flex-shrink-0 text-gray-400 dark:text-gray-500" / >
< div >
< h3 class = "font-medium text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.emailTabDisabledTitle' ) } }
< / h3 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.emailTabDisabledHint' ) } }
< / p >
< / div >
< / div >
< / div >
< / div >
<!-- SMTP Settings - Only show when email verification is enabled -- >
< div v-if = "form.email_verify_enabled" class="card" >
< div
class = "flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
< div >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.smtp.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.smtp.description' ) } }
< / p >
< / div >
< button
type = "button"
@ click = "testSmtpConnection"
2026-03-21 23:36:30 +08:00
: disabled = "testingSmtp || loadFailed"
2026-03-04 16:59:57 +08:00
class = "btn btn-secondary btn-sm"
>
< svg v-if = "testingSmtp" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24" >
< circle
class = "opacity-25"
cx = "12"
cy = "12"
r = "10"
stroke = "currentColor"
stroke - width = "4"
> < / circle >
< path
class = "opacity-75"
fill = "currentColor"
d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
> < / path >
< / svg >
{ {
testingSmtp
? t ( 'admin.settings.smtp.testing' )
: t ( 'admin.settings.smtp.testConnection' )
} }
< / button >
< / div >
< div class = "space-y-6 p-6" >
< div class = "grid grid-cols-1 gap-6 md:grid-cols-2" >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.smtp.host' ) } }
< / label >
< input
v - model = "form.smtp_host"
type = "text"
class = "input"
: placeholder = "t('admin.settings.smtp.hostPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.smtp.port' ) } }
< / label >
< input
v - model . number = "form.smtp_port"
type = "number"
min = "1"
max = "65535"
class = "input"
: placeholder = "t('admin.settings.smtp.portPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.smtp.username' ) } }
< / label >
< input
v - model = "form.smtp_username"
type = "text"
class = "input"
: placeholder = "t('admin.settings.smtp.usernamePlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.smtp.password' ) } }
< / label >
< input
v - model = "form.smtp_password"
type = "password"
class = "input"
2026-03-21 23:36:30 +08:00
autocomplete = "new-password"
autocapitalize = "off"
spellcheck = "false"
@ keydown = "smtpPasswordManuallyEdited = true"
@ paste = "smtpPasswordManuallyEdited = true"
2026-03-04 16:59:57 +08:00
: placeholder = "
form . smtp _password _configured
? t ( 'admin.settings.smtp.passwordConfiguredPlaceholder' )
: t ( 'admin.settings.smtp.passwordPlaceholder' )
"
/ >
< p class = "mt-1.5 text-xs text-gray-500 dark:text-gray-400" >
{ {
form . smtp _password _configured
? t ( 'admin.settings.smtp.passwordConfiguredHint' )
: t ( 'admin.settings.smtp.passwordHint' )
} }
< / p >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.smtp.fromEmail' ) } }
< / label >
< input
v - model = "form.smtp_from_email"
type = "email"
class = "input"
: placeholder = "t('admin.settings.smtp.fromEmailPlaceholder')"
/ >
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.settings.smtp.fromName' ) } }
< / label >
< input
v - model = "form.smtp_from_name"
type = "text"
class = "input"
: placeholder = "t('admin.settings.smtp.fromNamePlaceholder')"
/ >
< / div >
< / div >
<!-- Use TLS Toggle -- >
< div
class = "flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
< div >
< label class = "font-medium text-gray-900 dark:text-white" > { {
t ( 'admin.settings.smtp.useTls' )
} } < / label >
< p class = "text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.smtp.useTlsHint' ) } }
< / p >
< / div >
< Toggle v-model = "form.smtp_use_tls" / >
< / div >
2026-03-15 17:52:29 +08:00
2026-03-04 16:59:57 +08:00
< / div >
< / div >
2025-12-18 13:50:39 +08:00
<!-- Send Test Email - Only show when email verification is enabled -- >
< div v-if = "form.email_verify_enabled" class="card" >
2025-12-25 08:41:36 -08:00
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.testEmail.title' ) } }
< / h2 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.testEmail.description' ) } }
< / p >
2025-12-18 13:50:39 +08:00
< / div >
< div class = "p-6" >
< div class = "flex items-end gap-4" >
< div class = "flex-1" >
2025-12-25 08:41:36 -08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" >
2025-12-18 13:50:39 +08:00
{ { t ( 'admin.settings.testEmail.recipientEmail' ) } }
< / label >
< input
v - model = "testEmailAddress"
type = "email"
class = "input"
2025-12-27 10:50:25 +08:00
: placeholder = "t('admin.settings.testEmail.recipientEmailPlaceholder')"
2025-12-18 13:50:39 +08:00
/ >
< / div >
< button
type = "button"
@ click = "sendTestEmail"
2026-03-21 23:36:30 +08:00
: disabled = "sendingTestEmail || !testEmailAddress || loadFailed"
2025-12-18 13:50:39 +08:00
class = "btn btn-secondary"
>
2025-12-25 08:41:36 -08:00
< svg
v - if = "sendingTestEmail"
class = "h-4 w-4 animate-spin"
fill = "none"
viewBox = "0 0 24 24"
>
< circle
class = "opacity-25"
cx = "12"
cy = "12"
r = "10"
stroke = "currentColor"
stroke - width = "4"
> < / circle >
< path
class = "opacity-75"
fill = "currentColor"
d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
> < / path >
2025-12-18 13:50:39 +08:00
< / svg >
2025-12-25 08:41:36 -08:00
{ {
sendingTestEmail
? t ( 'admin.settings.testEmail.sending' )
: t ( 'admin.settings.testEmail.sendTestEmail' )
} }
2025-12-18 13:50:39 +08:00
< / button >
< / div >
< / div >
< / div >
2026-04-12 13:53:02 +08:00
<!-- Balance Low Notification -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h3 class = "text-base font-medium text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.balanceNotify.title' ) } }
< / h3 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.balanceNotify.description' ) } }
< / p >
< / div >
< div class = "px-6 py-6 space-y-4" >
< div class = "flex items-center justify-between" >
< label class = "mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300" > { { t ( 'admin.settings.balanceNotify.enabled' ) } } < / label >
< Toggle v-model = "form.balance_low_notify_enabled" / >
< / div >
2026-04-12 15:01:10 +08:00
< div v-if = "form.balance_low_notify_enabled" >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" > { { t ( 'admin.settings.balanceNotify.threshold' ) } } < / label >
< div class = "relative" >
< span class = "absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" > $ < / span >
< input v -model .number = " form.balance_low_notify_threshold " type = "number" min = "0" step = "0.01" class = "input pl-7" / >
2026-04-12 13:53:02 +08:00
< / div >
2026-04-12 15:01:10 +08:00
< p class = "mt-1 text-xs text-gray-500 dark:text-gray-400" > { { t ( 'admin.settings.balanceNotify.thresholdHint' ) } } < / p >
2026-04-12 13:53:02 +08:00
< / div >
2026-04-13 18:39:45 +08:00
< div >
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" > { { t ( 'admin.settings.balanceNotify.rechargeUrl' ) } } < / label >
2026-04-13 18:44:36 +08:00
< input v-model = "form.balance_low_notify_recharge_url" type="url" class="input" :placeholder="currentOrigin" / >
2026-04-13 18:39:45 +08:00
< p class = "mt-1 text-xs text-gray-500 dark:text-gray-400" > { { t ( 'admin.settings.balanceNotify.rechargeUrlHint' ) } } < / p >
< / div >
2026-04-12 13:53:02 +08:00
< / div >
< / div >
<!-- Account Quota Notification -- >
< div class = "card" >
< div class = "border-b border-gray-100 px-6 py-4 dark:border-dark-700" >
< h3 class = "text-base font-medium text-gray-900 dark:text-white" >
{ { t ( 'admin.settings.quotaNotify.title' ) } }
< / h3 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.settings.quotaNotify.description' ) } }
< / p >
< / div >
< div class = "px-6 py-6 space-y-4" >
2026-04-12 17:49:58 +08:00
< div class = "flex items-center justify-between" >
< label class = "mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300" > { { t ( 'admin.settings.quotaNotify.enabled' ) } } < / label >
< Toggle v-model = "form.account_quota_notify_enabled" / >
< / div >
< div v-if = "form.account_quota_notify_enabled" >
2026-04-12 13:53:02 +08:00
< label class = "mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" > { { t ( 'admin.settings.quotaNotify.emails' ) } } < / label >
< div class = "space-y-2" >
2026-04-13 00:52:42 +08:00
< div v-for = "(entry, index) in (form.account_quota_notify_emails || [])" :key="index" class="flex items-center gap-2" >
< label class = "relative inline-flex items-center cursor-pointer shrink-0" >
< input type = "checkbox" :checked = "!entry.disabled" @change ="entry.disabled = !entry.disabled" class = "sr-only peer" / >
< div class = "w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600" > < / div >
< / label >
< input v-model = "entry.email" type="email" class="input flex-1" :placeholder="t('admin.settings.quotaNotify.emailPlaceholder')" / >
2026-04-12 13:53:02 +08:00
< button @click ="form.account_quota_notify_emails.splice(index, 1)" class = "btn btn-secondary px-2" type = "button" >
< Icon name = "x" size = "xs" class = "h-4 w-4" / >
< / button >
< / div >
< button @click ="addQuotaNotifyEmail" class = "btn btn-secondary btn-sm" type = "button" >
+ { { t ( 'admin.settings.quotaNotify.addEmail' ) } }
< / button >
< / div >
< p class = "mt-1 text-xs text-gray-500 dark:text-gray-400" > { { t ( 'admin.settings.quotaNotify.emailsHint' ) } } < / p >
< / div >
< / div >
< / div >
2026-03-04 16:59:57 +08:00
< / div > <!-- / Tab : Email -- >
2025-12-18 13:50:39 +08:00
2026-03-14 20:22:39 +08:00
<!-- Tab : Backup -- >
< div v-show = "activeTab === 'backup'" >
< BackupSettings / >
< / div >
2025-12-18 13:50:39 +08:00
<!-- Save Button -- >
2026-04-05 15:30:40 +08:00
< div v-show = "activeTab !== 'backup'" class="flex justify-end" >
2026-03-21 23:36:30 +08:00
< button type = "submit" : disabled = "saving || loadFailed" class = "btn btn-primary" >
2025-12-25 08:41:36 -08:00
< svg v-if = "saving" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24" >
< circle
class = "opacity-25"
cx = "12"
cy = "12"
r = "10"
stroke = "currentColor"
stroke - width = "4"
> < / circle >
< path
class = "opacity-75"
fill = "currentColor"
d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
> < / path >
2025-12-18 13:50:39 +08:00
< / svg >
{ { saving ? t ( 'admin.settings.saving' ) : t ( 'admin.settings.saveSettings' ) } }
< / button >
< / div >
< / form >
2026-04-10 21:08:51 +08:00
<!-- Provider dialogs placed outside the settings form to prevent form submission bubbling -- >
< PaymentProviderDialog
ref = "providerDialogRef"
: show = "showProviderDialog"
: saving = "providerSaving"
: editing = "editingProvider"
: all - key - options = "providerKeyOptions"
: enabled - key - options = "enabledProviderKeyOptions"
: all - payment - types = "allPaymentTypes"
: redirect - label = "t('admin.settings.payment.easypayRedirect')"
@ close = "showProviderDialog = false"
@ save = "handleSaveProvider"
/ >
< ConfirmDialog :show = "showDeleteProviderDialog" :title = "t('admin.settings.payment.deleteProvider')" :message = "t('admin.settings.payment.deleteProviderConfirm')" :confirm-text = "t('common.delete')" danger @confirm ="handleDeleteProvider" @cancel ="showDeleteProviderDialog = false" / >
2025-12-18 13:50:39 +08:00
< / div >
< / AppLayout >
< / template >
< script setup lang = "ts" >
2026-01-12 09:14:32 +08:00
import { ref , reactive , computed , onMounted } from 'vue'
2025-12-25 08:41:36 -08:00
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api'
2026-04-20 17:39:57 +08:00
import {
appendAuthSourceDefaultsToUpdateRequest ,
buildAuthSourceDefaultsState ,
2026-04-21 00:03:27 +08:00
getPaymentVisibleMethodSourceOptions ,
normalizePaymentVisibleMethodSource ,
2026-04-20 17:39:57 +08:00
normalizeDefaultSubscriptionSettings ,
} from '@/api/admin/settings'
2026-03-02 03:41:50 +08:00
import type {
2026-04-20 17:39:57 +08:00
AuthSourceDefaultsState ,
AuthSourceType ,
2026-04-21 00:03:27 +08:00
PaymentVisibleMethod ,
2026-03-02 03:41:50 +08:00
SystemSettings ,
UpdateSettingsRequest ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
DefaultSubscriptionSetting ,
WebSearchEmulationConfig ,
WebSearchProviderConfig ,
2026-04-12 13:11:46 +08:00
WebSearchTestResult ,
2026-03-02 03:41:50 +08:00
} from '@/api/admin/settings'
2026-04-13 12:07:09 +08:00
import type { AdminGroup , Proxy , NotifyEmailEntry } from '@/types'
2026-04-10 21:08:51 +08:00
import type { ProviderInstance } from '@/types/payment'
2025-12-25 08:41:36 -08:00
import AppLayout from '@/components/layout/AppLayout.vue'
refactor(frontend): 完成所有组件的内联SVG统一替换为Icon组件
- 扩展 Icon.vue 组件,新增 60+ 图标路径
- 导航类: arrowRight, arrowLeft, arrowUp, arrowDown, chevronUp, externalLink
- 状态类: checkCircle, xCircle, exclamationCircle, exclamationTriangle, infoCircle
- 用户类: user, userCircle, userPlus, users
- 文档类: document, clipboard, copy, inbox
- 操作类: download, upload, filter, sort
- 安全类: key, lock, shield
- UI类: menu, calendar, home, terminal, gift, creditCard, mail
- 数据类: chartBar, trendingUp, database, cube
- 其他: bolt, sparkles, cloud, server, sun, moon, book 等
- 重构 56 个 Vue 组件,用 Icon 组件替换内联 SVG
- 净减少约 2200 行代码
- 提升代码可维护性和一致性
- 统一图标样式和尺寸管理
2026-01-05 20:22:48 +08:00
import Icon from '@/components/icons/Icon.vue'
2026-03-02 03:41:50 +08:00
import Select from '@/components/common/Select.vue'
2026-04-10 21:08:51 +08:00
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import PaymentProviderList from '@/components/payment/PaymentProviderList.vue'
import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vue'
2026-03-02 03:41:50 +08:00
import GroupBadge from '@/components/common/GroupBadge.vue'
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
2025-12-25 08:41:36 -08:00
import Toggle from '@/components/common/Toggle.vue'
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
import ProxySelector from '@/components/common/ProxySelector.vue'
2026-03-03 06:20:10 +08:00
import ImageUpload from '@/components/common/ImageUpload.vue'
2026-03-14 20:22:39 +08:00
import BackupSettings from '@/views/admin/BackupView.vue'
2026-01-12 09:14:32 +08:00
import { useClipboard } from '@/composables/useClipboard'
2026-04-10 21:08:51 +08:00
import { extractApiErrorMessage } from '@/utils/apiError'
2026-01-12 09:14:32 +08:00
import { useAppStore } from '@/stores'
2026-03-04 10:44:28 +08:00
import { useAdminSettingsStore } from '@/stores/adminSettings'
2026-03-02 23:13:39 +08:00
import {
isRegistrationEmailSuffixDomainValid ,
normalizeRegistrationEmailSuffixDomain ,
normalizeRegistrationEmailSuffixDomains ,
parseRegistrationEmailSuffixWhitelistInput
} from '@/utils/registrationEmailPolicy'
2025-12-25 08:41:36 -08:00
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
const { t , locale } = useI18n ( )
2025-12-25 08:41:36 -08:00
const appStore = useAppStore ( )
2026-03-04 10:44:28 +08:00
const adminSettingsStore = useAdminSettingsStore ( )
2026-03-04 16:59:57 +08:00
2026-04-20 17:39:57 +08:00
function localText ( zh : string , en : string ) : string {
return locale . value . startsWith ( 'zh' ) ? zh : en
}
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
type SettingsTab = 'general' | 'security' | 'users' | 'gateway' | 'payment' | 'email' | 'backup'
2026-03-04 16:59:57 +08:00
const activeTab = ref < SettingsTab > ( 'general' )
const settingsTabs = [
{ key : 'general' as SettingsTab , icon : 'home' as const } ,
{ key : 'security' as SettingsTab , icon : 'shield' as const } ,
{ key : 'users' as SettingsTab , icon : 'user' as const } ,
{ key : 'gateway' as SettingsTab , icon : 'server' as const } ,
2026-04-10 21:08:51 +08:00
{ key : 'payment' as SettingsTab , icon : 'creditCard' as const } ,
2026-03-04 16:59:57 +08:00
{ key : 'email' as SettingsTab , icon : 'mail' as const } ,
2026-03-14 20:22:39 +08:00
{ key : 'backup' as SettingsTab , icon : 'database' as const } ,
2026-03-04 16:59:57 +08:00
]
2026-01-12 09:14:32 +08:00
const { copyToClipboard } = useClipboard ( )
2025-12-25 08:41:36 -08:00
const loading = ref ( true )
2026-03-21 23:36:30 +08:00
const loadFailed = ref ( false )
2025-12-25 08:41:36 -08:00
const saving = ref ( false )
const testingSmtp = ref ( false )
const sendingTestEmail = ref ( false )
2026-03-21 23:36:30 +08:00
const smtpPasswordManuallyEdited = ref ( false )
2025-12-25 08:41:36 -08:00
const testEmailAddress = ref ( '' )
2026-03-02 23:13:39 +08:00
const registrationEmailSuffixWhitelistTags = ref < string [ ] > ( [ ] )
const registrationEmailSuffixWhitelistDraft = ref ( '' )
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
const tablePageSizeOptionsInput = ref ( '10, 20, 50, 100' )
2025-12-18 13:50:39 +08:00
2025-12-20 15:11:43 +08:00
// Admin API Key 状态
2025-12-25 08:41:36 -08:00
const adminApiKeyLoading = ref ( true )
const adminApiKeyExists = ref ( false )
const adminApiKeyMasked = ref ( '' )
const adminApiKeyOperating = ref ( false )
const newAdminApiKey = ref ( '' )
2026-03-02 03:41:50 +08:00
const subscriptionGroups = ref < AdminGroup [ ] > ( [ ] )
2025-12-20 15:11:43 +08:00
2026-03-18 16:22:19 +08:00
// Overload Cooldown (529) 状态
const overloadCooldownLoading = ref ( true )
const overloadCooldownSaving = ref ( false )
const overloadCooldownForm = reactive ( {
enabled : true ,
cooldown _minutes : 10
} )
2026-01-11 21:54:52 -08:00
// Stream Timeout 状态
const streamTimeoutLoading = ref ( true )
const streamTimeoutSaving = ref ( false )
const streamTimeoutForm = reactive ( {
enabled : true ,
action : 'temp_unsched' as 'temp_unsched' | 'error' | 'none' ,
temp _unsched _minutes : 5 ,
threshold _count : 3 ,
threshold _window _minutes : 10
} )
2026-03-07 21:45:18 +08:00
// Rectifier 状态
const rectifierLoading = ref ( true )
const rectifierSaving = ref ( false )
const rectifierForm = reactive ( {
enabled : true ,
thinking _signature _enabled : true ,
2026-03-26 16:43:38 +08:00
thinking _budget _enabled : true ,
apikey _signature _enabled : false ,
apikey _signature _patterns : [ ] as string [ ]
2026-03-07 21:45:18 +08:00
} )
2026-03-10 11:14:17 +08:00
// Beta Policy 状态
const betaPolicyLoading = ref ( true )
const betaPolicySaving = ref ( false )
const betaPolicyForm = reactive ( {
rules : [ ] as Array < {
beta _token : string
action : 'pass' | 'filter' | 'block'
2026-03-14 17:13:30 +08:00
scope : 'all' | 'oauth' | 'apikey' | 'bedrock'
2026-03-10 11:14:17 +08:00
error _message ? : string
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
model _whitelist ? : string [ ]
fallback _action ? : 'pass' | 'filter' | 'block'
fallback _error _message ? : string
2026-03-10 11:14:17 +08:00
} >
} )
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
const tablePageSizeMin = 5
const tablePageSizeMax = 1000
const tablePageSizeDefault = 20
2026-03-02 03:41:50 +08:00
interface DefaultSubscriptionGroupOption {
value : number
label : string
description : string | null
platform : AdminGroup [ 'platform' ]
subscriptionType : AdminGroup [ 'subscription_type' ]
rate : number
[ key : string ] : unknown
}
2026-01-02 17:40:57 +08:00
type SettingsForm = SystemSettings & {
smtp _password : string
turnstile _secret _key : string
2026-01-12 09:14:32 +08:00
linuxdo _connect _client _secret : string
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
oidc _connect _client _secret : string
2026-04-20 17:39:57 +08:00
force _email _on _third _party _signup : boolean
payment _visible _method _alipay _source : string
payment _visible _method _wxpay _source : string
payment _visible _method _alipay _enabled : boolean
payment _visible _method _wxpay _enabled : boolean
openai _advanced _scheduler _enabled : boolean
2026-01-02 17:40:57 +08:00
}
const form = reactive < SettingsForm > ( {
2025-12-18 13:50:39 +08:00
registration _enabled : true ,
email _verify _enabled : false ,
2026-03-02 23:13:39 +08:00
registration _email _suffix _whitelist : [ ] ,
2026-02-02 22:13:50 +08:00
promo _code _enabled : true ,
2026-01-29 16:29:59 +08:00
invitation _code _enabled : false ,
2026-02-02 22:13:50 +08:00
password _reset _enabled : false ,
totp _enabled : false ,
totp _encryption _key _configured : false ,
2025-12-18 13:50:39 +08:00
default _balance : 0 ,
default _concurrency : 1 ,
2026-03-02 03:41:50 +08:00
default _subscriptions : [ ] ,
2026-04-20 17:39:57 +08:00
force _email _on _third _party _signup : false ,
2025-12-18 13:50:39 +08:00
site _name : 'Sub2API' ,
site _logo : '' ,
site _subtitle : 'Subscription to API Conversion Platform' ,
api _base _url : '' ,
contact _info : '' ,
2025-12-24 21:30:19 +08:00
doc _url : '' ,
2026-01-10 18:37:44 +08:00
home _content : '' ,
2026-03-12 02:42:57 +03:00
backend _mode _enabled : false ,
2026-02-02 22:13:50 +08:00
hide _ccs _import _button : false ,
2026-04-20 17:39:57 +08:00
payment _enabled : false , payment _min _amount : 1 , payment _max _amount : 10000 , payment _daily _limit : 50000 , payment _max _pending _orders : 3 , payment _order _timeout _minutes : 30 , payment _balance _disabled : false , payment _balance _recharge _multiplier : 1 , payment _recharge _fee _rate : 0 , payment _enabled _types : [ ] , payment _help _image _url : '' , payment _help _text : '' , payment _product _name _prefix : '' , payment _product _name _suffix : '' , payment _load _balance _strategy : 'round-robin' , payment _cancel _rate _limit _enabled : false , payment _cancel _rate _limit _max : 10 , payment _cancel _rate _limit _window : 1 , payment _cancel _rate _limit _unit : 'day' , payment _cancel _rate _limit _window _mode : 'rolling' , payment _visible _method _alipay _source : '' , payment _visible _method _wxpay _source : '' , payment _visible _method _alipay _enabled : false , payment _visible _method _wxpay _enabled : false ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
table _default _page _size : tablePageSizeDefault ,
table _page _size _options : [ 10 , 20 , 50 , 100 ] ,
2026-03-02 19:37:40 +08:00
custom _menu _items : [ ] as Array < { id : string ; label : string ; icon _svg : string ; url : string ; visibility : 'user' | 'admin' ; sort _order : number } > ,
2026-03-24 10:13:28 +08:00
custom _endpoints : [ ] as Array < { name : string ; endpoint : string ; description : string } > ,
2026-03-15 17:52:29 +08:00
frontend _url : '' ,
2025-12-18 13:50:39 +08:00
smtp _host : '' ,
smtp _port : 587 ,
smtp _username : '' ,
smtp _password : '' ,
2026-01-02 17:40:57 +08:00
smtp _password _configured : false ,
2025-12-18 13:50:39 +08:00
smtp _from _email : '' ,
smtp _from _name : '' ,
smtp _use _tls : true ,
// Cloudflare Turnstile
turnstile _enabled : false ,
turnstile _site _key : '' ,
2026-01-04 22:49:40 +08:00
turnstile _secret _key : '' ,
2026-01-04 23:17:15 +08:00
turnstile _secret _key _configured : false ,
2026-01-12 09:14:32 +08:00
// LinuxDo Connect OAuth 登录
linuxdo _connect _enabled : false ,
linuxdo _connect _client _id : '' ,
linuxdo _connect _client _secret : '' ,
linuxdo _connect _client _secret _configured : false ,
linuxdo _connect _redirect _url : '' ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
// Generic OIDC OAuth 登录
oidc _connect _enabled : false ,
oidc _connect _provider _name : 'OIDC' ,
oidc _connect _client _id : '' ,
oidc _connect _client _secret : '' ,
oidc _connect _client _secret _configured : false ,
oidc _connect _issuer _url : '' ,
oidc _connect _discovery _url : '' ,
oidc _connect _authorize _url : '' ,
oidc _connect _token _url : '' ,
oidc _connect _userinfo _url : '' ,
oidc _connect _jwks _url : '' ,
oidc _connect _scopes : 'openid email profile' ,
oidc _connect _redirect _url : '' ,
oidc _connect _frontend _redirect _url : '/auth/oidc/callback' ,
oidc _connect _token _auth _method : 'client_secret_post' ,
2026-04-20 16:23:42 +08:00
oidc _connect _use _pkce : true ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
oidc _connect _validate _id _token : true ,
oidc _connect _allowed _signing _algs : 'RS256,ES256,PS256' ,
oidc _connect _clock _skew _seconds : 120 ,
oidc _connect _require _email _verified : false ,
oidc _connect _userinfo _email _path : '' ,
oidc _connect _userinfo _id _path : '' ,
oidc _connect _userinfo _username _path : '' ,
2026-01-09 21:00:04 +08:00
// Model fallback
enable _model _fallback : false ,
fallback _model _anthropic : 'claude-3-5-sonnet-20241022' ,
fallback _model _openai : 'gpt-4o' ,
fallback _model _gemini : 'gemini-2.5-pro' ,
fallback _model _antigravity : 'gemini-2.5-pro' ,
2026-01-04 22:49:40 +08:00
// Identity patch (Claude -> Gemini)
enable _identity _patch : true ,
2026-01-12 09:14:32 +08:00
identity _patch _prompt : '' ,
// Ops monitoring (vNext)
ops _monitoring _enabled : true ,
ops _realtime _monitoring _enabled : true ,
ops _query _mode _default : 'auto' ,
2026-03-01 15:35:46 +08:00
ops _metrics _interval _seconds : 60 ,
// Claude Code version check
2026-03-03 19:56:27 +08:00
min _claude _code _version : '' ,
2026-03-20 09:10:01 +08:00
max _claude _code _version : '' ,
2026-03-03 19:56:27 +08:00
// 分组隔离
2026-03-26 10:22:03 +08:00
allow _ungrouped _key _scheduling : false ,
2026-04-20 17:39:57 +08:00
openai _advanced _scheduler _enabled : false ,
2026-03-26 10:22:03 +08:00
// Gateway forwarding behavior
enable _fingerprint _unification : true ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
enable _metadata _passthrough : false ,
2026-04-12 13:53:02 +08:00
enable _cch _signing : false ,
// Balance & quota notification
balance _low _notify _enabled : false ,
balance _low _notify _threshold : 0 ,
2026-04-13 18:39:45 +08:00
balance _low _notify _recharge _url : '' ,
2026-04-12 17:49:58 +08:00
account _quota _notify _enabled : false ,
2026-04-13 12:07:09 +08:00
account _quota _notify _emails : [ ] as NotifyEmailEntry [ ]
2026-01-12 09:14:32 +08:00
} )
2026-04-20 17:39:57 +08:00
const authSourceDefaults = reactive < AuthSourceDefaultsState > ( buildAuthSourceDefaultsState ( { } ) )
const authSourceDefaultsMeta = computed ( ( ) => [
{
source : 'email' as AuthSourceType ,
title : localText ( '邮箱注册' , 'Email signup' ) ,
description : localText ( '适用于邮箱密码注册的新用户默认配额。' , 'Default quota grants for email-password signups.' )
} ,
{
source : 'linuxdo' as AuthSourceType ,
title : localText ( 'Linux DO 登录' , 'Linux DO signup' ) ,
description : localText ( '适用于 Linux DO 第三方注册的新用户默认配额。' , 'Default quota grants for Linux DO signups.' )
} ,
{
source : 'oidc' as AuthSourceType ,
title : localText ( 'OIDC 登录' , 'OIDC signup' ) ,
description : localText ( '适用于 OIDC 第三方注册的新用户默认配额。' , 'Default quota grants for OIDC signups.' )
} ,
{
source : 'wechat' as AuthSourceType ,
title : localText ( '微信登录' , 'WeChat signup' ) ,
description : localText ( '适用于微信第三方注册的新用户默认配额。' , 'Default quota grants for WeChat signups.' )
} ,
] )
const paymentVisibleMethodCards = computed ( ( ) => [
{
key : 'alipay' as const ,
title : t ( 'payment.methods.alipay' ) ,
enabledField : 'payment_visible_method_alipay_enabled' as const ,
sourceField : 'payment_visible_method_alipay_source' as const ,
} ,
{
key : 'wxpay' as const ,
title : t ( 'payment.methods.wxpay' ) ,
enabledField : 'payment_visible_method_wxpay_enabled' as const ,
sourceField : 'payment_visible_method_wxpay_source' as const ,
} ,
] )
function getPaymentVisibleMethodEnabled ( method : 'alipay' | 'wxpay' ) : boolean {
return method === 'alipay'
? form . payment _visible _method _alipay _enabled
: form . payment _visible _method _wxpay _enabled
}
function setPaymentVisibleMethodEnabled ( method : 'alipay' | 'wxpay' , enabled : boolean ) {
if ( method === 'alipay' ) {
form . payment _visible _method _alipay _enabled = enabled
return
}
form . payment _visible _method _wxpay _enabled = enabled
}
function getPaymentVisibleMethodSource ( method : 'alipay' | 'wxpay' ) : string {
return method === 'alipay'
? form . payment _visible _method _alipay _source
: form . payment _visible _method _wxpay _source
}
2026-04-21 00:03:27 +08:00
function getPaymentVisibleMethodSourceSelectOptions ( method : PaymentVisibleMethod ) {
return getPaymentVisibleMethodSourceOptions ( method ) . map ( ( option ) => ( {
value : option . value ,
label : localText ( option . labelZh , option . labelEn ) ,
} ) )
}
function setPaymentVisibleMethodSource (
method : 'alipay' | 'wxpay' ,
source : string | number | boolean | null
) {
const normalized = normalizePaymentVisibleMethodSource ( method , source )
2026-04-20 17:39:57 +08:00
if ( method === 'alipay' ) {
2026-04-21 00:03:27 +08:00
form . payment _visible _method _alipay _source = normalized
2026-04-20 17:39:57 +08:00
return
}
2026-04-21 00:03:27 +08:00
form . payment _visible _method _wxpay _source = normalized
2026-04-20 17:39:57 +08:00
}
2026-04-21 00:41:29 +08:00
function validatePaymentVisibleMethodSelections ( ) : boolean {
for ( const visibleMethod of paymentVisibleMethodCards . value ) {
if ( ! getPaymentVisibleMethodEnabled ( visibleMethod . key ) ) {
continue
}
if ( getPaymentVisibleMethodSource ( visibleMethod . key ) ) {
continue
}
appStore . showError (
localText (
` ${ visibleMethod . title } 已启用,请先选择支付来源 ` ,
` Select a payment source before enabling ${ visibleMethod . title } `
)
)
return false
}
return true
}
2026-04-12 13:11:46 +08:00
// Proxies for web search emulation ProxySelector
const webSearchProxies = ref < Proxy [ ] > ( [ ] )
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
// Web Search Emulation config (loaded/saved separately)
const DEFAULT _WEB _SEARCH _QUOTA _LIMIT = 1000
const webSearchConfig = reactive < WebSearchEmulationConfig > ( {
enabled : false ,
providers : [ ] ,
} )
2026-04-12 13:11:46 +08:00
const expandedProviders = reactive < Record < number , boolean > > ( { } )
const apiKeyVisible = reactive < Record < number , boolean > > ( { } )
const wsTestQuery = ref ( '' )
const wsTestLoading = ref ( false )
const wsTestResult = ref < WebSearchTestResult | null > ( null )
2026-04-12 15:59:45 +08:00
const wsTestDialogOpen = ref ( false )
function openTestDialog ( ) {
wsTestResult . value = null
wsTestDialogOpen . value = true
}
2026-04-12 13:11:46 +08:00
function toggleProviderExpand ( idx : number ) {
expandedProviders [ idx ] = ! expandedProviders [ idx ]
}
function removeWebSearchProvider ( idx : number ) {
webSearchConfig . providers . splice ( idx , 1 )
// Re-index expandedProviders and apiKeyVisible after removal
const newExpanded : Record < number , boolean > = { }
const newVisible : Record < number , boolean > = { }
for ( let i = 0 ; i < webSearchConfig . providers . length ; i ++ ) {
const oldIdx = i >= idx ? i + 1 : i
newExpanded [ i ] = expandedProviders [ oldIdx ] ? ? false
newVisible [ i ] = apiKeyVisible [ oldIdx ] ? ? false
}
Object . keys ( expandedProviders ) . forEach ( ( k ) => delete expandedProviders [ Number ( k ) ] )
Object . keys ( apiKeyVisible ) . forEach ( ( k ) => delete apiKeyVisible [ Number ( k ) ] )
Object . assign ( expandedProviders , newExpanded )
Object . assign ( apiKeyVisible , newVisible )
}
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
function addWebSearchProvider ( ) {
2026-04-12 13:11:46 +08:00
const idx = webSearchConfig . providers . length
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
webSearchConfig . providers . push ( {
type : 'brave' ,
api _key : '' ,
api _key _configured : false ,
quota _limit : DEFAULT _WEB _SEARCH _QUOTA _LIMIT ,
2026-04-12 13:11:46 +08:00
subscribed _at : null ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
proxy _id : null ,
expires _at : null ,
} as WebSearchProviderConfig )
2026-04-12 13:11:46 +08:00
expandedProviders [ idx ] = true
}
function formatSubscribedAt ( ts : number | null ) : string {
if ( ! ts ) return ''
// Use UTC to avoid timezone drift on repeated edits
const d = new Date ( ts * 1000 )
const y = d . getUTCFullYear ( )
const m = String ( d . getUTCMonth ( ) + 1 ) . padStart ( 2 , '0' )
const day = String ( d . getUTCDate ( ) ) . padStart ( 2 , '0' )
return ` ${ y } - ${ m } - ${ day } `
}
function parseSubscribedAt ( dateStr : string ) : number | null {
if ( ! dateStr ) return null
// Parse as UTC to match formatSubscribedAt
return Math . floor ( new Date ( dateStr + 'T00:00:00Z' ) . getTime ( ) / 1000 )
}
function quotaPercentage ( provider : WebSearchProviderConfig ) : number {
if ( ! provider . quota _limit || provider . quota _limit <= 0 ) return 0
return ( ( provider . quota _used ? ? 0 ) / provider . quota _limit ) * 100
}
2026-04-14 08:03:27 +08:00
async function resetWebSearchUsage ( idx : number ) {
const provider = webSearchConfig . providers [ idx ]
if ( ! provider ) return
if ( ! confirm ( t ( 'admin.settings.webSearchEmulation.resetUsageConfirm' ) ) ) return
try {
await adminAPI . settings . resetWebSearchUsage ( { provider _type : provider . type } )
provider . quota _used = 0
appStore . showSuccess ( t ( 'admin.settings.webSearchEmulation.resetUsageSuccess' ) )
} catch ( err : unknown ) {
appStore . showError ( extractApiErrorMessage ( err , t ( 'common.error' ) ) )
}
}
2026-04-12 13:11:46 +08:00
async function copyApiKey ( idx : number ) {
const key = webSearchConfig . providers [ idx ] ? . api _key
if ( ! key ) {
appStore . showError ( t ( 'admin.settings.webSearchEmulation.apiKeyPlaceholder' ) )
return
}
2026-04-12 14:43:12 +08:00
try {
await navigator . clipboard . writeText ( key )
appStore . showSuccess ( t ( 'admin.settings.webSearchEmulation.copied' ) )
} catch {
appStore . showError ( t ( 'common.error' ) )
}
2026-04-12 13:11:46 +08:00
}
async function testWebSearchProvider ( ) {
wsTestLoading . value = true
wsTestResult . value = null
try {
const query = wsTestQuery . value . trim ( ) || t ( 'admin.settings.webSearchEmulation.testDefaultQuery' )
wsTestResult . value = await adminAPI . settings . testWebSearchEmulation ( query )
} catch ( err : unknown ) {
appStore . showError ( extractApiErrorMessage ( err , t ( 'common.error' ) ) )
} finally {
wsTestLoading . value = false
}
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
}
async function loadWebSearchConfig ( ) {
try {
2026-04-12 13:11:46 +08:00
const [ resp , proxiesResp ] = await Promise . all ( [
adminAPI . settings . getWebSearchEmulationConfig ( ) ,
adminAPI . proxies . list ( ) . catch ( ( ) => ( { items : [ ] as Proxy [ ] } ) ) ,
] )
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
if ( resp ) {
webSearchConfig . enabled = resp . enabled || false
webSearchConfig . providers = resp . providers || [ ]
}
2026-04-12 13:11:46 +08:00
webSearchProxies . value = proxiesResp . items || [ ]
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
} catch ( err : unknown ) {
// 404 is expected when config hasn't been created yet; show error for other failures
const status = ( err as { status ? : number } ) ? . status
if ( status !== 404 && status !== undefined ) {
appStore . showError ( extractApiErrorMessage ( err , t ( 'common.error' ) ) )
}
}
}
async function saveWebSearchConfig ( ) : Promise < boolean > {
try {
2026-04-14 08:03:27 +08:00
for ( const p of webSearchConfig . providers ) {
const raw = p . quota _limit
if ( raw != null && Number ( raw ) !== 0 && Number ( raw ) < 1 ) {
appStore . showError ( t ( 'admin.settings.webSearchEmulation.quotaLimitMustBePositive' ) )
return false
}
}
2026-04-14 07:22:22 +08:00
const providers = webSearchConfig . providers . map ( ( p : WebSearchProviderConfig ) => ( {
... p ,
2026-04-14 08:03:27 +08:00
quota _limit : Number ( p . quota _limit ) > 0 ? Number ( p . quota _limit ) : null ,
2026-04-14 07:22:22 +08:00
} ) )
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
await adminAPI . settings . updateWebSearchEmulationConfig ( {
enabled : webSearchConfig . enabled ,
2026-04-14 07:22:22 +08:00
providers ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
} )
return true
} catch ( err : unknown ) {
appStore . showError ( extractApiErrorMessage ( err , t ( 'common.error' ) ) )
return false
}
}
2026-03-02 03:41:50 +08:00
const defaultSubscriptionGroupOptions = computed < DefaultSubscriptionGroupOption [ ] > ( ( ) =>
subscriptionGroups . value . map ( ( group ) => ( {
value : group . id ,
label : group . name ,
description : group . description ,
platform : group . platform ,
subscriptionType : group . subscription _type ,
rate : group . rate _multiplier
} ) )
)
2026-03-02 23:13:39 +08:00
const registrationEmailSuffixWhitelistSeparatorKeys = new Set ( [ ' ' , ',' , ', ' , 'Enter' , 'Tab' ] )
function removeRegistrationEmailSuffixWhitelistTag ( suffix : string ) {
registrationEmailSuffixWhitelistTags . value = registrationEmailSuffixWhitelistTags . value . filter (
( item ) => item !== suffix
)
}
function addRegistrationEmailSuffixWhitelistTag ( raw : string ) {
const suffix = normalizeRegistrationEmailSuffixDomain ( raw )
if (
! isRegistrationEmailSuffixDomainValid ( suffix ) ||
registrationEmailSuffixWhitelistTags . value . includes ( suffix )
) {
return
}
registrationEmailSuffixWhitelistTags . value = [
... registrationEmailSuffixWhitelistTags . value ,
suffix
]
}
function commitRegistrationEmailSuffixWhitelistDraft ( ) {
if ( ! registrationEmailSuffixWhitelistDraft . value ) {
return
}
addRegistrationEmailSuffixWhitelistTag ( registrationEmailSuffixWhitelistDraft . value )
registrationEmailSuffixWhitelistDraft . value = ''
}
function handleRegistrationEmailSuffixWhitelistDraftInput ( ) {
registrationEmailSuffixWhitelistDraft . value = normalizeRegistrationEmailSuffixDomain (
registrationEmailSuffixWhitelistDraft . value
)
}
function handleRegistrationEmailSuffixWhitelistDraftKeydown ( event : KeyboardEvent ) {
if ( event . isComposing ) {
return
}
if ( registrationEmailSuffixWhitelistSeparatorKeys . has ( event . key ) ) {
event . preventDefault ( )
commitRegistrationEmailSuffixWhitelistDraft ( )
return
}
if (
event . key === 'Backspace' &&
! registrationEmailSuffixWhitelistDraft . value &&
registrationEmailSuffixWhitelistTags . value . length > 0
) {
registrationEmailSuffixWhitelistTags . value . pop ( )
}
}
function handleRegistrationEmailSuffixWhitelistPaste ( event : ClipboardEvent ) {
const text = event . clipboardData ? . getData ( 'text' ) || ''
if ( ! text . trim ( ) ) {
return
}
event . preventDefault ( )
const tokens = parseRegistrationEmailSuffixWhitelistInput ( text )
for ( const token of tokens ) {
addRegistrationEmailSuffixWhitelistTag ( token )
}
}
2026-04-12 13:53:02 +08:00
// Quota notify email helpers
const addQuotaNotifyEmail = ( ) => {
if ( ! form . account _quota _notify _emails ) {
form . account _quota _notify _emails = [ ]
}
2026-04-13 00:52:42 +08:00
form . account _quota _notify _emails . push ( { email : '' , disabled : false , verified : true } )
2026-04-12 13:53:02 +08:00
}
2026-04-13 18:44:36 +08:00
const currentOrigin = typeof window !== 'undefined' ? window . location . origin : ''
2026-01-12 09:14:32 +08:00
// LinuxDo OAuth redirect URL suggestion
const linuxdoRedirectUrlSuggestion = computed ( ( ) => {
if ( typeof window === 'undefined' ) return ''
const origin =
window . location . origin || ` ${ window . location . protocol } // ${ window . location . host } `
return ` ${ origin } /api/v1/auth/oauth/linuxdo/callback `
2025-12-25 08:41:36 -08:00
} )
2025-12-18 13:50:39 +08:00
2026-01-12 09:14:32 +08:00
async function setAndCopyLinuxdoRedirectUrl ( ) {
const url = linuxdoRedirectUrlSuggestion . value
if ( ! url ) return
form . linuxdo _connect _redirect _url = url
await copyToClipboard ( url , t ( 'admin.settings.linuxdo.redirectUrlSetAndCopied' ) )
}
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
const oidcRedirectUrlSuggestion = computed ( ( ) => {
if ( typeof window === 'undefined' ) return ''
const origin =
window . location . origin || ` ${ window . location . protocol } // ${ window . location . host } `
return ` ${ origin } /api/v1/auth/oauth/oidc/callback `
} )
async function setAndCopyOIDCRedirectUrl ( ) {
const url = oidcRedirectUrlSuggestion . value
if ( ! url ) return
form . oidc _connect _redirect _url = url
await copyToClipboard ( url , t ( 'admin.settings.oidc.redirectUrlSetAndCopied' ) )
}
2026-03-02 19:37:40 +08:00
// Custom menu item management
function addMenuItem ( ) {
form . custom _menu _items . push ( {
id : '' ,
label : '' ,
icon _svg : '' ,
url : '' ,
visibility : 'user' ,
sort _order : form . custom _menu _items . length ,
} )
}
function removeMenuItem ( index : number ) {
form . custom _menu _items . splice ( index , 1 )
// Re-index sort_order
form . custom _menu _items . forEach ( ( item , i ) => {
item . sort _order = i
} )
}
function moveMenuItem ( index : number , direction : - 1 | 1 ) {
const targetIndex = index + direction
if ( targetIndex < 0 || targetIndex >= form . custom _menu _items . length ) return
const items = form . custom _menu _items
const temp = items [ index ]
items [ index ] = items [ targetIndex ]
items [ targetIndex ] = temp
// Re-index sort_order
items . forEach ( ( item , i ) => {
item . sort _order = i
} )
}
2026-03-24 10:13:28 +08:00
// Custom endpoint management
function addEndpoint ( ) {
form . custom _endpoints . push ( { name : '' , endpoint : '' , description : '' } )
}
function removeEndpoint ( index : number ) {
form . custom _endpoints . splice ( index , 1 )
}
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
function formatTablePageSizeOptions ( options : number [ ] ) : string {
return options . join ( ', ' )
}
function parseTablePageSizeOptionsInput ( raw : string ) : number [ ] | null {
const tokens = raw
. split ( ',' )
. map ( ( token ) => token . trim ( ) )
. filter ( ( token ) => token . length > 0 )
if ( tokens . length === 0 ) {
return null
}
const parsed = tokens . map ( ( token ) => Number ( token ) )
if ( parsed . some ( ( value ) => ! Number . isInteger ( value ) ) ) {
return null
}
const deduped = Array . from ( new Set ( parsed ) ) . sort ( ( a , b ) => a - b )
if (
deduped . some ( ( value ) => value < tablePageSizeMin || value > tablePageSizeMax )
) {
return null
}
return deduped
}
2025-12-18 13:50:39 +08:00
async function loadSettings ( ) {
2025-12-25 08:41:36 -08:00
loading . value = true
2026-03-21 23:36:30 +08:00
loadFailed . value = false
2025-12-18 13:50:39 +08:00
try {
2025-12-25 08:41:36 -08:00
const settings = await adminAPI . settings . getSettings ( )
2026-04-10 21:08:51 +08:00
settings . payment _load _balance _strategy = settings . payment _load _balance _strategy || 'round-robin'
// Only assign non-null values from backend (null means unconfigured, keep defaults)
for ( const [ key , value ] of Object . entries ( settings ) ) {
if ( value !== null && value !== undefined ) {
( form as Record < string , unknown > ) [ key ] = value
}
}
2026-04-20 17:39:57 +08:00
Object . assign ( authSourceDefaults , buildAuthSourceDefaultsState ( settings ) )
2026-03-12 02:42:57 +03:00
form . backend _mode _enabled = settings . backend _mode _enabled
2026-04-20 17:39:57 +08:00
form . default _subscriptions = normalizeDefaultSubscriptionSettings ( settings . default _subscriptions )
2026-04-21 00:03:27 +08:00
form . payment _visible _method _alipay _source = normalizePaymentVisibleMethodSource (
'alipay' ,
settings . payment _visible _method _alipay _source
)
form . payment _visible _method _wxpay _source = normalizePaymentVisibleMethodSource (
'wxpay' ,
settings . payment _visible _method _wxpay _source
)
2026-03-02 23:13:39 +08:00
registrationEmailSuffixWhitelistTags . value = normalizeRegistrationEmailSuffixDomains (
settings . registration _email _suffix _whitelist
)
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
tablePageSizeOptionsInput . value = formatTablePageSizeOptions (
Array . isArray ( settings . table _page _size _options ) ? settings . table _page _size _options : [ 10 , 20 , 50 , 100 ]
)
2026-03-02 23:13:39 +08:00
registrationEmailSuffixWhitelistDraft . value = ''
2026-01-02 17:40:57 +08:00
form . smtp _password = ''
2026-03-21 23:36:30 +08:00
smtpPasswordManuallyEdited . value = false
2026-01-02 17:40:57 +08:00
form . turnstile _secret _key = ''
2026-01-12 09:14:32 +08:00
form . linuxdo _connect _client _secret = ''
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
form . oidc _connect _client _secret = ''
// Load web search emulation config separately
await loadWebSearchConfig ( )
2026-04-10 21:08:51 +08:00
} catch ( error : unknown ) {
2026-03-21 23:36:30 +08:00
loadFailed . value = true
2026-04-10 21:08:51 +08:00
appStore . showError ( extractApiErrorMessage ( error , t ( 'admin.settings.failedToLoad' ) ) )
2025-12-18 13:50:39 +08:00
} finally {
2025-12-25 08:41:36 -08:00
loading . value = false
2025-12-18 13:50:39 +08:00
}
}
2026-03-02 03:41:50 +08:00
async function loadSubscriptionGroups ( ) {
try {
const groups = await adminAPI . groups . getAll ( )
subscriptionGroups . value = groups . filter (
( group ) => group . subscription _type === 'subscription' && group . status === 'active'
)
2026-04-10 21:08:51 +08:00
} catch ( _error : unknown ) {
2026-03-02 03:41:50 +08:00
subscriptionGroups . value = [ ]
}
}
2026-04-20 17:39:57 +08:00
function findNextAvailableSubscriptionGroup (
existingGroupIDs : number [ ]
) : AdminGroup | undefined {
const existing = new Set ( existingGroupIDs )
return subscriptionGroups . value . find ( ( group ) => ! existing . has ( group . id ) )
}
2026-03-02 03:41:50 +08:00
function addDefaultSubscription ( ) {
if ( subscriptionGroups . value . length === 0 ) return
2026-04-20 17:39:57 +08:00
const candidate = findNextAvailableSubscriptionGroup (
form . default _subscriptions . map ( ( item ) => item . group _id )
)
2026-03-02 03:41:50 +08:00
if ( ! candidate ) return
form . default _subscriptions . push ( {
group _id : candidate . id ,
validity _days : 30
} )
}
function removeDefaultSubscription ( index : number ) {
form . default _subscriptions . splice ( index , 1 )
}
2026-04-20 17:39:57 +08:00
function addAuthSourceDefaultSubscription ( source : AuthSourceType ) {
if ( subscriptionGroups . value . length === 0 ) return
const candidate = findNextAvailableSubscriptionGroup (
authSourceDefaults [ source ] . subscriptions . map ( ( item ) => item . group _id )
)
if ( ! candidate ) return
authSourceDefaults [ source ] . subscriptions . push ( {
group _id : candidate . id ,
validity _days : 30
} )
}
function removeAuthSourceDefaultSubscription ( source : AuthSourceType , index : number ) {
authSourceDefaults [ source ] . subscriptions . splice ( index , 1 )
}
function findDuplicateDefaultSubscription (
subscriptions : DefaultSubscriptionSetting [ ]
) : DefaultSubscriptionSetting | undefined {
const seenGroupIDs = new Set < number > ( )
return subscriptions . find ( ( item ) => {
if ( seenGroupIDs . has ( item . group _id ) ) {
return true
}
seenGroupIDs . add ( item . group _id )
return false
} )
}
2025-12-18 13:50:39 +08:00
async function saveSettings ( ) {
2025-12-25 08:41:36 -08:00
saving . value = true
2025-12-18 13:50:39 +08:00
try {
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
const normalizedTableDefaultPageSize = Math . floor ( Number ( form . table _default _page _size ) )
if (
! Number . isInteger ( normalizedTableDefaultPageSize ) ||
normalizedTableDefaultPageSize < tablePageSizeMin ||
normalizedTableDefaultPageSize > tablePageSizeMax
) {
appStore . showError (
t ( 'admin.settings.site.tableDefaultPageSizeRangeError' , {
min : tablePageSizeMin ,
max : tablePageSizeMax
} )
)
return
}
const normalizedTablePageSizeOptions = parseTablePageSizeOptionsInput (
tablePageSizeOptionsInput . value
)
if ( ! normalizedTablePageSizeOptions ) {
appStore . showError (
t ( 'admin.settings.site.tablePageSizeOptionsFormatError' , {
min : tablePageSizeMin ,
max : tablePageSizeMax
} )
)
return
}
form . table _default _page _size = normalizedTableDefaultPageSize
form . table _page _size _options = normalizedTablePageSizeOptions
2026-04-20 17:39:57 +08:00
const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings (
form . default _subscriptions
)
const duplicateDefaultSubscription = findDuplicateDefaultSubscription (
normalizedDefaultSubscriptions
)
2026-03-02 03:41:50 +08:00
if ( duplicateDefaultSubscription ) {
appStore . showError (
t ( 'admin.settings.defaults.defaultSubscriptionsDuplicate' , {
groupId : duplicateDefaultSubscription . group _id
} )
)
return
}
2026-04-20 17:39:57 +08:00
for ( const authSource of authSourceDefaultsMeta . value ) {
authSourceDefaults [ authSource . source ] . subscriptions = normalizeDefaultSubscriptionSettings (
authSourceDefaults [ authSource . source ] . subscriptions
)
const duplicate = findDuplicateDefaultSubscription (
authSourceDefaults [ authSource . source ] . subscriptions
)
if ( duplicate ) {
appStore . showError (
` ${ authSource . title } : ${ t ( 'admin.settings.defaults.defaultSubscriptionsDuplicate' , {
groupId : duplicate . group _id
} ) } `
)
return
}
}
2026-04-21 00:41:29 +08:00
if ( ! validatePaymentVisibleMethodSelections ( ) ) {
return
}
2026-03-21 15:03:18 +08:00
// Validate URL fields — novalidate disables browser-native checks, so we validate here
const isValidHttpUrl = ( url : string ) : boolean => {
if ( ! url ) return true
try {
const u = new URL ( url )
return u . protocol === 'http:' || u . protocol === 'https:'
} catch {
return false
}
}
// Optional URL fields: auto-clear invalid values so they don't cause backend 400 errors
if ( ! isValidHttpUrl ( form . frontend _url ) ) form . frontend _url = ''
if ( ! isValidHttpUrl ( form . doc _url ) ) form . doc _url = ''
2026-01-02 17:40:57 +08:00
const payload : UpdateSettingsRequest = {
registration _enabled : form . registration _enabled ,
email _verify _enabled : form . email _verify _enabled ,
2026-03-02 23:13:39 +08:00
registration _email _suffix _whitelist : registrationEmailSuffixWhitelistTags . value . map (
( suffix ) => ` @ ${ suffix } `
) ,
2026-02-02 22:13:50 +08:00
promo _code _enabled : form . promo _code _enabled ,
2026-01-29 16:29:59 +08:00
invitation _code _enabled : form . invitation _code _enabled ,
2026-02-02 22:13:50 +08:00
password _reset _enabled : form . password _reset _enabled ,
totp _enabled : form . totp _enabled ,
2026-01-02 17:40:57 +08:00
default _balance : form . default _balance ,
default _concurrency : form . default _concurrency ,
2026-03-02 03:41:50 +08:00
default _subscriptions : normalizedDefaultSubscriptions ,
2026-04-20 17:39:57 +08:00
force _email _on _third _party _signup : form . force _email _on _third _party _signup ,
2026-01-02 17:40:57 +08:00
site _name : form . site _name ,
site _logo : form . site _logo ,
site _subtitle : form . site _subtitle ,
api _base _url : form . api _base _url ,
contact _info : form . contact _info ,
doc _url : form . doc _url ,
2026-01-10 18:37:44 +08:00
home _content : form . home _content ,
2026-03-12 02:42:57 +03:00
backend _mode _enabled : form . backend _mode _enabled ,
2026-02-02 22:13:50 +08:00
hide _ccs _import _button : form . hide _ccs _import _button ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
table _default _page _size : form . table _default _page _size ,
table _page _size _options : form . table _page _size _options ,
2026-03-02 19:37:40 +08:00
custom _menu _items : form . custom _menu _items ,
2026-03-24 10:13:28 +08:00
custom _endpoints : form . custom _endpoints ,
2026-03-15 17:52:29 +08:00
frontend _url : form . frontend _url ,
2026-01-02 17:40:57 +08:00
smtp _host : form . smtp _host ,
smtp _port : form . smtp _port ,
smtp _username : form . smtp _username ,
smtp _password : form . smtp _password || undefined ,
smtp _from _email : form . smtp _from _email ,
smtp _from _name : form . smtp _from _name ,
smtp _use _tls : form . smtp _use _tls ,
turnstile _enabled : form . turnstile _enabled ,
turnstile _site _key : form . turnstile _site _key ,
2026-01-09 21:00:04 +08:00
turnstile _secret _key : form . turnstile _secret _key || undefined ,
2026-01-12 09:14:32 +08:00
linuxdo _connect _enabled : form . linuxdo _connect _enabled ,
linuxdo _connect _client _id : form . linuxdo _connect _client _id ,
linuxdo _connect _client _secret : form . linuxdo _connect _client _secret || undefined ,
linuxdo _connect _redirect _url : form . linuxdo _connect _redirect _url ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
oidc _connect _enabled : form . oidc _connect _enabled ,
oidc _connect _provider _name : form . oidc _connect _provider _name ,
oidc _connect _client _id : form . oidc _connect _client _id ,
oidc _connect _client _secret : form . oidc _connect _client _secret || undefined ,
oidc _connect _issuer _url : form . oidc _connect _issuer _url ,
oidc _connect _discovery _url : form . oidc _connect _discovery _url ,
oidc _connect _authorize _url : form . oidc _connect _authorize _url ,
oidc _connect _token _url : form . oidc _connect _token _url ,
oidc _connect _userinfo _url : form . oidc _connect _userinfo _url ,
oidc _connect _jwks _url : form . oidc _connect _jwks _url ,
oidc _connect _scopes : form . oidc _connect _scopes ,
oidc _connect _redirect _url : form . oidc _connect _redirect _url ,
oidc _connect _frontend _redirect _url : form . oidc _connect _frontend _redirect _url ,
oidc _connect _token _auth _method : form . oidc _connect _token _auth _method ,
2026-04-20 16:23:42 +08:00
oidc _connect _use _pkce : true ,
oidc _connect _validate _id _token : true ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
oidc _connect _allowed _signing _algs : form . oidc _connect _allowed _signing _algs ,
oidc _connect _clock _skew _seconds : form . oidc _connect _clock _skew _seconds ,
oidc _connect _require _email _verified : form . oidc _connect _require _email _verified ,
oidc _connect _userinfo _email _path : form . oidc _connect _userinfo _email _path ,
oidc _connect _userinfo _id _path : form . oidc _connect _userinfo _id _path ,
oidc _connect _userinfo _username _path : form . oidc _connect _userinfo _username _path ,
2026-01-09 21:00:04 +08:00
enable _model _fallback : form . enable _model _fallback ,
fallback _model _anthropic : form . fallback _model _anthropic ,
fallback _model _openai : form . fallback _model _openai ,
fallback _model _gemini : form . fallback _model _gemini ,
fallback _model _antigravity : form . fallback _model _antigravity ,
enable _identity _patch : form . enable _identity _patch ,
2026-03-01 15:35:46 +08:00
identity _patch _prompt : form . identity _patch _prompt ,
2026-03-03 19:56:27 +08:00
min _claude _code _version : form . min _claude _code _version ,
2026-03-20 09:10:01 +08:00
max _claude _code _version : form . max _claude _code _version ,
2026-03-26 10:22:03 +08:00
allow _ungrouped _key _scheduling : form . allow _ungrouped _key _scheduling ,
enable _fingerprint _unification : form . enable _fingerprint _unification ,
2026-04-08 16:11:19 +08:00
enable _metadata _passthrough : form . enable _metadata _passthrough ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
enable _cch _signing : form . enable _cch _signing ,
2026-04-10 21:08:51 +08:00
// Payment configuration
payment _enabled : form . payment _enabled ,
payment _min _amount : Number ( form . payment _min _amount ) || 0 ,
payment _max _amount : Number ( form . payment _max _amount ) || 0 ,
payment _daily _limit : Number ( form . payment _daily _limit ) || 0 ,
payment _max _pending _orders : Number ( form . payment _max _pending _orders ) || 0 ,
payment _order _timeout _minutes : Number ( form . payment _order _timeout _minutes ) || 0 ,
payment _balance _disabled : form . payment _balance _disabled ,
2026-04-15 00:14:57 +08:00
payment _balance _recharge _multiplier : Number ( form . payment _balance _recharge _multiplier ) || 1 ,
2026-04-15 00:41:33 +08:00
payment _recharge _fee _rate : Number ( form . payment _recharge _fee _rate ) || 0 ,
2026-04-10 21:08:51 +08:00
payment _enabled _types : form . payment _enabled _types ,
payment _load _balance _strategy : form . payment _load _balance _strategy ,
payment _product _name _prefix : form . payment _product _name _prefix ,
payment _product _name _suffix : form . payment _product _name _suffix ,
payment _help _image _url : form . payment _help _image _url ,
payment _help _text : form . payment _help _text ,
payment _cancel _rate _limit _enabled : form . payment _cancel _rate _limit _enabled ,
payment _cancel _rate _limit _max : Number ( form . payment _cancel _rate _limit _max ) || 10 ,
payment _cancel _rate _limit _window : Number ( form . payment _cancel _rate _limit _window ) || 1 ,
payment _cancel _rate _limit _unit : form . payment _cancel _rate _limit _unit ,
payment _cancel _rate _limit _window _mode : form . payment _cancel _rate _limit _window _mode ,
2026-04-21 00:03:27 +08:00
payment _visible _method _alipay _source : normalizePaymentVisibleMethodSource (
'alipay' ,
form . payment _visible _method _alipay _source
) ,
payment _visible _method _wxpay _source : normalizePaymentVisibleMethodSource (
'wxpay' ,
form . payment _visible _method _wxpay _source
) ,
2026-04-20 17:39:57 +08:00
payment _visible _method _alipay _enabled : form . payment _visible _method _alipay _enabled ,
payment _visible _method _wxpay _enabled : form . payment _visible _method _wxpay _enabled ,
openai _advanced _scheduler _enabled : form . openai _advanced _scheduler _enabled ,
2026-04-12 13:53:02 +08:00
// Balance & quota notification
balance _low _notify _enabled : form . balance _low _notify _enabled ,
balance _low _notify _threshold : Number ( form . balance _low _notify _threshold ) || 0 ,
2026-04-13 18:52:02 +08:00
balance _low _notify _recharge _url : ( form . balance _low _notify _recharge _url = form . balance _low _notify _recharge _url || currentOrigin ) ,
2026-04-12 17:49:58 +08:00
account _quota _notify _enabled : form . account _quota _notify _enabled ,
2026-04-13 00:52:42 +08:00
account _quota _notify _emails : ( form . account _quota _notify _emails || [ ] ) . filter ( ( e ) => e . email . trim ( ) !== '' ) ,
2026-01-02 17:40:57 +08:00
}
2026-04-10 21:08:51 +08:00
2026-04-20 17:39:57 +08:00
appendAuthSourceDefaultsToUpdateRequest ( payload , authSourceDefaults )
2026-01-02 17:40:57 +08:00
const updated = await adminAPI . settings . updateSettings ( payload )
2026-04-10 21:08:51 +08:00
for ( const [ key , value ] of Object . entries ( updated ) ) {
if ( value !== null && value !== undefined ) {
( form as Record < string , unknown > ) [ key ] = value
}
}
2026-04-20 17:39:57 +08:00
Object . assign ( authSourceDefaults , buildAuthSourceDefaultsState ( updated ) )
2026-04-21 00:03:27 +08:00
form . payment _visible _method _alipay _source = normalizePaymentVisibleMethodSource (
'alipay' ,
updated . payment _visible _method _alipay _source
)
form . payment _visible _method _wxpay _source = normalizePaymentVisibleMethodSource (
'wxpay' ,
updated . payment _visible _method _wxpay _source
)
2026-03-02 23:13:39 +08:00
registrationEmailSuffixWhitelistTags . value = normalizeRegistrationEmailSuffixDomains (
updated . registration _email _suffix _whitelist
)
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
tablePageSizeOptionsInput . value = formatTablePageSizeOptions (
Array . isArray ( updated . table _page _size _options ) ? updated . table _page _size _options : [ 10 , 20 , 50 , 100 ]
)
2026-03-02 23:13:39 +08:00
registrationEmailSuffixWhitelistDraft . value = ''
2026-01-02 17:40:57 +08:00
form . smtp _password = ''
2026-03-21 23:36:30 +08:00
smtpPasswordManuallyEdited . value = false
2026-01-02 17:40:57 +08:00
form . turnstile _secret _key = ''
2026-01-12 09:14:32 +08:00
form . linuxdo _connect _client _secret = ''
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
form . oidc _connect _client _secret = ''
// Save web search emulation config separately (errors handled internally)
const wsOk = await saveWebSearchConfig ( )
2026-03-04 10:44:28 +08:00
// Refresh cached settings so sidebar/header update immediately
2025-12-25 08:41:36 -08:00
await appStore . fetchPublicSettings ( true )
2026-03-04 10:44:28 +08:00
await adminSettingsStore . fetch ( true )
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
if ( wsOk ) {
appStore . showSuccess ( t ( 'admin.settings.settingsSaved' ) )
}
2026-04-10 21:08:51 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , t ( 'admin.settings.failedToSave' ) ) )
2025-12-18 13:50:39 +08:00
} finally {
2025-12-25 08:41:36 -08:00
saving . value = false
2025-12-18 13:50:39 +08:00
}
}
async function testSmtpConnection ( ) {
2025-12-25 08:41:36 -08:00
testingSmtp . value = true
2025-12-18 13:50:39 +08:00
try {
2026-03-21 23:36:30 +08:00
const smtpPasswordForTest = smtpPasswordManuallyEdited . value ? form . smtp _password : ''
2025-12-18 13:50:39 +08:00
const result = await adminAPI . settings . testSmtpConnection ( {
smtp _host : form . smtp _host ,
smtp _port : form . smtp _port ,
smtp _username : form . smtp _username ,
2026-03-21 23:36:30 +08:00
smtp _password : smtpPasswordForTest ,
2025-12-25 08:41:36 -08:00
smtp _use _tls : form . smtp _use _tls
} )
2025-12-18 13:50:39 +08:00
// API returns { message: "..." } on success, errors are thrown as exceptions
2025-12-25 08:41:36 -08:00
appStore . showSuccess ( result . message || t ( 'admin.settings.smtpConnectionSuccess' ) )
2026-04-10 21:08:51 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , t ( 'admin.settings.failedToTestSmtp' ) ) )
2025-12-18 13:50:39 +08:00
} finally {
2025-12-25 08:41:36 -08:00
testingSmtp . value = false
2025-12-18 13:50:39 +08:00
}
}
async function sendTestEmail ( ) {
if ( ! testEmailAddress . value ) {
2025-12-25 08:41:36 -08:00
appStore . showError ( t ( 'admin.settings.testEmail.enterRecipientHint' ) )
return
2025-12-18 13:50:39 +08:00
}
2025-12-25 08:41:36 -08:00
sendingTestEmail . value = true
2025-12-18 13:50:39 +08:00
try {
2026-03-21 23:36:30 +08:00
const smtpPasswordForSend = smtpPasswordManuallyEdited . value ? form . smtp _password : ''
2025-12-18 13:50:39 +08:00
const result = await adminAPI . settings . sendTestEmail ( {
email : testEmailAddress . value ,
smtp _host : form . smtp _host ,
smtp _port : form . smtp _port ,
smtp _username : form . smtp _username ,
2026-03-21 23:36:30 +08:00
smtp _password : smtpPasswordForSend ,
2025-12-18 13:50:39 +08:00
smtp _from _email : form . smtp _from _email ,
smtp _from _name : form . smtp _from _name ,
2025-12-25 08:41:36 -08:00
smtp _use _tls : form . smtp _use _tls
} )
2025-12-18 13:50:39 +08:00
// API returns { message: "..." } on success, errors are thrown as exceptions
2025-12-25 08:41:36 -08:00
appStore . showSuccess ( result . message || t ( 'admin.settings.testEmailSent' ) )
2026-04-10 21:08:51 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , t ( 'admin.settings.failedToSendTestEmail' ) ) )
2025-12-18 13:50:39 +08:00
} finally {
2025-12-25 08:41:36 -08:00
sendingTestEmail . value = false
2025-12-18 13:50:39 +08:00
}
}
2025-12-20 15:11:43 +08:00
// Admin API Key 方法
async function loadAdminApiKey ( ) {
2025-12-25 08:41:36 -08:00
adminApiKeyLoading . value = true
2025-12-20 15:11:43 +08:00
try {
2025-12-25 08:41:36 -08:00
const status = await adminAPI . settings . getAdminApiKey ( )
adminApiKeyExists . value = status . exists
adminApiKeyMasked . value = status . masked _key
2026-04-10 21:08:51 +08:00
} catch ( _error : unknown ) {
// Silent fail - admin API key status is non-critical
2025-12-20 15:11:43 +08:00
} finally {
2025-12-25 08:41:36 -08:00
adminApiKeyLoading . value = false
2025-12-20 15:11:43 +08:00
}
}
async function createAdminApiKey ( ) {
2025-12-25 08:41:36 -08:00
adminApiKeyOperating . value = true
2025-12-20 15:11:43 +08:00
try {
2025-12-25 08:41:36 -08:00
const result = await adminAPI . settings . regenerateAdminApiKey ( )
newAdminApiKey . value = result . key
adminApiKeyExists . value = true
adminApiKeyMasked . value = result . key . substring ( 0 , 10 ) + '...' + result . key . slice ( - 4 )
appStore . showSuccess ( t ( 'admin.settings.adminApiKey.keyGenerated' ) )
2026-04-10 21:08:51 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , t ( 'common.error' ) ) )
2025-12-20 15:11:43 +08:00
} finally {
2025-12-25 08:41:36 -08:00
adminApiKeyOperating . value = false
2025-12-20 15:11:43 +08:00
}
}
async function regenerateAdminApiKey ( ) {
2025-12-25 08:41:36 -08:00
if ( ! confirm ( t ( 'admin.settings.adminApiKey.regenerateConfirm' ) ) ) return
await createAdminApiKey ( )
2025-12-20 15:11:43 +08:00
}
async function deleteAdminApiKey ( ) {
2025-12-25 08:41:36 -08:00
if ( ! confirm ( t ( 'admin.settings.adminApiKey.deleteConfirm' ) ) ) return
adminApiKeyOperating . value = true
2025-12-20 15:11:43 +08:00
try {
2025-12-25 08:41:36 -08:00
await adminAPI . settings . deleteAdminApiKey ( )
adminApiKeyExists . value = false
adminApiKeyMasked . value = ''
newAdminApiKey . value = ''
appStore . showSuccess ( t ( 'admin.settings.adminApiKey.keyDeleted' ) )
2026-04-10 21:08:51 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , t ( 'common.error' ) ) )
2025-12-20 15:11:43 +08:00
} finally {
2025-12-25 08:41:36 -08:00
adminApiKeyOperating . value = false
2025-12-20 15:11:43 +08:00
}
}
function copyNewKey ( ) {
2025-12-25 08:41:36 -08:00
navigator . clipboard
. writeText ( newAdminApiKey . value )
. then ( ( ) => {
appStore . showSuccess ( t ( 'admin.settings.adminApiKey.keyCopied' ) )
} )
. catch ( ( ) => {
appStore . showError ( t ( 'common.copyFailed' ) )
} )
2025-12-20 15:11:43 +08:00
}
2026-03-18 16:22:19 +08:00
// Overload Cooldown 方法
async function loadOverloadCooldownSettings ( ) {
overloadCooldownLoading . value = true
try {
const settings = await adminAPI . settings . getOverloadCooldownSettings ( )
Object . assign ( overloadCooldownForm , settings )
2026-04-10 21:08:51 +08:00
} catch ( _error : unknown ) {
// Silent fail - settings will use defaults
2026-03-18 16:22:19 +08:00
} finally {
overloadCooldownLoading . value = false
}
}
async function saveOverloadCooldownSettings ( ) {
overloadCooldownSaving . value = true
try {
const updated = await adminAPI . settings . updateOverloadCooldownSettings ( {
enabled : overloadCooldownForm . enabled ,
cooldown _minutes : overloadCooldownForm . cooldown _minutes
} )
Object . assign ( overloadCooldownForm , updated )
appStore . showSuccess ( t ( 'admin.settings.overloadCooldown.saved' ) )
2026-04-10 21:08:51 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , t ( 'admin.settings.overloadCooldown.saveFailed' ) ) )
2026-03-18 16:22:19 +08:00
} finally {
overloadCooldownSaving . value = false
}
}
2026-01-11 21:54:52 -08:00
// Stream Timeout 方法
async function loadStreamTimeoutSettings ( ) {
streamTimeoutLoading . value = true
try {
const settings = await adminAPI . settings . getStreamTimeoutSettings ( )
Object . assign ( streamTimeoutForm , settings )
2026-04-10 21:08:51 +08:00
} catch ( _error : unknown ) {
// Silent fail - settings will use defaults
2026-01-11 21:54:52 -08:00
} finally {
streamTimeoutLoading . value = false
}
}
async function saveStreamTimeoutSettings ( ) {
streamTimeoutSaving . value = true
try {
const updated = await adminAPI . settings . updateStreamTimeoutSettings ( {
enabled : streamTimeoutForm . enabled ,
action : streamTimeoutForm . action ,
temp _unsched _minutes : streamTimeoutForm . temp _unsched _minutes ,
threshold _count : streamTimeoutForm . threshold _count ,
threshold _window _minutes : streamTimeoutForm . threshold _window _minutes
} )
Object . assign ( streamTimeoutForm , updated )
appStore . showSuccess ( t ( 'admin.settings.streamTimeout.saved' ) )
2026-04-10 21:08:51 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , t ( 'admin.settings.streamTimeout.saveFailed' ) ) )
2026-01-11 21:54:52 -08:00
} finally {
streamTimeoutSaving . value = false
}
}
2026-03-07 21:45:18 +08:00
// Rectifier 方法
async function loadRectifierSettings ( ) {
rectifierLoading . value = true
try {
const settings = await adminAPI . settings . getRectifierSettings ( )
Object . assign ( rectifierForm , settings )
2026-03-26 16:43:38 +08:00
// 确保 patterns 是数组(旧数据可能为 null)
if ( ! Array . isArray ( rectifierForm . apikey _signature _patterns ) ) {
rectifierForm . apikey _signature _patterns = [ ]
}
2026-04-10 21:08:51 +08:00
} catch ( _error : unknown ) {
// Silent fail - settings will use defaults
2026-03-07 21:45:18 +08:00
} finally {
rectifierLoading . value = false
}
}
async function saveRectifierSettings ( ) {
rectifierSaving . value = true
try {
const updated = await adminAPI . settings . updateRectifierSettings ( {
enabled : rectifierForm . enabled ,
thinking _signature _enabled : rectifierForm . thinking _signature _enabled ,
2026-03-26 16:43:38 +08:00
thinking _budget _enabled : rectifierForm . thinking _budget _enabled ,
apikey _signature _enabled : rectifierForm . apikey _signature _enabled ,
apikey _signature _patterns : rectifierForm . apikey _signature _patterns . filter (
( p ) => p . trim ( ) !== ''
)
2026-03-07 21:45:18 +08:00
} )
Object . assign ( rectifierForm , updated )
2026-03-26 16:43:38 +08:00
if ( ! Array . isArray ( rectifierForm . apikey _signature _patterns ) ) {
rectifierForm . apikey _signature _patterns = [ ]
}
2026-03-07 21:45:18 +08:00
appStore . showSuccess ( t ( 'admin.settings.rectifier.saved' ) )
2026-04-10 21:08:51 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , t ( 'admin.settings.rectifier.saveFailed' ) ) )
2026-03-07 21:45:18 +08:00
} finally {
rectifierSaving . value = false
}
}
2026-03-10 11:14:17 +08:00
const betaPolicyActionOptions = computed ( ( ) => [
{ value : 'pass' , label : t ( 'admin.settings.betaPolicy.actionPass' ) } ,
{ value : 'filter' , label : t ( 'admin.settings.betaPolicy.actionFilter' ) } ,
{ value : 'block' , label : t ( 'admin.settings.betaPolicy.actionBlock' ) }
] )
const betaPolicyScopeOptions = computed ( ( ) => [
{ value : 'all' , label : t ( 'admin.settings.betaPolicy.scopeAll' ) } ,
{ value : 'oauth' , label : t ( 'admin.settings.betaPolicy.scopeOAuth' ) } ,
2026-03-14 17:13:30 +08:00
{ value : 'apikey' , label : t ( 'admin.settings.betaPolicy.scopeAPIKey' ) } ,
{ value : 'bedrock' , label : t ( 'admin.settings.betaPolicy.scopeBedrock' ) }
2026-03-10 11:14:17 +08:00
] )
// Beta Policy 方法
const betaDisplayNames : Record < string , string > = {
'fast-mode-2026-02-01' : 'Fast Mode' ,
'context-1m-2025-08-07' : 'Context 1M'
}
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
// 快捷预设:按 beta_token 定义预设方案
const betaPresets : Record < string , Array < {
label : string
description : string
action : 'pass' | 'filter' | 'block'
model _whitelist : string [ ]
fallback _action : 'pass' | 'filter' | 'block'
} >> = {
'context-1m-2025-08-07' : [
{
label : t ( 'admin.settings.betaPolicy.presetOpusOnly' ) ,
description : t ( 'admin.settings.betaPolicy.presetOpusOnlyDesc' ) ,
action : 'pass' ,
model _whitelist : [ 'claude-opus-4-6' ] ,
fallback _action : 'filter' ,
} ,
] ,
}
// 常用模型模式(具体 ID + 通配符示例)
const commonModelPatterns = [ 'claude-opus-4-6' , 'claude-sonnet-4-6' , 'claude-opus-*' , 'claude-sonnet-*' ]
2026-03-10 11:14:17 +08:00
function getBetaDisplayName ( token : string ) : string {
return betaDisplayNames [ token ] || token
}
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
function applyBetaPreset (
rule : ( typeof betaPolicyForm . rules ) [ number ] ,
preset : { action : 'pass' | 'filter' | 'block' ; model _whitelist : string [ ] ; fallback _action : 'pass' | 'filter' | 'block' }
) {
rule . action = preset . action
rule . model _whitelist = [ ... preset . model _whitelist ]
rule . fallback _action = preset . fallback _action
}
function addQuickPattern ( rule : ( typeof betaPolicyForm . rules ) [ number ] , pattern : string ) {
if ( ! rule . model _whitelist ) rule . model _whitelist = [ ]
if ( ! rule . model _whitelist . includes ( pattern ) ) {
rule . model _whitelist . push ( pattern )
}
}
2026-03-10 11:14:17 +08:00
async function loadBetaPolicySettings ( ) {
betaPolicyLoading . value = true
try {
const settings = await adminAPI . settings . getBetaPolicySettings ( )
betaPolicyForm . rules = settings . rules
2026-04-10 21:08:51 +08:00
} catch ( _error : unknown ) {
// Silent fail - settings will use defaults
2026-03-10 11:14:17 +08:00
} finally {
betaPolicyLoading . value = false
}
}
async function saveBetaPolicySettings ( ) {
betaPolicySaving . value = true
try {
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
// Clean up empty patterns before saving
const cleanedRules = betaPolicyForm . rules . map ( rule => {
const whitelist = rule . model _whitelist ? . filter ( p => p . trim ( ) !== '' )
const hasWhitelist = whitelist && whitelist . length > 0
return {
beta _token : rule . beta _token ,
action : rule . action ,
scope : rule . scope ,
error _message : rule . error _message ,
model _whitelist : hasWhitelist ? whitelist : undefined ,
fallback _action : hasWhitelist ? ( rule . fallback _action || 'pass' ) : undefined ,
fallback _error _message : hasWhitelist && rule . fallback _action === 'block' ? rule . fallback _error _message : undefined ,
}
} )
2026-03-10 11:14:17 +08:00
const updated = await adminAPI . settings . updateBetaPolicySettings ( {
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
rules : cleanedRules
2026-03-10 11:14:17 +08:00
} )
betaPolicyForm . rules = updated . rules
appStore . showSuccess ( t ( 'admin.settings.betaPolicy.saved' ) )
2026-04-10 21:08:51 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , t ( 'admin.settings.betaPolicy.saveFailed' ) ) )
2026-03-10 11:14:17 +08:00
} finally {
betaPolicySaving . value = false
}
}
2026-04-10 21:08:51 +08:00
// ==================== Provider Management ====================
const allPaymentTypes = computed ( ( ) => [
{ value : 'easypay' , label : t ( 'payment.methods.easypay' ) } ,
{ value : 'alipay' , label : t ( 'payment.methods.alipay' ) } ,
{ value : 'wxpay' , label : t ( 'payment.methods.wxpay' ) } ,
{ value : 'stripe' , label : t ( 'payment.methods.stripe' ) } ,
] )
function isPaymentTypeEnabled ( type : string ) : boolean {
return form . payment _enabled _types . includes ( type )
}
const hasAnyPaymentTypeEnabled = computed ( ( ) => form . payment _enabled _types . length > 0 )
function togglePaymentType ( type : string ) {
if ( form . payment _enabled _types . includes ( type ) ) {
form . payment _enabled _types = form . payment _enabled _types . filter ( t => t !== type )
// Disable all provider instances matching this type
disableProvidersByType ( type )
} else {
form . payment _enabled _types = [ ... form . payment _enabled _types , type ]
}
}
async function disableProvidersByType ( type : string ) {
const matching = providers . value . filter ( p => p . provider _key === type && p . enabled )
for ( const p of matching ) {
try {
await adminAPI . payment . updateProvider ( p . id , { enabled : false } )
p . enabled = false
} catch ( err : unknown ) {
slog ( 'disable provider failed' , p . id , err )
}
}
}
function slog ( ... args : unknown [ ] ) { console . warn ( '[payment]' , ... args ) }
const providersLoading = ref ( false )
const providerSaving = ref ( false )
const providers = ref < ProviderInstance [ ] > ( [ ] )
const showProviderDialog = ref ( false )
const showDeleteProviderDialog = ref ( false )
const editingProvider = ref < ProviderInstance | null > ( null )
const deletingProviderId = ref < number | null > ( null )
const providerDialogRef = ref < InstanceType < typeof PaymentProviderDialog > | null > ( null )
const providerKeyOptions = computed ( ( ) => [
{ value : 'easypay' , label : t ( 'admin.settings.payment.providerEasypay' ) } ,
{ value : 'alipay' , label : t ( 'admin.settings.payment.providerAlipay' ) } ,
{ value : 'wxpay' , label : t ( 'admin.settings.payment.providerWxpay' ) } ,
{ value : 'stripe' , label : t ( 'admin.settings.payment.providerStripe' ) } ,
] )
const enabledProviderKeyOptions = computed ( ( ) => {
const enabled = form . payment _enabled _types
return providerKeyOptions . value . filter ( opt => enabled . includes ( opt . value ) )
} )
const loadBalanceOptions = computed ( ( ) => [
{ value : 'round-robin' , label : t ( 'admin.settings.payment.strategyRoundRobin' ) } ,
{ value : 'least-amount' , label : t ( 'admin.settings.payment.strategyLeastAmount' ) } ,
] )
const cancelRateLimitUnitOptions = computed ( ( ) => [
{ value : 'minute' , label : t ( 'admin.settings.payment.cancelRateLimitUnitMinute' ) } ,
{ value : 'hour' , label : t ( 'admin.settings.payment.cancelRateLimitUnitHour' ) } ,
{ value : 'day' , label : t ( 'admin.settings.payment.cancelRateLimitUnitDay' ) } ,
] )
const cancelRateLimitModeOptions = computed ( ( ) => [
{ value : 'rolling' , label : t ( 'admin.settings.payment.cancelRateLimitWindowModeRolling' ) } ,
{ value : 'fixed' , label : t ( 'admin.settings.payment.cancelRateLimitWindowModeFixed' ) } ,
] )
const paymentErrorMap = computed ( ( ) => ( {
PENDING _ORDERS : t ( 'payment.errors.PENDING_ORDERS' ) ,
} ) )
async function loadProviders ( ) {
providersLoading . value = true
try { const res = await adminAPI . payment . getProviders ( ) ; providers . value = res . data || [ ] }
catch ( err : unknown ) { appStore . showError ( extractApiErrorMessage ( err , t ( 'common.error' ) ) ) }
finally { providersLoading . value = false }
}
function openCreateProvider ( ) {
editingProvider . value = null
providerDialogRef . value ? . reset ( enabledProviderKeyOptions . value [ 0 ] ? . value || 'easypay' )
showProviderDialog . value = true
}
function openEditProvider ( provider : ProviderInstance ) {
editingProvider . value = provider
providerDialogRef . value ? . loadProvider ( provider )
showProviderDialog . value = true
}
async function handleSaveProvider ( payload : Partial < ProviderInstance > ) {
providerSaving . value = true
try {
if ( editingProvider . value ) {
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
await adminAPI . payment . updateProvider ( editingProvider . value . id , payload )
2026-04-10 21:08:51 +08:00
} else {
await adminAPI . payment . createProvider ( payload )
}
showProviderDialog . value = false
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
// Reload full list (API returns decrypted/formatted data with correct sort order)
await loadProviders ( )
// Auto-save settings so provider changes take effect immediately
await saveSettings ( )
2026-04-10 21:08:51 +08:00
} catch ( err : unknown ) {
appStore . showError ( extractApiErrorMessage ( err , t ( 'common.error' ) , paymentErrorMap . value ) )
} finally {
providerSaving . value = false
}
}
2026-04-14 16:26:46 +08:00
async function handleToggleField ( provider : ProviderInstance , field : 'enabled' | 'refund_enabled' | 'allow_user_refund' ) {
let newValue : boolean
if ( field === 'enabled' ) newValue = ! provider . enabled
else if ( field === 'refund_enabled' ) newValue = ! provider . refund _enabled
else newValue = ! provider . allow _user _refund
2026-04-14 19:29:37 +08:00
const payload : Record < string , boolean > = { [ field ] : newValue }
// Cascade: turning off refund_enabled also turns off allow_user_refund
if ( field === 'refund_enabled' && ! newValue ) {
payload . allow _user _refund = false
}
2026-04-10 21:08:51 +08:00
try {
2026-04-14 16:26:46 +08:00
await adminAPI . payment . updateProvider ( provider . id , payload )
2026-04-10 21:08:51 +08:00
if ( field === 'enabled' ) provider . enabled = newValue
2026-04-14 16:26:46 +08:00
else if ( field === 'refund_enabled' ) {
provider . refund _enabled = newValue
if ( ! newValue ) provider . allow _user _refund = false
} else {
provider . allow _user _refund = newValue
}
2026-04-10 21:08:51 +08:00
} catch ( err : unknown ) { appStore . showError ( extractApiErrorMessage ( err , t ( 'common.error' ) , paymentErrorMap . value ) ) }
}
async function handleToggleType ( provider : ProviderInstance , type : string ) {
const updated = provider . supported _types . includes ( type )
? provider . supported _types . filter ( t => t !== type )
: [ ... provider . supported _types , type ]
try {
await adminAPI . payment . updateProvider ( provider . id , { supported _types : updated } as any )
provider . supported _types = updated
} catch ( err : unknown ) { appStore . showError ( extractApiErrorMessage ( err , t ( 'common.error' ) , paymentErrorMap . value ) ) }
}
function confirmDeleteProvider ( provider : ProviderInstance ) {
deletingProviderId . value = provider . id
showDeleteProviderDialog . value = true
}
async function handleReorderProviders ( updates : { id : number ; sort _order : number } [ ] ) {
try {
await Promise . all (
updates . map ( u => adminAPI . payment . updateProvider ( u . id , { sort _order : u . sort _order } as Partial < ProviderInstance > ) )
)
// Update local state to match new order
for ( const u of updates ) {
const p = providers . value . find ( p => p . id === u . id )
if ( p ) p . sort _order = u . sort _order
}
} catch ( err : unknown ) {
appStore . showError ( extractApiErrorMessage ( err , t ( 'common.error' ) ) )
loadProviders ( )
}
}
async function handleDeleteProvider ( ) {
if ( ! deletingProviderId . value ) return
try {
await adminAPI . payment . deleteProvider ( deletingProviderId . value )
appStore . showSuccess ( t ( 'common.deleted' ) )
showDeleteProviderDialog . value = false
loadProviders ( )
} catch ( err : unknown ) { appStore . showError ( extractApiErrorMessage ( err , t ( 'common.error' ) , paymentErrorMap . value ) ) }
}
2025-12-18 13:50:39 +08:00
onMounted ( ( ) => {
2025-12-25 08:41:36 -08:00
loadSettings ( )
2026-03-02 03:41:50 +08:00
loadSubscriptionGroups ( )
2025-12-25 08:41:36 -08:00
loadAdminApiKey ( )
2026-03-18 16:22:19 +08:00
loadOverloadCooldownSettings ( )
2026-01-11 21:54:52 -08:00
loadStreamTimeoutSettings ( )
2026-03-07 21:45:18 +08:00
loadRectifierSettings ( )
2026-03-10 11:14:17 +08:00
loadBetaPolicySettings ( )
2026-04-10 21:08:51 +08:00
loadProviders ( )
2025-12-25 08:41:36 -08:00
} )
2025-12-18 13:50:39 +08:00
< / script >
2026-03-02 03:41:50 +08:00
< style scoped >
. default - sub - group - select : deep ( . select - trigger ) {
@ apply h - [ 42 px ] ;
}
. default - sub - delete - btn {
@ apply h - [ 42 px ] ;
}
2026-03-04 16:59:57 +08:00
/* ============ Settings Tab Navigation ============ */
2026-03-14 20:22:39 +08:00
/* Scroll container: thin scrollbar on PC, auto-hide on mobile */
. settings - tabs - scroll {
scrollbar - width : thin ;
scrollbar - color : transparent transparent ;
}
. settings - tabs - scroll : hover {
scrollbar - color : rgb ( 0 0 0 / 0.15 ) transparent ;
}
: root . dark . settings - tabs - scroll : hover {
scrollbar - color : rgb ( 255 255 255 / 0.2 ) transparent ;
}
. settings - tabs - scroll : : - webkit - scrollbar {
height : 3 px ;
}
. settings - tabs - scroll : : - webkit - scrollbar - track {
background : transparent ;
}
. settings - tabs - scroll : : - webkit - scrollbar - thumb {
background : transparent ;
border - radius : 3 px ;
}
. settings - tabs - scroll : hover : : - webkit - scrollbar - thumb {
background : rgb ( 0 0 0 / 0.15 ) ;
}
: root . dark . settings - tabs - scroll : hover : : - webkit - scrollbar - thumb {
background : rgb ( 255 255 255 / 0.2 ) ;
}
2026-03-04 16:59:57 +08:00
. settings - tabs {
2026-03-14 20:22:39 +08:00
@ apply inline - flex min - w - full gap - 0.5 rounded - 2 xl
border border - gray - 100 bg - white / 80 p - 1 backdrop - blur - sm
2026-03-04 16:59:57 +08:00
dark : border - dark - 700 / 50 dark : bg - dark - 800 / 80 ;
box - shadow : 0 1 px 3 px rgb ( 0 0 0 / 0.04 ) , 0 1 px 2 px rgb ( 0 0 0 / 0.02 ) ;
}
@ media ( min - width : 640 px ) {
. settings - tabs {
@ apply flex ;
}
}
. settings - tab {
2026-03-14 20:22:39 +08:00
@ apply relative flex flex - 1 items - center justify - center gap - 1.5
whitespace - nowrap rounded - xl px - 2.5 py - 2
2026-03-04 16:59:57 +08:00
text - sm font - medium
text - gray - 500 dark : text - dark - 400
transition - all duration - 200 ease - out ;
}
. settings - tab : hover : not ( . settings - tab - active ) {
@ apply text - gray - 700 dark : text - gray - 300 ;
background : rgb ( 0 0 0 / 0.03 ) ;
}
: root . dark . settings - tab : hover : not ( . settings - tab - active ) {
background : rgb ( 255 255 255 / 0.04 ) ;
}
. settings - tab - active {
@ apply text - primary - 600 dark : text - primary - 400 ;
background : linear - gradient ( 135 deg , rgba ( 20 , 184 , 166 , 0.08 ) , rgba ( 20 , 184 , 166 , 0.03 ) ) ;
box - shadow : 0 1 px 2 px rgba ( 20 , 184 , 166 , 0.1 ) ;
}
: root . dark . settings - tab - active {
background : linear - gradient ( 135 deg , rgba ( 45 , 212 , 191 , 0.12 ) , rgba ( 45 , 212 , 191 , 0.05 ) ) ;
box - shadow : 0 1 px 3 px rgb ( 0 0 0 / 0.25 ) ;
}
. settings - tab - icon {
2026-03-14 20:22:39 +08:00
@ apply flex h - 6 w - 6 items - center justify - center rounded - lg
2026-03-04 16:59:57 +08:00
transition - all duration - 200 ;
}
. settings - tab - active . settings - tab - icon {
@ apply bg - primary - 500 / 15 text - primary - 600
dark : bg - primary - 400 / 15 dark : text - primary - 400 ;
}
2026-03-02 03:41:50 +08:00
< / style >