feat(copilot): integrate GitHub Copilot provider with dynamic model list / 集成 GitHub Copilot provider 与动态模型列表 (#239)
* feat(copilot): integrate GitHub Copilot provider with dynamic model list 集成 GitHub Copilot provider 与动态模型列表 EN: - New copilot-models service: fetch live model list from GitHub /models API - Filter noise IDs (accounts/, text-embedding, rerank prefixes) - Pass through preview/disabled metadata to frontend - Cache isolated per OAuth token (FNV-1a hash key) to prevent cross-account leak - Multi-source token resolution: env > apps.json > gh CLI - ModelSelector renders PREVIEW (orange) and UNAVAILABLE (gray, non-selectable) badges with tooltips - ProviderFormModal exposes Copilot OAuth login entry - New CopilotLoginModal component: guides gh auth login device flow - ProviderCard hides delete button for OAuth-only builtin providers (copilot/codex/nous) since their credentials live outside auth.json ZH: - 新增 copilot-models 服务:从 GitHub /models live API 拉取模型列表 - 噪音 ID 过滤(accounts/、text-embedding、rerank 前缀) - preview/disabled 元数据透传至前端 - 缓存按 OAuth token 隔离(FNV-1a hash key),避免切换 profile 串账号 - 多源 token 解析优先级:env > apps.json > gh CLI - ModelSelector 渲染 PREVIEW(橙色)/ UNAVAILABLE(灰色、不可选)badge,附 tooltip - ProviderFormModal 提供 Copilot OAuth 登录入口 - 新增 CopilotLoginModal 组件:引导 gh auth login 设备流程 - ProviderCard 对 OAuth-only builtin(copilot/codex/nous)隐藏删除按钮 其凭证不在 auth.json,删除按钮原本无效 Tests / 测试: new copilot-models suite (cache isolation, noise filter, preview/disabled passthrough) + copilot-login-modal — 24/24 passed. Pre-existing sessions-db-lineage failure on upstream/main is unrelated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(copilot): switch to explicit opt-in per maintainer feedback 回应 PR #239 review:上一版会自动把系统级 GitHub OAuth 凭证(VS Code Copilot 插件、gh CLI 登录态)当作 hermes provider 拉到列表里,对未在 hermes 中注册过 Copilot 的用户造成困扰。本次改为显式 opt-in:用户必须通过 Add Provider 主动添加, 删除时按 token 来源决定是否清 ~/.hermes/.env,并避免误清理 VS Code / gh CLI 用户的 全局凭证。 Address PR #239 review feedback. Previously Copilot would silently appear in the provider list whenever the host had any GitHub OAuth token (VS Code plugin, gh CLI login). This caused confusion for users who never explicitly registered Copilot in hermes. Now Copilot requires explicit opt-in via Add Provider; on delete we only clear ~/.hermes/.env when the token actually originated there, leaving VS Code / gh CLI credentials untouched. What changed - 新增 ~/.hermes-web-ui/config.json 的 copilotEnabled flag 控制可见性 - 即便能解析到 token,未启用时也不在列表中显示 - resolveCopilotOAuthTokenWithSource 区分 token 来源(env / gh-cli / apps-json) - ProviderFormModal 增加 GitHub Copilot 入口;无 token 时进 device flow modal - CopilotLoginModal 重写为 in-app device flow 状态机(不再要求用户在终端跑 gh) - 删除 Copilot 时仅 source='env' 才清 ~/.hermes/.env,并自动 fallback 默认模型 - 老用户升级兼容:若 default 仍指向已禁用的 copilot,后端清空 default 让前端兜底 API - POST /api/hermes/copilot-auth/check-token - POST /api/hermes/copilot-auth/enable - POST /api/hermes/copilot-auth/disable - POST /api/hermes/copilot-auth/start (device flow) - POST /api/hermes/copilot-auth/poll (device flow) Tests - tests/server/copilot-auth-controller.test.ts (11 cases) - tests/server/copilot-device-flow.test.ts (12 cases) - tests/client/copilot-login-modal.test.ts 重写覆盖状态机 Follow-ups (留作后续 PR) - device flow session 未绑定 profile,登录中切 profile 会写到错的 .env - copilot device-code 接口的 expires_in 字段未使用,硬编码 15 分钟超时 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { request } from '../client'
|
||||
|
||||
export type CopilotTokenSource = 'env' | 'gh-cli' | 'apps-json' | null
|
||||
|
||||
export interface CopilotStartResult {
|
||||
session_id: string
|
||||
user_code: string
|
||||
verification_url: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface CopilotPollResult {
|
||||
status: 'pending' | 'approved' | 'denied' | 'expired' | 'error'
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface CopilotCheckTokenResult {
|
||||
has_token: boolean
|
||||
source: CopilotTokenSource
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export async function startCopilotLogin(): Promise<CopilotStartResult> {
|
||||
return request<CopilotStartResult>('/api/hermes/auth/copilot/start', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function pollCopilotLogin(sessionId: string): Promise<CopilotPollResult> {
|
||||
return request<CopilotPollResult>(`/api/hermes/auth/copilot/poll/${sessionId}`)
|
||||
}
|
||||
|
||||
export async function checkCopilotToken(): Promise<CopilotCheckTokenResult> {
|
||||
return request<CopilotCheckTokenResult>('/api/hermes/auth/copilot/check-token')
|
||||
}
|
||||
|
||||
export async function enableCopilot(): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>('/api/hermes/auth/copilot/enable', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function disableCopilot(): Promise<{ ok: boolean; cleared_env: boolean; cleared_default?: boolean }> {
|
||||
return request<{ ok: boolean; cleared_env: boolean; cleared_default?: boolean }>('/api/hermes/auth/copilot/disable', { method: 'POST' })
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export interface AvailableModelGroup {
|
||||
base_url: string
|
||||
models: string[]
|
||||
api_key: string
|
||||
/** 可选:模型 ID -> 元数据(preview/disabled)。目前仅 Copilot 提供。 */
|
||||
model_meta?: Record<string, { preview?: boolean; disabled?: boolean }>
|
||||
}
|
||||
|
||||
export interface AvailableModelsResponse {
|
||||
|
||||
@@ -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>
|
||||
@@ -3,29 +3,65 @@ import { ref, computed } from 'vue'
|
||||
import { NButton, 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.provider.startsWith('custom:'))
|
||||
const isCopilot = computed(() => props.provider.provider === 'copilot')
|
||||
const displayName = computed(() => props.provider.label)
|
||||
const deleting = ref(false)
|
||||
|
||||
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: t('models.deleteConfirm', { name: displayName.value }),
|
||||
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 {
|
||||
await modelsStore.removeProvider(props.provider.provider)
|
||||
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 Promise.all([modelsStore.fetchProviders(), appStore.loadModels()])
|
||||
} 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)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NInputNumber, NButton, NSelect, NRadioGroup, NRadioButton, useMessage } from 'naive-ui'
|
||||
import { 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 { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -15,12 +17,15 @@ const emit = defineEmits<{
|
||||
|
||||
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 copilotChecking = ref(false)
|
||||
|
||||
const providerType = ref<'preset' | 'custom'>('preset')
|
||||
const selectedPreset = ref<string | null>(null)
|
||||
@@ -36,6 +41,7 @@ const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const CODEX_KEY = 'openai-codex'
|
||||
const NOUS_KEY = 'nous'
|
||||
const COPILOT_KEY = 'copilot'
|
||||
const ALIBABA_CODING_KEY = 'alibaba-coding-plan'
|
||||
const ALIBABA_CODING_REGIONS = {
|
||||
intl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
@@ -44,6 +50,7 @@ const ALIBABA_CODING_REGIONS = {
|
||||
|
||||
const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
|
||||
const isNous = computed(() => selectedPreset.value === NOUS_KEY)
|
||||
const isCopilot = computed(() => selectedPreset.value === COPILOT_KEY)
|
||||
const isAlibabaCoding = computed(() => selectedPreset.value === ALIBABA_CODING_KEY)
|
||||
const alibabaCodingRegion = ref<'intl' | 'cn'>('intl')
|
||||
|
||||
@@ -73,6 +80,10 @@ watch(selectedPreset, (val) => {
|
||||
formData.value.model = group.models[0]
|
||||
}
|
||||
}
|
||||
if (val === COPILOT_KEY) {
|
||||
// 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow
|
||||
void triggerCopilotAdd()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -150,6 +161,12 @@ async function handleSave() {
|
||||
return
|
||||
}
|
||||
|
||||
// Copilot: 走 token-aware 的添加流程(已有 token → 确认窗;否则 device flow)
|
||||
if (isCopilot.value) {
|
||||
void triggerCopilotAdd()
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.base_url.trim()) {
|
||||
message.warning(t('models.baseUrlRequired'))
|
||||
return
|
||||
@@ -199,6 +216,68 @@ async function handleNousSuccess() {
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
async function handleCopilotSuccess() {
|
||||
showCopilotLogin.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 handleClose() {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
@@ -211,7 +290,7 @@ function handleClose() {
|
||||
preset="card"
|
||||
:title="t('models.addProvider')"
|
||||
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!loading && !showCodexLogin && !showNousLogin"
|
||||
:mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<NForm label-placement="top">
|
||||
@@ -326,6 +405,12 @@ function handleClose() {
|
||||
@close="showNousLogin = false"
|
||||
@success="handleNousSuccess"
|
||||
/>
|
||||
|
||||
<CopilotLoginModal
|
||||
v-if="showCopilotLogin"
|
||||
@close="handleCopilotClose"
|
||||
@success="handleCopilotSuccess"
|
||||
/>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ function isGroupCollapsed(provider: string) {
|
||||
}
|
||||
|
||||
function handleSelect(model: string, provider: string) {
|
||||
const meta = appStore.modelGroups.find(g => g.provider === provider)?.model_meta?.[model]
|
||||
if (meta?.disabled) return
|
||||
appStore.switchModel(model, provider)
|
||||
showModal.value = false
|
||||
searchQuery.value = ''
|
||||
@@ -65,6 +67,9 @@ function handleSelect(model: string, provider: string) {
|
||||
function handleCustomSubmit() {
|
||||
const model = customInput.value.trim()
|
||||
if (!model || !customProvider.value) return
|
||||
// 拦截 disabled 模型,避免 custom input 绕过列表里的灰显限制
|
||||
const meta = appStore.modelGroups.find(g => g.provider === customProvider.value)?.model_meta?.[model]
|
||||
if (meta?.disabled) return
|
||||
appStore.switchModel(model, customProvider.value)
|
||||
showModal.value = false
|
||||
searchQuery.value = ''
|
||||
@@ -122,10 +127,16 @@ function openModal() {
|
||||
v-for="model in group.models"
|
||||
:key="model"
|
||||
class="model-item"
|
||||
:class="{ active: model === appStore.selectedModel && group.provider === appStore.selectedProvider }"
|
||||
:class="{
|
||||
active: model === appStore.selectedModel && group.provider === appStore.selectedProvider,
|
||||
disabled: !!group.model_meta?.[model]?.disabled,
|
||||
}"
|
||||
:title="group.model_meta?.[model]?.disabled ? t('models.disabledTooltip') : ''"
|
||||
@click="handleSelect(model, group.provider)"
|
||||
>
|
||||
<span class="model-item-name">{{ model }}</span>
|
||||
<span v-if="group.model_meta?.[model]?.preview" class="model-badge-preview">{{ t('models.previewBadge') }}</span>
|
||||
<span v-if="group.model_meta?.[model]?.disabled" class="model-badge-disabled">{{ t('models.disabledBadge') }}</span>
|
||||
<span v-if="customModelSet.has(model)" class="model-badge-custom">{{ t('models.customBadge') }}</span>
|
||||
<svg v-if="model === appStore.selectedModel && group.provider === appStore.selectedProvider" class="model-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
@@ -285,6 +296,16 @@ function openModal() {
|
||||
color: $accent-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.model-item-name {
|
||||
@@ -313,6 +334,31 @@ function openModal() {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.model-badge-preview {
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #d97706;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.model-badge-disabled {
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
background: transparent;
|
||||
border: 1px solid $border-color;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.model-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: 'Login erfolgreich',
|
||||
nousDenied: 'Autorisierung wurde abgelehnt',
|
||||
nousExpired: 'Autorisierung abgelaufen',
|
||||
copilotLoginTitle: 'GitHub Copilot Anmeldung',
|
||||
copilotWaiting: 'Öffnen Sie GitHub und geben Sie den unten angezeigten Gerätecode ein. Das Fenster schließt sich automatisch nach Genehmigung.',
|
||||
copilotCopyCode: 'Code kopiert',
|
||||
copilotOpenLink: 'GitHub-Autorisierungsseite öffnen',
|
||||
copilotApproved: 'Anmeldung erfolgreich!',
|
||||
copilotDenied: 'Autorisierung abgelehnt.',
|
||||
copilotExpired: 'Der Autorisierungslink ist abgelaufen. Bitte erneut versuchen.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot erkannt',
|
||||
copilotAddDetected: 'Auf diesem Rechner wurde ein GitHub Copilot OAuth-Token erkannt. Klicken Sie auf „Hinzufügen", um Copilot in Hermes zu aktivieren.',
|
||||
copilotAddSourceEnv: 'Quelle: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'Quelle: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'Quelle: VS Code Copilot-Erweiterung (apps.json)',
|
||||
copilotDeleteHintEnv: 'Dies löscht COPILOT_GITHUB_TOKEN in ~/.hermes/.env. Andere Tools sind nicht betroffen.',
|
||||
copilotDeleteHintGhCli: 'Copilot wird aus Hermes ausgeblendet. Ihre gh CLI-Anmeldung bleibt erhalten — `gh auth status` zeigt weiterhin als angemeldet.',
|
||||
copilotDeleteHintAppsJson: 'Copilot wird aus Hermes ausgeblendet. Ihre VS Code Copilot-Erweiterung bleibt angemeldet.',
|
||||
customBadge: 'BENUTZERDEF.',
|
||||
previewBadge: 'VORSCHAU',
|
||||
disabledBadge: 'NICHT VERFÜGBAR',
|
||||
disabledTooltip: "Dieses Modell ist für Ihr Konto derzeit nicht verfügbar.",
|
||||
customModelPlaceholder: 'Benutzerdefinierter Modellname',
|
||||
customModelHint: 'Enter zum Laden',
|
||||
noProviders: 'Keine Anbieter gefunden. Fugen Sie einen benutzerdefinierten Anbieter hinzu, um zu beginnen.',
|
||||
|
||||
@@ -288,7 +288,25 @@ export default {
|
||||
nousApproved: 'Login successful',
|
||||
nousDenied: 'Authorization was denied. Please try again.',
|
||||
nousExpired: 'Authorization expired. Please try again.',
|
||||
copilotLoginTitle: 'GitHub Copilot Login',
|
||||
copilotWaiting: 'Open GitHub and enter the device code below to authorize. The window will close automatically once approved.',
|
||||
copilotCopyCode: 'Code copied',
|
||||
copilotOpenLink: 'Open GitHub authorization page',
|
||||
copilotApproved: 'Sign-in succeeded!',
|
||||
copilotDenied: 'Authorization denied.',
|
||||
copilotExpired: 'The authorization link has expired. Please retry.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot detected',
|
||||
copilotAddDetected: 'A GitHub Copilot OAuth token was detected on this machine. Click Add to enable Copilot in Hermes.',
|
||||
copilotAddSourceEnv: 'Source: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'Source: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'Source: VS Code Copilot extension (apps.json)',
|
||||
copilotDeleteHintEnv: 'This will clear COPILOT_GITHUB_TOKEN in ~/.hermes/.env. Other tools are not affected.',
|
||||
copilotDeleteHintGhCli: 'Copilot will be hidden from Hermes. Your gh CLI login is not affected — `gh auth status` will still show you signed in.',
|
||||
copilotDeleteHintAppsJson: 'Copilot will be hidden from Hermes. Your VS Code Copilot extension login is not affected.',
|
||||
customBadge: 'CUSTOM',
|
||||
previewBadge: 'PREVIEW',
|
||||
disabledBadge: 'UNAVAILABLE',
|
||||
disabledTooltip: "This model is currently unavailable for your account.",
|
||||
customModelPlaceholder: 'Custom model name',
|
||||
customModelHint: 'Enter to load',
|
||||
noProviders: 'No providers found. Add a custom provider to get started.',
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: 'Inicio de sesión exitoso',
|
||||
nousDenied: 'Autorización denegada',
|
||||
nousExpired: 'Autorización expirada',
|
||||
copilotLoginTitle: 'Inicio de sesión de GitHub Copilot',
|
||||
copilotWaiting: 'Abra GitHub e introduzca el código de dispositivo a continuación para autorizar. La ventana se cerrará automáticamente tras la aprobación.',
|
||||
copilotCopyCode: 'Código copiado',
|
||||
copilotOpenLink: 'Abrir la página de autorización de GitHub',
|
||||
copilotApproved: '¡Inicio de sesión exitoso!',
|
||||
copilotDenied: 'Autorización denegada.',
|
||||
copilotExpired: 'El enlace de autorización ha caducado. Vuelva a intentarlo.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot detectado',
|
||||
copilotAddDetected: 'Se detectó un token OAuth de GitHub Copilot en este equipo. Haz clic en Agregar para habilitar Copilot en Hermes.',
|
||||
copilotAddSourceEnv: 'Origen: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'Origen: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'Origen: extensión Copilot de VS Code (apps.json)',
|
||||
copilotDeleteHintEnv: 'Esto borrará COPILOT_GITHUB_TOKEN en ~/.hermes/.env. Otras herramientas no se verán afectadas.',
|
||||
copilotDeleteHintGhCli: 'Copilot se ocultará de Hermes. Tu sesión de gh CLI no se verá afectada — `gh auth status` seguirá mostrando que estás conectado.',
|
||||
copilotDeleteHintAppsJson: 'Copilot se ocultará de Hermes. La extensión Copilot de VS Code seguirá conectada.',
|
||||
customBadge: 'PERSONALIZADO',
|
||||
previewBadge: 'VISTA PREVIA',
|
||||
disabledBadge: 'NO DISPONIBLE',
|
||||
disabledTooltip: "Este modelo no está disponible para tu cuenta.",
|
||||
customModelPlaceholder: 'Nombre del modelo personalizado',
|
||||
customModelHint: 'Enter para cargar',
|
||||
noProviders: 'No se encontraron proveedores. Anade un proveedor personalizado para comenzar.',
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: 'Connexion réussie',
|
||||
nousDenied: 'Autorisation refusée',
|
||||
nousExpired: 'Autorisation expirée',
|
||||
copilotLoginTitle: 'Connexion GitHub Copilot',
|
||||
copilotWaiting: 'Ouvrez GitHub et saisissez le code ci-dessous pour autoriser. La fenêtre se fermera automatiquement après approbation.',
|
||||
copilotCopyCode: 'Code copié',
|
||||
copilotOpenLink: 'Ouvrir la page d\'autorisation GitHub',
|
||||
copilotApproved: 'Connexion réussie !',
|
||||
copilotDenied: 'Autorisation refusée.',
|
||||
copilotExpired: 'Le lien d\'autorisation a expiré. Veuillez réessayer.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot détecté',
|
||||
copilotAddDetected: 'Un token OAuth GitHub Copilot a été détecté sur cette machine. Cliquez sur Ajouter pour activer Copilot dans Hermes.',
|
||||
copilotAddSourceEnv: 'Source : ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'Source : gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'Source : extension Copilot de VS Code (apps.json)',
|
||||
copilotDeleteHintEnv: 'Cela supprimera COPILOT_GITHUB_TOKEN dans ~/.hermes/.env. Les autres outils ne sont pas affectés.',
|
||||
copilotDeleteHintGhCli: 'Copilot sera masqué dans Hermes. Votre connexion gh CLI n\'est pas affectée — `gh auth status` indiquera toujours que vous êtes connecté.',
|
||||
copilotDeleteHintAppsJson: 'Copilot sera masqué dans Hermes. Votre extension Copilot de VS Code reste connectée.',
|
||||
customBadge: 'PERSONNALISÉ',
|
||||
previewBadge: 'APERÇU',
|
||||
disabledBadge: 'INDISPONIBLE',
|
||||
disabledTooltip: "Ce modèle n'est pas disponible pour votre compte.",
|
||||
customModelPlaceholder: 'Nom du modèle personnalisé',
|
||||
customModelHint: 'Entrée pour charger',
|
||||
noProviders: 'Aucun fournisseur trouve. Ajoutez un fournisseur personnalise pour commencer.',
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: 'ログイン成功',
|
||||
nousDenied: '認証が拒否されました',
|
||||
nousExpired: '認証の有効期限が切れました',
|
||||
copilotLoginTitle: 'GitHub Copilot ログイン',
|
||||
copilotWaiting: 'GitHub を開き、以下のデバイスコードを入力して認証してください。承認後、ウィンドウは自動的に閉じます。',
|
||||
copilotCopyCode: 'コードをコピーしました',
|
||||
copilotOpenLink: 'GitHub 認証ページを開く',
|
||||
copilotApproved: 'ログインに成功しました!',
|
||||
copilotDenied: '認証が拒否されました。',
|
||||
copilotExpired: '認証リンクの有効期限が切れました。もう一度お試しください。',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot を検出しました',
|
||||
copilotAddDetected: 'このマシンで GitHub Copilot OAuth トークンを検出しました。「追加」をクリックして Hermes で Copilot を有効化します。',
|
||||
copilotAddSourceEnv: 'ソース: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'ソース: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'ソース: VS Code Copilot 拡張機能 (apps.json)',
|
||||
copilotDeleteHintEnv: 'この操作で ~/.hermes/.env の COPILOT_GITHUB_TOKEN を消去します。他のツールには影響しません。',
|
||||
copilotDeleteHintGhCli: 'Copilot は Hermes 上で非表示になります。gh CLI のログインには影響しません — `gh auth status` は引き続きログイン状態を表示します。',
|
||||
copilotDeleteHintAppsJson: 'Copilot は Hermes 上で非表示になります。VS Code Copilot 拡張機能のログインには影響しません。',
|
||||
customBadge: 'カスタム',
|
||||
previewBadge: 'プレビュー',
|
||||
disabledBadge: '利用不可',
|
||||
disabledTooltip: "このモデルは現在のアカウントでは利用できません。",
|
||||
customModelPlaceholder: 'カスタムモデル名',
|
||||
customModelHint: 'Enterで読み込み',
|
||||
noProviders: 'プロバイダーがありません。カスタムプロバイダーを追加して始めましょう。',
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: '로그인 성공',
|
||||
nousDenied: '인증이 거부되었습니다',
|
||||
nousExpired: '인증이 만료되었습니다',
|
||||
copilotLoginTitle: 'GitHub Copilot 로그인',
|
||||
copilotWaiting: 'GitHub을 열고 아래의 디바이스 코드를 입력하여 인증하세요. 승인 후 창이 자동으로 닫힙니다.',
|
||||
copilotCopyCode: '코드가 복사되었습니다',
|
||||
copilotOpenLink: 'GitHub 인증 페이지 열기',
|
||||
copilotApproved: '로그인 성공!',
|
||||
copilotDenied: '인증이 거부되었습니다.',
|
||||
copilotExpired: '인증 링크가 만료되었습니다. 다시 시도하세요.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot 감지됨',
|
||||
copilotAddDetected: '이 컴퓨터에서 GitHub Copilot OAuth 토큰이 감지되었습니다. 추가를 클릭하여 Hermes에서 Copilot을 활성화하세요.',
|
||||
copilotAddSourceEnv: '출처: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: '출처: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: '출처: VS Code Copilot 확장 (apps.json)',
|
||||
copilotDeleteHintEnv: '이 작업은 ~/.hermes/.env의 COPILOT_GITHUB_TOKEN을 지웁니다. 다른 도구에는 영향이 없습니다.',
|
||||
copilotDeleteHintGhCli: 'Copilot이 Hermes에서 숨겨집니다. gh CLI 로그인에는 영향이 없으며 `gh auth status`는 여전히 로그인 상태를 표시합니다.',
|
||||
copilotDeleteHintAppsJson: 'Copilot이 Hermes에서 숨겨집니다. VS Code Copilot 확장 로그인에는 영향이 없습니다.',
|
||||
customBadge: '커스텀',
|
||||
previewBadge: '프리뷰',
|
||||
disabledBadge: '사용 불가',
|
||||
disabledTooltip: "이 모델은 현재 계정에서 사용할 수 없습니다.",
|
||||
customModelPlaceholder: '사용자 지정 모델 이름',
|
||||
customModelHint: 'Enter로 불러오기',
|
||||
noProviders: 'Provider가 없습니다. 사용자 지정 Provider를 추가하여 시작하세요.',
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: 'Login bem-sucedido',
|
||||
nousDenied: 'Autorização negada',
|
||||
nousExpired: 'Autorização expirada',
|
||||
copilotLoginTitle: 'Login do GitHub Copilot',
|
||||
copilotWaiting: 'Abra o GitHub e insira o código do dispositivo abaixo para autorizar. A janela fechará automaticamente após a aprovação.',
|
||||
copilotCopyCode: 'Código copiado',
|
||||
copilotOpenLink: 'Abrir a página de autorização do GitHub',
|
||||
copilotApproved: 'Login bem-sucedido!',
|
||||
copilotDenied: 'Autorização negada.',
|
||||
copilotExpired: 'O link de autorização expirou. Tente novamente.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot detectado',
|
||||
copilotAddDetected: 'Foi detectado um token OAuth do GitHub Copilot nesta máquina. Clique em Adicionar para ativar o Copilot no Hermes.',
|
||||
copilotAddSourceEnv: 'Origem: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'Origem: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'Origem: extensão Copilot do VS Code (apps.json)',
|
||||
copilotDeleteHintEnv: 'Isto irá limpar o COPILOT_GITHUB_TOKEN em ~/.hermes/.env. Outras ferramentas não são afetadas.',
|
||||
copilotDeleteHintGhCli: 'O Copilot ficará oculto no Hermes. Sua sessão no gh CLI não é afetada — `gh auth status` continuará indicando que está conectado.',
|
||||
copilotDeleteHintAppsJson: 'O Copilot ficará oculto no Hermes. A extensão Copilot do VS Code continuará conectada.',
|
||||
customBadge: 'PERSONALIZADO',
|
||||
previewBadge: 'PRÉVIA',
|
||||
disabledBadge: 'INDISPONÍVEL',
|
||||
disabledTooltip: "Este modelo não está disponível para sua conta.",
|
||||
customModelPlaceholder: 'Nome do modelo personalizado',
|
||||
customModelHint: 'Enter para carregar',
|
||||
noProviders: 'Nenhum provedor encontrado. Adicione um provedor personalizado para comecar.',
|
||||
|
||||
@@ -288,7 +288,25 @@ export default {
|
||||
nousApproved: '登录成功',
|
||||
nousDenied: '授权被拒绝,请重试。',
|
||||
nousExpired: '授权已过期,请重试。',
|
||||
copilotLoginTitle: 'GitHub Copilot 登录',
|
||||
copilotWaiting: '请前往 GitHub 输入下方设备代码完成授权。授权完成后窗口会自动关闭。',
|
||||
copilotCopyCode: '代码已复制',
|
||||
copilotOpenLink: '打开 GitHub 授权页',
|
||||
copilotApproved: '登录成功!',
|
||||
copilotDenied: '授权被拒绝。',
|
||||
copilotExpired: '授权链接已过期,请重试。',
|
||||
copilotAddDetectedTitle: '检测到 GitHub Copilot',
|
||||
copilotAddDetected: '已在本机检测到 GitHub Copilot OAuth 凭证,点击「添加」即可在 Hermes 中启用 Copilot。',
|
||||
copilotAddSourceEnv: '来源:~/.hermes/.env(COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: '来源:gh CLI(gh auth token)',
|
||||
copilotAddSourceAppsJson: '来源:VS Code Copilot 插件(apps.json)',
|
||||
copilotDeleteHintEnv: '此操作会清除 ~/.hermes/.env 中的 COPILOT_GITHUB_TOKEN,不影响其他工具。',
|
||||
copilotDeleteHintGhCli: 'Copilot 将从 Hermes 列表移除。不会影响 gh CLI —— `gh auth status` 仍显示已登录。',
|
||||
copilotDeleteHintAppsJson: 'Copilot 将从 Hermes 列表移除。不会影响 VS Code Copilot 插件的登录。',
|
||||
customBadge: '自定义',
|
||||
previewBadge: '预览',
|
||||
disabledBadge: '不可用',
|
||||
disabledTooltip: "此模型当前账号不可用",
|
||||
customModelPlaceholder: '自定义模型名称',
|
||||
customModelHint: '按回车加载',
|
||||
noProviders: '暂无 Provider,添加一个开始吧。',
|
||||
|
||||
@@ -259,6 +259,30 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
label: 'GitHub Copilot',
|
||||
value: 'copilot',
|
||||
base_url: 'https://api.githubcopilot.com',
|
||||
models: [
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5-mini',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-4.1',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'claude-sonnet-4.6',
|
||||
'claude-sonnet-4',
|
||||
'claude-sonnet-4.5',
|
||||
'claude-haiku-4.5',
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-2.5-pro',
|
||||
'grok-code-fast-1',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Build a Record<providerKey, models[]> for backend lookup */
|
||||
|
||||
@@ -1205,6 +1205,20 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function clearProviderFromSessions(provider: string) {
|
||||
if (!provider) return
|
||||
const target = provider.toLowerCase()
|
||||
let dirty = false
|
||||
for (const s of sessions.value) {
|
||||
if ((s.provider || '').toLowerCase() === target) {
|
||||
s.model = undefined
|
||||
s.provider = ''
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
if (dirty) persistSessionsList()
|
||||
}
|
||||
|
||||
function clearThinkingObservationFor(_sessionId: string) {
|
||||
// messageId 与 sessionId 的关联未单独持有;方案是切会话时一律清空。
|
||||
// 这符合 spec 定义:observation 是"当前会话范围内"的 transient 状态。
|
||||
@@ -1227,6 +1241,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
newChat,
|
||||
switchSession,
|
||||
switchSessionModel,
|
||||
clearProviderFromSessions,
|
||||
deleteSession,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
|
||||
@@ -6,13 +6,17 @@ import ProvidersPanel from '@/components/hermes/models/ProvidersPanel.vue'
|
||||
import ProviderFormModal from '@/components/hermes/models/ProviderFormModal.vue'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { checkCopilotToken } from '@/api/hermes/copilot-auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
const appStore = useAppStore()
|
||||
const showModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// 先 invalidate 后端 copilot 缓存(gh logout / VS Code 退出后下一次 list 立刻反映),
|
||||
// 再拉 providers。check-token 失败不阻断。
|
||||
try { await checkCopilotToken() } catch { /* ignore */ }
|
||||
modelsStore.fetchProviders()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user