feat: 灵犀 Studio Web UI 定制版
Build / build (push) Has been cancelled
NPM Lockfile Check / npm ci --ignore-scripts (push) Has been cancelled
Playwright / e2e (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yi
2026-06-05 11:29:11 +08:00
commit 7d10320a82
643 changed files with 164406 additions and 0 deletions
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startCodexLogin, pollCodexLogin } from '@/api/hermes/codex-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const userCode = ref('')
const verificationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startCodexLogin()
userCode.value = data.user_code
verificationUrl.value = data.verification_url
sessionId.value = data.session_id
status.value = 'waiting'
startPolling()
} catch (err: any) {
status.value = 'error'
const msg = err.message || ''
// Try to extract friendly error from response
try {
const match = msg.match(/\{[\s\S]*\}$/)
if (match) {
const body = JSON.parse(match[0])
errorMessage.value = body.error || msg
} else {
errorMessage.value = msg
}
} catch {
errorMessage.value = msg
}
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollCodexLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.codexApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.codexCopyCode'))
else message.error(t('models.codexCopyCode') + ' ✗')
}
function openLink() {
window.open(verificationUrl.value, '_blank')
}
function retry() {
status.value = 'idle'
userCode.value = ''
verificationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(() => {
stopPolling()
})
// Auto-start when modal opens
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.codexLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="codex-login">
<!-- Idle / Loading -->
<div v-if="status === 'idle' || status === 'loading'" class="codex-login__state">
<NSpin size="small" />
</div>
<!-- Waiting for authorization -->
<div v-else-if="status === 'waiting'" class="codex-login__state">
<p class="codex-login__hint">{{ t('models.codexWaiting') }}</p>
<div class="codex-login__code" @click="copyCode">
<span class="codex-login__code-text">{{ userCode }}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</div>
<NButton type="primary" block @click="openLink">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</template>
{{ t('models.codexOpenLink') }}
</NButton>
</div>
<!-- Approved -->
<div v-else-if="status === 'approved'" class="codex-login__state codex-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.codexApproved') }}</p>
</div>
<!-- Expired -->
<div v-else-if="status === 'expired'" class="codex-login__state">
<p class="codex-login__error">{{ t('models.codexExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="codex-login__state">
<p class="codex-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.codex-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.codex-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.codex-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.codex-login__code {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
background: var(--n-color, #fafafa);
&:hover {
border-color: var(--n-primary-color, #18a058);
}
}
.codex-login__code-text {
font-size: 28px;
font-weight: 700;
font-family: monospace;
letter-spacing: 4px;
color: var(--n-text-color, inherit);
}
.codex-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.codex-login__error {
color: #d03050;
text-align: center;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startCopilotLogin, pollCopilotLogin } from '@/api/hermes/copilot-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const userCode = ref('')
const verificationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startCopilotLogin()
userCode.value = data.user_code
verificationUrl.value = data.verification_url
sessionId.value = data.session_id
status.value = 'waiting'
startPolling()
} catch (err: any) {
status.value = 'error'
const msg = err?.message || ''
try {
const match = msg.match(/\{[\s\S]*\}$/)
if (match) {
const body = JSON.parse(match[0])
errorMessage.value = body.error || msg
} else {
errorMessage.value = msg
}
} catch {
errorMessage.value = msg
}
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollCopilotLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.copilotApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'denied') {
status.value = 'error'
errorMessage.value = t('models.copilotDenied')
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.copilotCopyCode'))
else message.error(t('models.copilotCopyCode') + ' ✗')
}
function openLink() {
window.open(verificationUrl.value, '_blank')
}
function retry() {
status.value = 'idle'
userCode.value = ''
verificationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(() => {
stopPolling()
})
// Auto-start when modal opens
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.copilotLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="copilot-login">
<!-- Idle / Loading -->
<div v-if="status === 'idle' || status === 'loading'" class="copilot-login__state">
<NSpin size="small" />
</div>
<!-- Waiting for authorization -->
<div v-else-if="status === 'waiting'" class="copilot-login__state">
<p class="copilot-login__hint">{{ t('models.copilotWaiting') }}</p>
<div class="copilot-login__code" @click="copyCode">
<span class="copilot-login__code-text">{{ userCode }}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</div>
<NButton type="primary" block @click="openLink">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</template>
{{ t('models.copilotOpenLink') }}
</NButton>
</div>
<!-- Approved -->
<div v-else-if="status === 'approved'" class="copilot-login__state copilot-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.copilotApproved') }}</p>
</div>
<!-- Expired -->
<div v-else-if="status === 'expired'" class="copilot-login__state">
<p class="copilot-login__error">{{ t('models.copilotExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="copilot-login__state">
<p class="copilot-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.copilot-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.copilot-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.copilot-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.copilot-login__code {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
background: var(--n-color, #fafafa);
&:hover {
border-color: var(--n-primary-color, #18a058);
}
}
.copilot-login__code-text {
font-size: 28px;
font-weight: 700;
font-family: monospace;
letter-spacing: 4px;
color: var(--n-text-color, inherit);
}
.copilot-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.copilot-login__error {
color: #d03050;
text-align: center;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startNousLogin, pollNousLogin } from '@/api/hermes/nous-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const userCode = ref('')
const verificationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startNousLogin()
userCode.value = data.user_code
verificationUrl.value = data.verification_url
sessionId.value = data.session_id
status.value = 'waiting'
startPolling()
} catch (err: any) {
status.value = 'error'
const msg = err.message || ''
try {
const match = msg.match(/\{[\s\S]*\}$/)
if (match) {
const body = JSON.parse(match[0])
errorMessage.value = body.error || msg
} else {
errorMessage.value = msg
}
} catch {
errorMessage.value = msg
}
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollNousLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.nousApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'denied') {
status.value = 'error'
errorMessage.value = t('models.nousDenied')
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.nousCopyCode'))
else message.error(t('models.nousCopyCode') + ' ✗')
}
function openLink() {
window.open(verificationUrl.value, '_blank')
}
function retry() {
status.value = 'idle'
userCode.value = ''
verificationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(() => {
stopPolling()
})
// Auto-start when modal opens
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.nousLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="nous-login">
<!-- Idle / Loading -->
<div v-if="status === 'idle' || status === 'loading'" class="nous-login__state">
<NSpin size="small" />
</div>
<!-- Waiting for authorization -->
<div v-else-if="status === 'waiting'" class="nous-login__state">
<p class="nous-login__hint">{{ t('models.nousWaiting') }}</p>
<div class="nous-login__code" @click="copyCode">
<span class="nous-login__code-text">{{ userCode }}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</div>
<NButton type="primary" block @click="openLink">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</template>
{{ t('models.nousOpenLink') }}
</NButton>
</div>
<!-- Approved -->
<div v-else-if="status === 'approved'" class="nous-login__state nous-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.nousApproved') }}</p>
</div>
<!-- Expired -->
<div v-else-if="status === 'expired'" class="nous-login__state">
<p class="nous-login__error">{{ t('models.nousExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="nous-login__state">
<p class="nous-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.nous-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.nous-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.nous-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.nous-login__code {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
background: var(--n-color, #fafafa);
&:hover {
border-color: var(--n-primary-color, #18a058);
}
}
.nous-login__code-text {
font-size: 28px;
font-weight: 700;
font-family: monospace;
letter-spacing: 4px;
color: var(--n-text-color, inherit);
}
.nous-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.nous-login__error {
color: #d03050;
text-align: center;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,603 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NButton, NCheckbox, NCheckboxGroup, NModal, NInput, useMessage, useDialog } from 'naive-ui'
import type { AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useAppStore } from '@/stores/hermes/app'
import { useChatStore } from '@/stores/hermes/chat'
import { checkCopilotToken, disableCopilot } from '@/api/hermes/copilot-auth'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ provider: AvailableModelGroup }>()
const { t } = useI18n()
const modelsStore = useModelsStore()
const appStore = useAppStore()
const chatStore = useChatStore()
const message = useMessage()
const dialog = useDialog()
const isCustom = computed(() => !props.provider.builtin && props.provider.provider.startsWith('custom:'))
const isCopilot = computed(() => props.provider.provider === 'copilot')
const displayName = computed(() => props.provider.label)
const deleting = ref(false)
const showAliasListModal = ref(false)
const showAliasModal = ref(false)
const aliasProvider = ref('')
const aliasModel = ref('')
const aliasInput = ref('')
const showVisibilityModal = ref(false)
const visibilitySaving = ref(false)
const selectedVisibleModels = ref<string[]>([])
const sourceProvider = computed(() => modelsStore.allProviders.find(g => g.provider === props.provider.provider))
const allModels = computed(() => props.provider.available_models?.length ? props.provider.available_models : (sourceProvider.value?.models?.length ? sourceProvider.value.models : props.provider.models))
const visibilityRule = computed(() => appStore.getProviderVisibility(props.provider.provider))
const isFiltered = computed(() => visibilityRule.value.mode === 'include')
const visibleCountLabel = computed(() => `${props.provider.models.length}/${allModels.value.length}`)
const isDefaultProvider = computed(() => modelsStore.defaultProvider === props.provider.provider)
function isDefaultModel(model: string) {
return isDefaultProvider.value && modelsStore.defaultModel === model
}
function modelAlias(model: string) {
return appStore.getModelAlias(model, props.provider.provider)
}
function modelDisplayName(model: string) {
return appStore.displayModelName(model, props.provider.provider)
}
function openAliasEditor(model: string) {
aliasProvider.value = props.provider.provider
aliasModel.value = model
aliasInput.value = appStore.getModelAlias(model, props.provider.provider)
showAliasModal.value = true
}
async function saveAlias() {
if (!aliasModel.value || !aliasProvider.value) return
try {
await appStore.setModelAlias(aliasModel.value, aliasProvider.value, aliasInput.value)
showAliasModal.value = false
} catch (e: any) {
message.error(e.message || t('models.aliasSaveFailed'))
}
}
async function clearAlias() {
aliasInput.value = ''
await saveAlias()
}
function openVisibilityModal() {
const rule = appStore.getProviderVisibility(props.provider.provider)
selectedVisibleModels.value = rule.mode === 'include' ? allModels.value.filter(m => rule.models.includes(m)) : [...allModels.value]
showVisibilityModal.value = true
}
async function handleVisibilitySave() {
if (selectedVisibleModels.value.length === 0) {
message.error(t('models.visibilitySelectOne'))
return
}
visibilitySaving.value = true
try {
const selected = selectedVisibleModels.value.filter(m => allModels.value.includes(m))
const mode = selected.length === allModels.value.length ? 'all' : 'include'
await appStore.setModelVisibility(props.provider.provider, { mode, models: selected })
await modelsStore.fetchProviders()
showVisibilityModal.value = false
message.success(t('models.visibilitySaved'))
} catch (e: any) {
message.error(e.message || t('models.visibilitySaveFailed'))
} finally {
visibilitySaving.value = false
}
}
function resetVisibility() {
selectedVisibleModels.value = [...allModels.value]
}
function clearVisibility() {
selectedVisibleModels.value = []
}
async function handleDelete() {
let copilotMsg = ''
if (isCopilot.value) {
// 提前查 source,让用户清楚移除会不会影响 VS Code/gh CLI 等其他工具的登录态
try {
const status = await checkCopilotToken()
if (status.source === 'env') copilotMsg = t('models.copilotDeleteHintEnv')
else if (status.source === 'gh-cli') copilotMsg = t('models.copilotDeleteHintGhCli')
else if (status.source === 'apps-json') copilotMsg = t('models.copilotDeleteHintAppsJson')
} catch { /* ignore — fall back to generic confirm copy */ }
}
dialog.warning({
title: t('models.deleteProvider'),
content: isCopilot.value && copilotMsg
? `${t('models.deleteConfirm', { name: displayName.value })}\n\n${copilotMsg}`
: t('models.deleteConfirm', { name: displayName.value }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
deleting.value = true
try {
if (isCopilot.value) {
// Copilot 走显式 opt-in 模型:disable 把 enabled 置 false
// 仅当 token 来自 ~/.hermes/.env 时才清掉,gh-cli / apps.json 不动。
await disableCopilot()
// 服务端会在默认模型属于 copilot 时清掉 model.default,这里再清理本地
// 会话级 model/provider,避免 Chat 页继续显示已下架的 copilot 模型。
chatStore.clearProviderFromSessions('copilot')
await modelsStore.fetchProviders()
} else {
await modelsStore.removeProvider(props.provider.provider)
}
// 删完之后若已没有默认模型,自动从剩余 provider 里挑一个,避免 chat 页
// "无默认模型"的尴尬态。与 hermes CLI `model` 子命令的隐含行为对齐。
if (!appStore.selectedModel && appStore.modelGroups.length > 0) {
const first = appStore.modelGroups.find(g => g.models.length > 0)
if (first) {
await appStore.switchModel(first.models[0], first.provider)
}
}
message.success(t('models.providerDeleted'))
} catch (e: any) {
message.error(e.message)
} finally {
deleting.value = false
}
},
})
}
</script>
<template>
<div class="provider-card">
<div class="card-header">
<h3 class="provider-name">{{ displayName }}</h3>
<div class="provider-badges">
<span v-if="isDefaultProvider" class="type-badge default">{{ t('models.currentDefault') }}</span>
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
</span>
</div>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">{{ t('models.provider') }}</span>
<code class="info-value mono">{{ provider.provider }}</code>
</div>
<div class="info-row">
<span class="info-label">{{ t('models.baseUrl') }}</span>
<code class="info-value mono">{{ provider.base_url }}</code>
</div>
<div class="info-row models-row">
<span class="info-label">{{ t('models.models') }}</span>
<span class="info-value models-count">
{{ isFiltered ? visibleCountLabel : provider.models.length }} {{ t('models.count') }}
</span>
</div>
<div class="models-list">
<button
v-for="model in provider.models.slice(0, 20)"
:key="model"
class="model-tag model-tag-button"
:class="{ default: isDefaultModel(model) }"
type="button"
:title="t('models.aliasTitleFor', { model })"
@click="openAliasEditor(model)"
>
<span class="model-tag-name">{{ modelDisplayName(model) }}</span>
<span v-if="isDefaultModel(model)" class="model-tag-default">{{ t('models.defaultShort') }}</span>
<span v-if="modelAlias(model)" class="model-tag-id">{{ model }}</span>
</button>
<span v-if="provider.models.length > 20" class="model-tag model-tag-more">
+{{ provider.models.length - 20 }} {{ t('models.more') }}
</span>
</div>
</div>
<div class="card-actions">
<NButton size="tiny" quaternary @click="showAliasListModal = true">{{ t('models.aliasManage') }}</NButton>
<NButton size="tiny" quaternary @click="openVisibilityModal">{{ t('models.manageVisibleModels') }}</NButton>
<NButton size="tiny" quaternary type="error" :loading="deleting" @click="handleDelete">{{ t('common.delete') }}</NButton>
</div>
<NModal
v-model:show="showAliasListModal"
preset="card"
:title="t('models.aliasManageFor', { provider: displayName })"
:style="{ width: 'min(560px, calc(100vw - 32px))' }"
:mask-closable="true"
>
<div class="alias-list-hint">{{ t('models.aliasHint') }}</div>
<div class="alias-list">
<div v-for="model in provider.models" :key="model" class="alias-row">
<div class="alias-row-text">
<span class="alias-row-name">{{ modelDisplayName(model) }}</span>
<span v-if="isDefaultModel(model)" class="alias-row-default">{{ t('models.defaultShort') }}</span>
<code class="alias-row-id">{{ model }}</code>
</div>
<NButton size="tiny" quaternary @click="openAliasEditor(model)">{{ t('models.aliasEdit') }}</NButton>
</div>
</div>
</NModal>
<NModal
v-model:show="showAliasModal"
preset="card"
:title="aliasModel ? t('models.aliasTitleFor', { model: aliasModel }) : t('models.aliasTitle')"
:style="{ width: 'min(420px, calc(100vw - 32px))' }"
:mask-closable="true"
>
<NInput
v-model:value="aliasInput"
:placeholder="t('models.aliasPlaceholder')"
clearable
@keydown.enter="saveAlias"
/>
<div v-if="aliasModel" class="model-alias-canonical">
{{ t('models.aliasCanonical', { model: aliasModel }) }}
</div>
<div class="model-alias-hint">{{ t('models.aliasHint') }}</div>
<template #footer>
<div class="model-alias-actions">
<NButton quaternary :disabled="!appStore.getModelAlias(aliasModel, aliasProvider)" @click="clearAlias">
{{ t('models.aliasUseOriginal') }}
</NButton>
<div class="model-alias-spacer" />
<NButton @click="showAliasModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" @click="saveAlias">{{ t('common.save') }}</NButton>
</div>
</template>
</NModal>
<NModal
v-model:show="showVisibilityModal"
preset="card"
:title="t('models.manageVisibleModelsFor', { name: displayName })"
:style="{ width: 'min(560px, calc(100vw - 32px))' }"
:mask-closable="!visibilitySaving"
>
<p class="visibility-hint">{{ t('models.visibilityHint') }}</p>
<div class="visibility-count">
{{ selectedVisibleModels.length }}/{{ allModels.length }} {{ t('models.count') }}
</div>
<div class="visibility-list">
<NCheckboxGroup v-model:value="selectedVisibleModels">
<NCheckbox
v-for="model in allModels"
:key="model"
:value="model"
class="visibility-model"
>
<code>{{ modelDisplayName(model) }}</code>
<code v-if="modelAlias(model)" class="visibility-model-id">{{ model }}</code>
</NCheckbox>
</NCheckboxGroup>
</div>
<div class="visibility-actions">
<NButton size="small" quaternary :disabled="visibilitySaving" @click="resetVisibility">
{{ t('models.showAllModels') }}
</NButton>
<NButton size="small" quaternary :disabled="visibilitySaving" @click="clearVisibility">
{{ t('models.clearVisibleModels') }}
</NButton>
<div class="visibility-action-spacer" />
<NButton size="small" :disabled="visibilitySaving" @click="showVisibilityModal = false">
{{ t('common.cancel') }}
</NButton>
<NButton size="small" type="primary" :loading="visibilitySaving" @click="handleVisibilitySave">
{{ t('common.save') }}
</NButton>
</div>
</NModal>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.provider-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
transition: border-color $transition-fast;
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.provider-name {
font-size: 15px;
font-weight: 600;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.provider-badges {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
}
.type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
&.builtin {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.custom {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.default {
background: rgba(var(--warning-rgb), 0.14);
color: $warning;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 12px;
color: $text-muted;
}
.info-value {
font-size: 12px;
color: $text-secondary;
}
.mono {
font-family: $font-code;
font-size: 12px;
}
.models-row {
margin-top: 4px;
}
.models-count {
color: $text-muted;
font-size: 12px;
}
.models-list {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
margin-top: 6px;
height: 100px;
overflow-y: auto;
align-content: flex-start;
}
.model-tag {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 22px;
font-size: 10px;
font-family: $font-code;
padding: 2px 6px;
border-radius: 3px;
background: rgba(var(--accent-primary-rgb), 0.08);
color: $text-secondary;
white-space: nowrap;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
&-more {
background: rgba(var(--accent-primary-rgb), 0.15);
color: $accent-primary;
font-weight: 500;
}
&.default {
background: rgba(var(--warning-rgb), 0.14);
color: $text-primary;
}
}
.model-tag-button {
border: 0;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.16);
color: $text-primary;
}
}
.model-tag-name,
.model-tag-id {
overflow: hidden;
text-overflow: ellipsis;
}
.model-tag-id {
color: $text-muted;
font-size: 9px;
}
.model-tag-default,
.alias-row-default {
color: $warning;
font-family: $font-ui;
font-size: 10px;
font-weight: 600;
}
.card-actions {
display: flex;
gap: 8px;
border-top: 1px solid $border-light;
padding-top: 10px;
}
.alias-list-hint,
.model-alias-hint {
color: $text-muted;
font-size: 12px;
}
.alias-list-hint {
margin-bottom: 12px;
}
.alias-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 45vh;
overflow-y: auto;
}
.alias-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border: 1px solid $border-light;
border-radius: $radius-sm;
}
.alias-row-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.alias-row-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-primary;
font-family: $font-code;
font-size: 12px;
}
.alias-row-id,
.model-alias-canonical {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-muted;
font-family: $font-code;
font-size: 11px;
}
.model-alias-canonical {
margin-top: 8px;
}
.model-alias-hint {
margin-top: 6px;
}
.model-alias-actions {
display: flex;
align-items: center;
gap: 8px;
}
.model-alias-spacer {
flex: 1;
}
.visibility-hint {
margin: 0 0 10px;
color: $text-secondary;
font-size: 13px;
line-height: 1.5;
}
.visibility-count {
color: $text-muted;
font-size: 12px;
margin-bottom: 10px;
}
.visibility-list {
max-height: 360px;
overflow-y: auto;
border: 1px solid $border-light;
border-radius: $radius-sm;
padding: 8px;
}
.visibility-model {
display: flex;
width: 100%;
padding: 4px 2px;
code {
font-family: $font-code;
font-size: 12px;
color: $text-secondary;
}
}
.visibility-model-id {
margin-left: 6px;
color: $text-muted !important;
font-size: 11px !important;
}
.visibility-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.visibility-action-spacer {
flex: 1;
}
</style>
@@ -0,0 +1,492 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { NModal, NForm, NFormItem, NInput, NInputNumber, NButton, NSelect, NRadioGroup, NRadioButton, useMessage, useDialog } from 'naive-ui'
import { useModelsStore } from '@/stores/hermes/models'
import { useI18n } from 'vue-i18n'
import CodexLoginModal from './CodexLoginModal.vue'
import NousLoginModal from './NousLoginModal.vue'
import CopilotLoginModal from './CopilotLoginModal.vue'
import XaiOAuthLoginModal from './XaiOAuthLoginModal.vue'
import { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth'
import { fetchProviderModels } from '@/api/hermes/system'
import { normalizeCustomProviderBaseUrl } from '@/utils/providerBaseUrl'
const { t } = useI18n()
const emit = defineEmits<{
close: []
saved: []
}>()
const modelsStore = useModelsStore()
const message = useMessage()
const dialog = useDialog()
const showModal = ref(true)
const loading = ref(false)
const fetchingModels = ref(false)
const showCodexLogin = ref(false)
const showNousLogin = ref(false)
const showCopilotLogin = ref(false)
const showXaiLogin = ref(false)
const copilotChecking = 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 COPILOT_KEY = 'copilot'
const CLIPROXYAPI_KEY = 'cliproxyapi'
const XAI_OAUTH_KEY = 'xai-oauth'
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 isCopilot = computed(() => selectedPreset.value === COPILOT_KEY)
const isCliproxyApi = computed(() => selectedPreset.value === CLIPROXYAPI_KEY)
const isXaiOAuth = computed(() => selectedPreset.value === XAI_OAUTH_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 })),
)
const selectedPresetProvider = computed(() =>
selectedPreset.value ? modelsStore.allProviders.find(g => g.provider === selectedPreset.value) : null,
)
const canEditPresetBaseUrl = computed(() => !!selectedPresetProvider.value?.base_url_env)
const FUN_LINK_MAP: Record<string, string> = {
'fun-codex': 'https://apikey.fun/register?aff=LIBAPI',
'fun-claude': 'https://apikey.fun/register?aff=LIBAPI',
}
const funProviderLink = computed(() => selectedPreset.value ? FUN_LINK_MAP[selectedPreset.value] || '' : '')
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 = selectedPresetProvider.value
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]
}
}
if (val === COPILOT_KEY) {
// 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow
void triggerCopilotAdd()
} else if (val === XAI_OAUTH_KEY) {
showXaiLogin.value = true
}
}
})
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 data = await fetchProviderModels({
base_url: base_url.trim(),
api_key: formData.value.api_key.trim(),
})
modelOptions.value = data.models.map(m => ({ label: m, value: m }))
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
}
// Copilot: 走 token-aware 的添加流程(已有 token → 确认窗;否则 device flow
if (isCopilot.value) {
void triggerCopilotAdd()
return
}
if (isXaiOAuth.value) {
showXaiLogin.value = true
return
}
if (!formData.value.base_url.trim()) {
message.warning(t('models.baseUrlRequired'))
return
}
if (!formData.value.api_key.trim() && !isCliproxyApi.value && !isXaiOAuth.value) {
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
const baseUrl = providerType.value === 'custom'
? normalizeCustomProviderBaseUrl(formData.value.base_url)
: formData.value.base_url.trim()
await modelsStore.addProvider({
name: formData.value.name.trim(),
base_url: baseUrl,
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')
}
async function handleCopilotSuccess() {
showCopilotLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
async function handleXaiSuccess() {
showXaiLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
function copilotSourceLabel(source: CopilotTokenSource): string {
if (source === 'env') return t('models.copilotAddSourceEnv')
if (source === 'gh-cli') return t('models.copilotAddSourceGhCli')
if (source === 'apps-json') return t('models.copilotAddSourceAppsJson')
return ''
}
async function triggerCopilotAdd() {
if (copilotChecking.value) return
copilotChecking.value = true
try {
const status = await checkCopilotToken()
if (status.has_token) {
// 已能解析到 token:弹确认窗,用户点 [添加] → enable + saved
const sourceText = copilotSourceLabel(status.source)
dialog.success({
title: t('models.copilotAddDetectedTitle'),
content: sourceText
? `${t('models.copilotAddDetected')}\n\n${sourceText}`
: t('models.copilotAddDetected'),
positiveText: t('common.add'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await enableCopilot()
message.success(t('models.providerAdded'))
emit('saved')
} catch (e: any) {
message.error(e?.message ?? String(e))
}
},
onNegativeClick: () => {
selectedPreset.value = null
},
onClose: () => {
selectedPreset.value = null
},
})
} else {
// 无 tokendevice flow
showCopilotLogin.value = true
}
} catch (e: any) {
message.error(e?.message ?? String(e))
selectedPreset.value = null
} finally {
copilotChecking.value = false
}
}
function handleCopilotClose() {
showCopilotLogin.value = false
// 用户取消 Copilot 引导时,清空选择避免卡在无 api_key 状态
selectedPreset.value = null
}
function handleXaiClose() {
showXaiLogin.value = false
selectedPreset.value = null
}
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 && !showCopilotLogin && !showXaiLogin"
@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
/>
<div v-if="selectedPreset && funProviderLink" class="fun-provider-hint">
<a :href="funProviderLink" target="_blank" rel="noopener noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
{{ t('models.getApiKey') }}
</a>
</div>
</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' && !canEditPresetBaseUrl"
/>
</NFormItem>
<NFormItem v-if="!isCodex && !isNous" :label="t('models.apiKey')" :required="!isCliproxyApi && !isXaiOAuth">
<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"
/>
<CopilotLoginModal
v-if="showCopilotLogin"
@close="handleCopilotClose"
@success="handleCopilotSuccess"
/>
<XaiOAuthLoginModal
v-if="showXaiLogin"
@close="handleXaiClose"
@success="handleXaiSuccess"
/>
</NModal>
</template>
<style scoped lang="scss">
.fun-provider-hint {
margin-top: 6px;
font-size: 12px;
a {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
white-space: nowrap;
color: var(--accent-primary);
text-decoration: none;
opacity: 0.7;
transition: opacity 0.2s;
svg {
flex-shrink: 0;
}
&:hover { opacity: 1; }
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import ProviderCard from './ProviderCard.vue'
import { useModelsStore } from '@/stores/hermes/models'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const modelsStore = useModelsStore()
</script>
<template>
<div v-if="modelsStore.providers.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<p>{{ t('models.noProviders') }}</p>
</div>
<div v-else class="providers-grid">
<ProviderCard
v-for="g in modelsStore.providers"
:key="g.provider"
:provider="g"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: $text-muted;
gap: 12px;
.empty-icon {
opacity: 0.3;
}
p {
font-size: 14px;
}
}
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
gap: 14px;
}
</style>
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startXaiLogin, pollXaiLogin } from '@/api/hermes/xai-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const authorizationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startXaiLogin()
authorizationUrl.value = data.authorization_url
sessionId.value = data.session_id
status.value = 'waiting'
window.open(authorizationUrl.value, '_blank')
startPolling()
} catch (err: any) {
status.value = 'error'
errorMessage.value = err?.message || String(err)
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollXaiLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.xaiApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 2000)
}
function stopPolling() {
if (pollTimer) clearTimeout(pollTimer)
pollTimer = null
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
function openLink() {
window.open(authorizationUrl.value, '_blank')
}
async function copyLink() {
const ok = await copyToClipboard(authorizationUrl.value)
if (ok) message.success(t('common.copied'))
else message.error(t('chat.copyFailed'))
}
function retry() {
status.value = 'idle'
authorizationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(stopPolling)
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.xaiLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="xai-login">
<div v-if="status === 'idle' || status === 'loading'" class="xai-login__state">
<NSpin size="small" />
</div>
<div v-else-if="status === 'waiting'" class="xai-login__state">
<p class="xai-login__hint">{{ t('models.xaiWaiting') }}</p>
<NButton type="primary" block @click="openLink">
{{ t('models.xaiOpenLink') }}
</NButton>
<NButton block @click="copyLink">
{{ t('models.xaiCopyLink') }}
</NButton>
</div>
<div v-else-if="status === 'approved'" class="xai-login__state xai-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.xaiApproved') }}</p>
</div>
<div v-else-if="status === 'expired'" class="xai-login__state">
<p class="xai-login__error">{{ t('models.xaiExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<div v-else-if="status === 'error'" class="xai-login__state">
<p class="xai-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.xai-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.xai-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.xai-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.xai-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.xai-login__error {
color: #d03050;
text-align: center;
word-break: break-word;
}
.modal-footer {
display: flex;
justify-content: flex-end;
}
</style>