feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {
|
||||
// 无 token:device 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>
|
||||
Reference in New Issue
Block a user