From 610f3eb9d08e0923da4c8e9727481a41163fd136 Mon Sep 17 00:00:00 2001 From: ww Date: Sun, 26 Apr 2026 22:51:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(copilot):=20integrate=20GitHub=20Copilot?= =?UTF-8?q?=20provider=20with=20dynamic=20model=20list=20/=20=E9=9B=86?= =?UTF-8?q?=E6=88=90=20GitHub=20Copilot=20provider=20=E4=B8=8E=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=20(#239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../client/src/api/hermes/copilot-auth.ts | 42 +++ packages/client/src/api/hermes/system.ts | 2 + .../hermes/models/CopilotLoginModal.vue | 243 ++++++++++++ .../components/hermes/models/ProviderCard.vue | 40 +- .../hermes/models/ProviderFormModal.vue | 89 ++++- .../src/components/layout/ModelSelector.vue | 48 ++- packages/client/src/i18n/locales/de.ts | 18 + packages/client/src/i18n/locales/en.ts | 18 + packages/client/src/i18n/locales/es.ts | 18 + packages/client/src/i18n/locales/fr.ts | 18 + packages/client/src/i18n/locales/ja.ts | 18 + packages/client/src/i18n/locales/ko.ts | 18 + packages/client/src/i18n/locales/pt.ts | 18 + packages/client/src/i18n/locales/zh.ts | 18 + packages/client/src/shared/providers.ts | 24 ++ packages/client/src/stores/hermes/chat.ts | 15 + .../client/src/views/hermes/ModelsView.vue | 6 +- .../src/controllers/hermes/copilot-auth.ts | 237 ++++++++++++ .../server/src/controllers/hermes/models.ts | 86 ++++- .../server/src/routes/hermes/copilot-auth.ts | 10 + packages/server/src/routes/index.ts | 2 + packages/server/src/services/app-config.ts | 43 +++ .../server/src/services/config-helpers.ts | 1 + .../services/hermes/copilot-device-flow.ts | 158 ++++++++ .../src/services/hermes/copilot-models.ts | 270 ++++++++++++++ packages/server/src/shared/providers.ts | 25 ++ tests/client/copilot-login-modal.test.ts | 133 +++++++ tests/server/copilot-auth-controller.test.ts | 172 +++++++++ tests/server/copilot-device-flow.test.ts | 139 +++++++ tests/server/copilot-models.test.ts | 351 ++++++++++++++++++ 30 files changed, 2264 insertions(+), 16 deletions(-) create mode 100644 packages/client/src/api/hermes/copilot-auth.ts create mode 100644 packages/client/src/components/hermes/models/CopilotLoginModal.vue create mode 100644 packages/server/src/controllers/hermes/copilot-auth.ts create mode 100644 packages/server/src/routes/hermes/copilot-auth.ts create mode 100644 packages/server/src/services/app-config.ts create mode 100644 packages/server/src/services/hermes/copilot-device-flow.ts create mode 100644 packages/server/src/services/hermes/copilot-models.ts create mode 100644 tests/client/copilot-login-modal.test.ts create mode 100644 tests/server/copilot-auth-controller.test.ts create mode 100644 tests/server/copilot-device-flow.test.ts create mode 100644 tests/server/copilot-models.test.ts diff --git a/packages/client/src/api/hermes/copilot-auth.ts b/packages/client/src/api/hermes/copilot-auth.ts new file mode 100644 index 00000000..e2733f84 --- /dev/null +++ b/packages/client/src/api/hermes/copilot-auth.ts @@ -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 { + return request('/api/hermes/auth/copilot/start', { method: 'POST' }) +} + +export async function pollCopilotLogin(sessionId: string): Promise { + return request(`/api/hermes/auth/copilot/poll/${sessionId}`) +} + +export async function checkCopilotToken(): Promise { + return request('/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' }) +} diff --git a/packages/client/src/api/hermes/system.ts b/packages/client/src/api/hermes/system.ts index fc359be9..920c3488 100644 --- a/packages/client/src/api/hermes/system.ts +++ b/packages/client/src/api/hermes/system.ts @@ -31,6 +31,8 @@ export interface AvailableModelGroup { base_url: string models: string[] api_key: string + /** 可选:模型 ID -> 元数据(preview/disabled)。目前仅 Copilot 提供。 */ + model_meta?: Record } export interface AvailableModelsResponse { diff --git a/packages/client/src/components/hermes/models/CopilotLoginModal.vue b/packages/client/src/components/hermes/models/CopilotLoginModal.vue new file mode 100644 index 00000000..179106e1 --- /dev/null +++ b/packages/client/src/components/hermes/models/CopilotLoginModal.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/packages/client/src/components/hermes/models/ProviderCard.vue b/packages/client/src/components/hermes/models/ProviderCard.vue index 9aad9448..14f1b793 100644 --- a/packages/client/src/components/hermes/models/ProviderCard.vue +++ b/packages/client/src/components/hermes/models/ProviderCard.vue @@ -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) diff --git a/packages/client/src/components/hermes/models/ProviderFormModal.vue b/packages/client/src/components/hermes/models/ProviderFormModal.vue index a574e5d1..997cefce 100644 --- a/packages/client/src/components/hermes/models/ProviderFormModal.vue +++ b/packages/client/src/components/hermes/models/ProviderFormModal.vue @@ -1,10 +1,12 @@