Files
Aether/frontend/src/features/providers/components/ProviderFormDialog.vue
T

567 lines
20 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<Dialog
:model-value="internalOpen"
:title="legacyT(isEditMode ? '编辑提供商' : '添加提供商')"
:description="legacyT(isEditMode ? '更新提供商配置。API 端点和密钥需在详情页面单独管理。' : '创建新的提供商配置。创建后可以为其添加 API 端点和密钥。')"
2025-12-10 20:52:44 +08:00
:icon="isEditMode ? SquarePen : Server"
size="xl"
2025-12-10 20:52:44 +08:00
@update:model-value="handleDialogUpdate"
>
<form
class="space-y-5"
@submit.prevent="handleSubmit"
>
2025-12-10 20:52:44 +08:00
<!-- 基本信息 -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
{{ legacyT('基本信息') }}
</h3>
2025-12-10 20:52:44 +08:00
<div class="space-y-1.5">
<Label for="name">{{ legacyT('名称 *') }}</Label>
<Input
id="name"
v-model="form.name"
:placeholder="legacyT('例如: OpenAI 主账号')"
/>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>{{ legacyT('提供商类型') }}</Label>
<Select
v-model="form.provider_type"
:disabled="isEditMode"
>
<SelectTrigger>
<SelectValue :placeholder="legacyT('请选择')" />
</SelectTrigger>
<SelectContent>
<!-- 新建模式:允许自定义及各反代类型 -->
<template v-if="!isEditMode">
<SelectItem value="custom">
{{ legacyT('自定义') }}
</SelectItem>
<SelectItem value="vertex_ai">
Vertex AI
</SelectItem>
<SelectItem value="claude_code">
{{ legacyT('Claude Code(实验性功能)') }}
</SelectItem>
<SelectItem value="codex">
Codex
</SelectItem>
<SelectItem value="chatgpt_web">
ChatGPT Web
</SelectItem>
2026-03-10 23:07:50 +08:00
<SelectItem value="gemini_cli">
Gemini CLI
</SelectItem>
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="xai">
xAI
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
<SelectItem value="windsurf">
Windsurf
</SelectItem>
<SelectItem value="antigravity">
Antigravity
</SelectItem>
</template>
<!-- 编辑模式:显示所有类型(兼容已有数据) -->
<template v-else>
<SelectItem value="custom">
{{ legacyT('自定义') }}
</SelectItem>
<SelectItem value="vertex_ai">
Vertex AI
</SelectItem>
<SelectItem value="claude_code">
{{ legacyT('Claude Code(实验性功能)') }}
</SelectItem>
<SelectItem value="codex">
Codex
</SelectItem>
<SelectItem value="chatgpt_web">
ChatGPT Web
</SelectItem>
<SelectItem value="gemini_cli">
2026-03-10 23:07:50 +08:00
Gemini CLI
</SelectItem>
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="xai">
xAI
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
<SelectItem value="windsurf">
Windsurf
</SelectItem>
<SelectItem value="antigravity">
Antigravity
</SelectItem>
</template>
</SelectContent>
</Select>
<p
v-if="!isEditMode && form.provider_type !== 'custom'"
class="text-xs text-muted-foreground"
>
{{ legacyT('反代使用固定端点且不可修改') }}
</p>
2025-12-10 20:52:44 +08:00
</div>
<div class="space-y-1.5">
<Label for="website">{{ legacyT('主站链接') }}</Label>
<Input
id="website"
v-model="form.website"
:placeholder="legacyT('https://example.com(可选)')"
/>
</div>
</div>
2025-12-10 20:52:44 +08:00
</div>
<!-- 请求配置 -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
{{ legacyT('请求配置') }}
</h3>
2025-12-10 20:52:44 +08:00
<!-- 超时配置 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
{{ legacyT('流式首字节超时') }}
<span class="text-xs text-muted-foreground">{{ legacyT('(秒)') }}</span>
</Label>
<Input
:model-value="form.stream_first_byte_timeout ?? ''"
type="number"
min="1"
max="300"
step="1"
placeholder="30"
@update:model-value="(v) => form.stream_first_byte_timeout = parseNumberInput(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
{{ legacyT('非流式请求超时') }}
<span class="text-xs text-muted-foreground">{{ legacyT('(秒)') }}</span>
</Label>
<Input
:model-value="form.request_timeout ?? ''"
type="number"
min="1"
max="1200"
step="1"
placeholder="300"
@update:model-value="(v) => form.request_timeout = parseNumberInput(v)"
/>
</div>
</div>
<!-- 提供商内转移限制 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="min-w-0 space-y-1.5">
<Label
for="max-transfer-count"
class="text-xs sm:text-sm"
>
{{ legacyT('最大转移次数') }}
</Label>
<Input
id="max-transfer-count"
:model-value="form.max_transfer_count === 0 ? '' : form.max_transfer_count"
type="number"
min="0"
step="1"
:placeholder="legacyT('0 (不限制)')"
@update:model-value="(v) => form.max_transfer_count = parseNumberInput(v, { min: 0 }) ?? 0"
/>
</div>
<div class="min-w-0 space-y-1.5">
<Label
for="max-transfer-timeout-seconds"
class="text-xs sm:text-sm"
>
{{ legacyT('最大转移超时') }}
<span class="text-xs text-muted-foreground">{{ legacyT('(秒)') }}</span>
</Label>
<Input
id="max-transfer-timeout-seconds"
:model-value="form.max_transfer_timeout_seconds === 0 ? '' : form.max_transfer_timeout_seconds"
type="number"
min="0"
step="1"
:placeholder="legacyT('0 (不限制)')"
@update:model-value="(v) => form.max_transfer_timeout_seconds = parseNumberInput(v, { min: 0 }) ?? 0"
/>
</div>
</div>
</div>
<!-- 功能开关 -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
{{ legacyT('功能开关') }}
</h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">{{ legacyT('号池调度模式') }}</span>
<p class="text-xs text-muted-foreground">
{{ legacyT('启用后该提供商的密钥将由号池统一调度') }}
</p>
</div>
<Switch
:model-value="form.pool_mode_enabled"
@update:model-value="(v: boolean) => form.pool_mode_enabled = v"
/>
2025-12-10 20:52:44 +08:00
</div>
<div
v-if="form.provider_type === 'kiro'"
class="flex items-center justify-between p-3 border rounded-lg bg-muted/50"
>
<div class="space-y-0.5">
<span class="text-sm font-medium">{{ legacyT('模拟缓存模式') }}</span>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ legacyT('启用后仅对 Kiro 请求模拟 prompt cache 读写计量。') }}
</p>
</div>
<Switch
:model-value="form.kiro_simulated_cache_enabled"
@update:model-value="(v: boolean) => form.kiro_simulated_cache_enabled = v"
/>
</div>
<div
v-if="form.provider_type === 'codex'"
class="flex items-center justify-between gap-4 p-3 border rounded-lg bg-muted/50"
data-testid="codex-fingerprint-convergence-setting"
>
<div class="space-y-0.5">
<div class="flex items-center gap-1">
<Label
for="codex-fingerprint-convergence"
class="text-sm font-medium"
>
{{ legacyT('Codex 指纹收敛(推荐开启)') }}
</Label>
<HelpHint
:label="legacyT('Codex 指纹收敛(推荐开启)')"
:text="legacyT('开启后,Aether 会根据当前账号身份为出站请求统一生成并写入以下标识,使同一 Codex 账号在上游看来始终是同一设备与会话:\n\n• 请求头:installation_id、session_id、thread_id、window_id、turn_id 等\n• 请求体(Responses 格式):client_metadata 与 prompt_cache_key 中的对应字段\n\n关闭时保持客户端原始透传,不做任何改写。建议开启以提升上游指纹稳定性与会话连续性。')"
/>
</div>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ legacyT('统一同一 Codex 身份的设备与会话标识;关闭时保持现有透传行为。') }}
</p>
</div>
<Switch
id="codex-fingerprint-convergence"
:model-value="form.codex_fingerprint_convergence_enabled"
:aria-label="legacyT('Codex 指纹收敛(推荐开启)')"
@update:model-value="(v: boolean) => form.codex_fingerprint_convergence_enabled = v"
/>
</div>
<div
class="flex items-center justify-between p-3 border rounded-lg bg-muted/50"
data-testid="responses-websocket-setting"
>
<div class="space-y-0.5">
<Label
for="responses-websocket-enabled"
class="text-sm font-medium"
>
{{ legacyT('Responses WebSocket 模式') }}
</Label>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ legacyT('允许此提供商处理标准 Responses API WebSocket 请求。') }}
</p>
</div>
<Switch
id="responses-websocket-enabled"
:model-value="form.responses_websocket_enabled"
:aria-label="legacyT('Responses WebSocket 模式')"
@update:model-value="(v: boolean) => form.responses_websocket_enabled = v"
/>
</div>
2026-05-14 11:14:20 +08:00
<div class="flex items-center justify-between gap-4 p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">{{ legacyT('敏感信息保护') }}</span>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ legacyT('请前往模块管理-敏感信息保护中配置详细规则。') }}
</p>
</div>
</div>
2025-12-10 20:52:44 +08:00
</div>
</form>
<template #footer>
<Button
type="button"
variant="outline"
:disabled="loading"
@click="handleCancel"
2025-12-10 20:52:44 +08:00
>
{{ legacyT('取消') }}
2025-12-10 20:52:44 +08:00
</Button>
<Button
:disabled="loading || !form.name"
@click="handleSubmit"
2025-12-10 20:52:44 +08:00
>
{{ submitLabel }}
2025-12-10 20:52:44 +08:00
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
Dialog,
Button,
Input,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Switch,
} from '@/components/ui'
2025-12-10 20:52:44 +08:00
import { Server, SquarePen } from 'lucide-vue-next'
import HelpHint from '@/components/common/HelpHint.vue'
2025-12-10 20:52:44 +08:00
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { useI18n } from '@/i18n'
import {
createProvider,
normalizePoolAdvancedConfig,
updateProvider,
type ProviderType,
type ProviderWithEndpointsSummary,
} from '@/api/endpoints'
2025-12-10 20:52:44 +08:00
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput } from '@/utils/form'
const props = defineProps<{
modelValue: boolean
provider?: ProviderWithEndpointsSummary | null // 编辑模式时传入
maxPriority?: number // 当前已有的最大优先级值
2025-12-10 20:52:44 +08:00
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'providerCreated': []
'providerUpdated': [provider: ProviderWithEndpointsSummary]
2025-12-10 20:52:44 +08:00
}>()
const { success, error: showError } = useToast()
const { legacyT } = useI18n()
2025-12-10 20:52:44 +08:00
const loading = ref(false)
// 内部状态
const internalOpen = computed(() => props.modelValue)
// 计算新建时的默认优先级
const defaultPriority = computed(() => {
if (props.maxPriority != null) {
return Math.min(props.maxPriority + 10, 10000)
}
return 100
})
const submitLabel = computed(() => {
if (loading.value) {
return legacyT(isEditMode.value ? '保存中...' : '创建中...')
}
return legacyT(isEditMode.value ? '保存' : '创建')
})
2025-12-10 20:52:44 +08:00
// 表单数据
const form = ref({
name: '',
provider_type: 'custom' as ProviderType,
2025-12-10 20:52:44 +08:00
description: '',
website: '',
provider_priority: 100,
2025-12-10 20:52:44 +08:00
// 状态配置
is_active: true,
rate_limit: undefined as number | undefined,
concurrent_limit: undefined as number | undefined,
// 请求配置
max_retries: undefined as number | undefined,
max_transfer_count: 0,
max_transfer_timeout_seconds: 0,
// 超时配置(秒)
stream_first_byte_timeout: undefined as number | undefined,
request_timeout: undefined as number | undefined,
// 号池模式
pool_mode_enabled: false,
// Codex 专属配置
codex_fingerprint_convergence_enabled: false,
// Kiro 专属配置
kiro_simulated_cache_enabled: false,
// Responses WebSocket 配置
responses_websocket_enabled: false,
2025-12-10 20:52:44 +08:00
})
// 重置表单
function resetForm() {
form.value = {
name: '',
provider_type: 'custom',
2025-12-10 20:52:44 +08:00
description: '',
website: '',
provider_priority: defaultPriority.value,
2025-12-10 20:52:44 +08:00
is_active: true,
rate_limit: undefined,
concurrent_limit: undefined,
// 请求配置
max_retries: undefined,
max_transfer_count: 0,
max_transfer_timeout_seconds: 0,
// 超时配置
stream_first_byte_timeout: undefined,
request_timeout: undefined,
// 号池模式
pool_mode_enabled: false,
// Codex 专属配置
codex_fingerprint_convergence_enabled: false,
// Kiro 专属配置
kiro_simulated_cache_enabled: false,
// Responses WebSocket 配置
responses_websocket_enabled: false,
2025-12-10 20:52:44 +08:00
}
}
// 加载提供商数据(编辑模式)
function loadProviderData() {
if (!props.provider) return
const poolAdvanced = normalizePoolAdvancedConfig(props.provider.pool_advanced)
2025-12-10 20:52:44 +08:00
form.value = {
name: props.provider.name,
provider_type: props.provider.provider_type || 'custom',
2025-12-10 20:52:44 +08:00
description: props.provider.description || '',
website: props.provider.website || '',
provider_priority: props.provider.provider_priority || 999,
is_active: props.provider.is_active,
rate_limit: undefined,
concurrent_limit: undefined,
// 请求配置
max_retries: props.provider.max_retries ?? undefined,
max_transfer_count: props.provider.max_transfer_count ?? 0,
max_transfer_timeout_seconds: props.provider.max_transfer_timeout_seconds ?? 0,
// 超时配置
stream_first_byte_timeout: props.provider.stream_first_byte_timeout ?? undefined,
request_timeout: props.provider.request_timeout ?? undefined,
// 号池模式
pool_mode_enabled: poolAdvanced !== null,
// Codex 专属配置
codex_fingerprint_convergence_enabled: props.provider.codex_fingerprint_convergence_enabled ?? false,
// Kiro 专属配置
kiro_simulated_cache_enabled: props.provider.kiro_simulated_cache_enabled ?? false,
// Responses WebSocket 配置
responses_websocket_enabled: props.provider.responses_websocket_enabled ?? false,
2025-12-10 20:52:44 +08:00
}
}
// 使用 useFormDialog 统一处理对话框逻辑
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
isOpen: () => props.modelValue,
entity: () => props.provider,
isLoading: loading,
onClose: () => emit('update:modelValue', false),
loadData: loadProviderData,
resetForm,
})
// 新建模式下切换 provider_type 时不自动开启号池模式
watch(() => form.value.provider_type, () => {
if (!isEditMode.value) {
form.value.pool_mode_enabled = false
}
if (form.value.provider_type !== 'kiro') {
form.value.kiro_simulated_cache_enabled = false
}
if (form.value.provider_type !== 'codex') {
form.value.codex_fingerprint_convergence_enabled = false
}
})
2025-12-10 20:52:44 +08:00
// 提交表单
const handleSubmit = async () => {
loading.value = true
try {
const currentPoolAdvanced = normalizePoolAdvancedConfig(props.provider?.pool_advanced)
2026-05-14 11:14:20 +08:00
const basePayload = {
name: form.value.name,
provider_type: form.value.provider_type,
description: form.value.description || undefined,
website: form.value.website || undefined,
responses_websocket_enabled: form.value.responses_websocket_enabled,
is_active: form.value.is_active,
// 请求配置
max_retries: form.value.max_retries ?? undefined,
max_transfer_count: form.value.max_transfer_count,
max_transfer_timeout_seconds: form.value.max_transfer_timeout_seconds,
// 超时配置(null 表示清除,使用全局配置)
stream_first_byte_timeout: form.value.stream_first_byte_timeout ?? null,
request_timeout: form.value.request_timeout ?? null,
pool_advanced: form.value.pool_mode_enabled
? (currentPoolAdvanced ?? {})
: null,
...(form.value.provider_type === 'codex'
? {
codex_fingerprint_convergence_enabled: form.value.codex_fingerprint_convergence_enabled,
}
: {}),
...(form.value.provider_type === 'kiro'
? {
config: {
kiro: {
simulated_cache_enabled: form.value.kiro_simulated_cache_enabled,
},
},
}
: {}),
2026-05-14 11:14:20 +08:00
}
2025-12-10 20:52:44 +08:00
if (isEditMode.value && props.provider) {
// 更新提供商
const updated = await updateProvider(props.provider.id, {
...basePayload,
provider_priority: form.value.provider_priority,
})
success(legacyT('提供商更新成功'))
emit('providerUpdated', updated)
2025-12-10 20:52:44 +08:00
} else {
// 创建提供商(优先级由后端自动置顶)
await createProvider(basePayload)
success(legacyT('提供商已创建,请继续添加端点和密钥,或在优先级管理中调整顺序'), legacyT('创建成功'))
emit('providerCreated')
2025-12-10 20:52:44 +08:00
}
emit('update:modelValue', false)
} catch (error: unknown) {
2025-12-10 20:52:44 +08:00
const action = isEditMode.value ? '更新' : '创建'
showError(parseApiError(error, legacyT(`${action}提供商失败`)), legacyT(`${action}失败`))
2025-12-10 20:52:44 +08:00
} finally {
loading.value = false
}
}
</script>