4bdcaa6258
* feat(providers): 新增 Alibaba Cloud (Coding Plan) 内置 provider 对齐 hermes-agent 上游 PR #15045(commit 727d1088),新增 alibaba-coding-plan provider,鉴权使用 ALIBABA_CODING_PLAN_API_KEY 环境变量,base_url 可通过 ALIBABA_CODING_PLAN_BASE_URL 覆盖。 默认 base_url 使用国际版端点 coding-intl.dashscope.aliyuncs.com/v1, 与上游 auth.py:255 保持一致。中国大陆 DashScope 账号 (dashscope.aliyun.com 颁发的 sk-sp-* 密钥)需要通过 ALIBABA_CODING_PLAN_BASE_URL=https://coding.dashscope.aliyuncs.com/v1 (不带 -intl)覆盖,因为 -intl 端点对该类密钥返回 HTTP 401。 该差异在源码注释中已说明。 模型列表覆盖 8 个 Coding Plan 支持的模型:qwen3.5-plus、 qwen3-max-2026-01-23、qwen3-coder-next/plus、glm-5、glm-4.7、 kimi-k2.5、MiniMax-M2.5(基于实测可用列表)。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(providers): Alibaba Coding Plan 添加国内/国际区域切换 在 ProviderFormModal 中针对 alibaba-coding-plan preset 增加一个 "区域"字段,可在国际版(coding-intl)与中国大陆(coding,无 -intl) 两个端点之间切换,切换时自动更新 base_url。 默认选中国际版以对齐上游 hermes-agent 默认值。中国大陆 DashScope 账号(dashscope.aliyun.com 颁发的 sk-sp-* 密钥)只需在表单里点一下 "中国大陆"即可,无需手动改 base_url 或设环境变量。 8 个 locale(zh/en/de/es/fr/ja/ko/pt)都补全了 region/regionIntl/ regionCn 三个 i18n key。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(providers): builtin provider 列表优先读取 base_url env override 之前服务端 getAvailable 在渲染 builtin provider 列表时直接 用 PROVIDER_PRESETS 里的默认 base_url,忽略了用户保存到 .env 的 base_url override。这导致用户在 Alibaba Coding Plan 选了"中国 大陆"保存后,列表里仍然显示国际版 URL。 修复:envMapping.base_url_env 如果存在且 .env 中有值,优先 使用该值;否则 fallback 到 preset 默认。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
339 lines
9.7 KiB
Vue
339 lines
9.7 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, computed, onMounted } from 'vue'
|
|
import { NModal, NForm, NFormItem, NInput, NInputNumber, NButton, NSelect, NRadioGroup, NRadioButton, useMessage } from 'naive-ui'
|
|
import { useModelsStore } from '@/stores/hermes/models'
|
|
import { useI18n } from 'vue-i18n'
|
|
import CodexLoginModal from './CodexLoginModal.vue'
|
|
import NousLoginModal from './NousLoginModal.vue'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
saved: []
|
|
}>()
|
|
|
|
const modelsStore = useModelsStore()
|
|
const message = useMessage()
|
|
|
|
const showModal = ref(true)
|
|
const loading = ref(false)
|
|
const fetchingModels = ref(false)
|
|
const showCodexLogin = ref(false)
|
|
const showNousLogin = ref(false)
|
|
|
|
const providerType = ref<'preset' | 'custom'>('preset')
|
|
const selectedPreset = ref<string | null>(null)
|
|
const formData = ref({
|
|
name: '',
|
|
base_url: '',
|
|
api_key: '',
|
|
model: '',
|
|
context_length: null as number | null,
|
|
})
|
|
|
|
const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
|
|
|
const CODEX_KEY = 'openai-codex'
|
|
const NOUS_KEY = 'nous'
|
|
const ALIBABA_CODING_KEY = 'alibaba-coding-plan'
|
|
const ALIBABA_CODING_REGIONS = {
|
|
intl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
|
cn: 'https://coding.dashscope.aliyuncs.com/v1',
|
|
} as const
|
|
|
|
const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
|
|
const isNous = computed(() => selectedPreset.value === NOUS_KEY)
|
|
const isAlibabaCoding = computed(() => selectedPreset.value === ALIBABA_CODING_KEY)
|
|
const alibabaCodingRegion = ref<'intl' | 'cn'>('intl')
|
|
|
|
const presetOptions = computed(() =>
|
|
modelsStore.allProviders.map(g => ({ label: g.label, value: g.provider })),
|
|
)
|
|
|
|
function autoGenerateName(url: string): string {
|
|
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
|
|
const host = clean.split('/')[0]
|
|
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
|
return t('models.local', { host })
|
|
}
|
|
return host.charAt(0).toUpperCase() + host.slice(1)
|
|
}
|
|
|
|
watch(selectedPreset, (val) => {
|
|
formData.value.model = ''
|
|
alibabaCodingRegion.value = 'intl'
|
|
if (val) {
|
|
const group = modelsStore.allProviders.find(g => g.provider === val)
|
|
if (group) {
|
|
formData.value.name = group.label
|
|
formData.value.base_url = group.base_url
|
|
modelOptions.value = group.models.map((m: string) => ({ label: m, value: m }))
|
|
if (group.models.length > 0) {
|
|
formData.value.model = group.models[0]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
watch(alibabaCodingRegion, (region) => {
|
|
if (isAlibabaCoding.value) {
|
|
formData.value.base_url = ALIBABA_CODING_REGIONS[region]
|
|
}
|
|
})
|
|
|
|
watch(() => formData.value.base_url, (url) => {
|
|
if (providerType.value === 'custom' && url.trim() && !formData.value.name) {
|
|
formData.value.name = autoGenerateName(url.trim())
|
|
}
|
|
})
|
|
|
|
watch(providerType, () => {
|
|
modelOptions.value = []
|
|
formData.value = { name: '', base_url: '', api_key: '', model: '', context_length: null }
|
|
selectedPreset.value = null
|
|
})
|
|
|
|
onMounted(() => {
|
|
if (modelsStore.providers.length === 0) {
|
|
modelsStore.fetchProviders()
|
|
}
|
|
})
|
|
|
|
async function fetchModels() {
|
|
const { base_url } = formData.value
|
|
if (!base_url.trim()) {
|
|
message.warning(t('models.enterBaseUrl'))
|
|
return
|
|
}
|
|
|
|
fetchingModels.value = true
|
|
try {
|
|
const base = base_url.replace(/\/+$/, '')
|
|
const url = /\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models`
|
|
const headers: Record<string, string> = {}
|
|
if (formData.value.api_key.trim()) {
|
|
headers['Authorization'] = `Bearer ${formData.value.api_key.trim()}`
|
|
}
|
|
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) })
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
const data = await res.json() as { data?: Array<{ id: string }> }
|
|
if (!Array.isArray(data.data)) throw new Error(t('models.unexpectedFormat'))
|
|
|
|
modelOptions.value = data.data.map(m => ({ label: m.id, value: m.id }))
|
|
if (modelOptions.value.length > 0 && !formData.value.model) {
|
|
formData.value.model = modelOptions.value[0].value
|
|
}
|
|
message.success(t('models.foundModels', { count: modelOptions.value.length }))
|
|
} catch (e: any) {
|
|
message.error(t('models.fetchFailed') + ': ' + e.message)
|
|
} finally {
|
|
fetchingModels.value = false
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (providerType.value === 'preset' && !selectedPreset.value) {
|
|
message.warning(t('models.selectProviderRequired'))
|
|
return
|
|
}
|
|
|
|
// Codex: 弹出授权码弹窗
|
|
if (isCodex.value) {
|
|
showCodexLogin.value = true
|
|
return
|
|
}
|
|
|
|
// Nous: 弹出 OAuth 设备码弹窗
|
|
if (isNous.value) {
|
|
showNousLogin.value = true
|
|
return
|
|
}
|
|
|
|
if (!formData.value.base_url.trim()) {
|
|
message.warning(t('models.baseUrlRequired'))
|
|
return
|
|
}
|
|
if (!formData.value.api_key.trim()) {
|
|
message.warning(t('models.apiKeyRequired'))
|
|
return
|
|
}
|
|
if (!formData.value.model) {
|
|
message.warning(t('models.modelRequired'))
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
try {
|
|
const providerKey = providerType.value === 'preset'
|
|
? selectedPreset.value
|
|
: null
|
|
|
|
const contextLength = formData.value.context_length ?? undefined
|
|
await modelsStore.addProvider({
|
|
name: formData.value.name.trim(),
|
|
base_url: formData.value.base_url.trim(),
|
|
api_key: formData.value.api_key.trim(),
|
|
model: formData.value.model,
|
|
context_length: contextLength,
|
|
providerKey,
|
|
})
|
|
message.success(t('models.providerAdded'))
|
|
emit('saved')
|
|
} catch (e: any) {
|
|
message.error(e.message)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function handleCodexSuccess() {
|
|
showCodexLogin.value = false
|
|
message.success(t('models.providerAdded'))
|
|
emit('saved')
|
|
}
|
|
|
|
async function handleNousSuccess() {
|
|
showNousLogin.value = false
|
|
message.success(t('models.providerAdded'))
|
|
emit('saved')
|
|
}
|
|
|
|
function handleClose() {
|
|
showModal.value = false
|
|
setTimeout(() => emit('close'), 200)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<NModal
|
|
v-model:show="showModal"
|
|
preset="card"
|
|
:title="t('models.addProvider')"
|
|
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
|
:mask-closable="!loading && !showCodexLogin && !showNousLogin"
|
|
@after-leave="emit('close')"
|
|
>
|
|
<NForm label-placement="top">
|
|
<NFormItem :label="t('models.providerType')">
|
|
<div style="display: flex; gap: 12px">
|
|
<NButton
|
|
:type="providerType === 'preset' ? 'primary' : 'default'"
|
|
size="small"
|
|
@click="providerType = 'preset'"
|
|
>
|
|
{{ t('models.preset') }}
|
|
</NButton>
|
|
<NButton
|
|
:type="providerType === 'custom' ? 'primary' : 'default'"
|
|
size="small"
|
|
@click="providerType = 'custom'"
|
|
>
|
|
{{ t('models.custom') }}
|
|
</NButton>
|
|
</div>
|
|
</NFormItem>
|
|
|
|
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
|
|
<NSelect
|
|
v-model:value="selectedPreset"
|
|
:options="presetOptions"
|
|
:placeholder="t('models.chooseProvider')"
|
|
filterable
|
|
/>
|
|
</NFormItem>
|
|
|
|
<NFormItem v-if="providerType === 'custom'" :label="t('models.name')">
|
|
<NInput
|
|
v-model:value="formData.name"
|
|
:placeholder="t('models.autoGeneratedName')"
|
|
/>
|
|
</NFormItem>
|
|
|
|
<NFormItem v-if="isAlibabaCoding" :label="t('models.region')">
|
|
<NRadioGroup v-model:value="alibabaCodingRegion">
|
|
<NRadioButton value="intl">{{ t('models.regionIntl') }}</NRadioButton>
|
|
<NRadioButton value="cn">{{ t('models.regionCn') }}</NRadioButton>
|
|
</NRadioGroup>
|
|
</NFormItem>
|
|
|
|
<NFormItem v-if="!isCodex && !isNous" :label="t('models.baseUrl')" required>
|
|
<NInput
|
|
v-model:value="formData.base_url"
|
|
:placeholder="t('models.baseUrlPlaceholder')"
|
|
:disabled="providerType === 'preset'"
|
|
/>
|
|
</NFormItem>
|
|
|
|
<NFormItem v-if="!isCodex && !isNous" :label="t('models.apiKey')" required>
|
|
<NInput
|
|
v-model:value="formData.api_key"
|
|
type="password"
|
|
show-password-on="click"
|
|
:placeholder="t('models.apiKeyPlaceholder')"
|
|
autocomplete="off"
|
|
/>
|
|
</NFormItem>
|
|
|
|
<NFormItem :label="t('models.defaultModel')" required>
|
|
<div style="display: flex; gap: 8px; width: 100%">
|
|
<NSelect
|
|
v-model:value="formData.model"
|
|
:options="modelOptions"
|
|
filterable
|
|
tag
|
|
:placeholder="t('models.selectOrInput')"
|
|
style="flex: 1"
|
|
/>
|
|
<NButton
|
|
v-if="providerType === 'custom' || (providerType === 'preset' && modelOptions.length === 0)"
|
|
:loading="fetchingModels"
|
|
@click="fetchModels"
|
|
>
|
|
{{ t('common.fetch') }}
|
|
</NButton>
|
|
</div>
|
|
</NFormItem>
|
|
|
|
<NFormItem v-if="providerType === 'custom'" :label="t('models.contextLength')">
|
|
<NInputNumber
|
|
v-model:value="formData.context_length as number | null"
|
|
:placeholder="t('models.contextLengthPlaceholder')"
|
|
:min="0"
|
|
clearable
|
|
style="width: 100%"
|
|
/>
|
|
</NFormItem>
|
|
</NForm>
|
|
|
|
<template #footer>
|
|
<div class="modal-footer">
|
|
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
|
<NButton type="primary" :loading="loading" @click="handleSave">
|
|
{{ t('common.add') }}
|
|
</NButton>
|
|
</div>
|
|
</template>
|
|
|
|
<CodexLoginModal
|
|
v-if="showCodexLogin"
|
|
@close="showCodexLogin = false"
|
|
@success="handleCodexSuccess"
|
|
/>
|
|
|
|
<NousLoginModal
|
|
v-if="showNousLogin"
|
|
@close="showNousLogin = false"
|
|
@success="handleNousSuccess"
|
|
/>
|
|
</NModal>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.modal-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
</style>
|