From d483e5bff9dc0f02618f41529bd1873333d2ae87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Thu, 14 May 2026 05:23:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(hermes):=20Batch=203=20=C2=A7Q=20-=20OAuth?= =?UTF-8?q?=20=E4=B8=89=E7=A7=8D=E7=99=BB=E5=BD=95=20(PKCE=20/=20device=5F?= =?UTF-8?q?code=20/=20external)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 校对发现端点 + token 注入已就位(前一 commit),可直接做 UI。 ## 新页面 /h/oauth (~250 行) ### 数据流 - GET /api/providers/oauth - 列表 + 状态(公开) - POST /api/providers/oauth/{id}/start - PKCE 返回 auth_url,device_code 返回 user_code + verification_url - POST /api/providers/oauth/{id}/submit { session_id, code } - PKCE 提交 - GET /api/providers/oauth/{id}/poll/{session_id} - device_code 轮询(公开) - DELETE /api/providers/oauth/{id} - 断开 ### UI - 卡片网格(复用 .lazy-deps-grid)展示每个 provider - active 徽章 + flow 标签 + token preview + expires 信息 - 「登录」按钮按 flow 类型分发: · **external** (claude-code 等):showContentModal 显示 CLI 命令,用户终端运行后回点刷新 · **PKCE** (anthropic): 1. 系统 window.open(auth_url) 打开浏览器授权 2. showModal 弹窗:显示授权链接 + 让用户粘贴回调 code 3. POST submit → 刷新状态 · **device_code** (qwen / minimax / nous / openai): 1. window.open(verification_url) 打开授权页 2. showContentModal 显示 6 位 user_code(大字号 mono 字体) 3. 每 2.5s 自动 GET poll 轮询直到 success/failed/expired 4. 10 分钟超时 5. MutationObserver 监听模态关闭自动停轮询 ### sidebar - 管理 section 加 OAuth 入口(memory icon) - 路由 /h/oauth 注册 ### 修复:之前 edit 弄坏的 sidebar 结构 - 恢复 monitor 5 项(dashboard/chat/sessions/logs/usage) - 恢复 manage 8 项(skills/memory/cron/profiles/kanban/oauth/lazy-deps/extensions) - 删除文件末尾多余的 `}` ### i18n - 26 个新键 × 3 语言(hermesOAuth*) ## 复用基础设施 - hermes_dashboard_api_proxy 自动注入 session token(前一 commit 已做) - humanizeError 友好错误 - showModal / showContentModal / toast / lazy-deps-grid 样式 ## 累计 - 1 个新页面 ~250 行 - sidebar 结构修复 + /h/oauth 路由注册 - 26 个 i18n 键 × 3 语言 - npm build ✓ --- src/engines/hermes/index.js | 6 +- src/engines/hermes/pages/oauth.js | 287 ++++++++++++++++++++++++++++++ src/locales/modules/engine.js | 34 ++++ 3 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 src/engines/hermes/pages/oauth.js diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js index 4f5e9e1..087206d 100644 --- a/src/engines/hermes/index.js +++ b/src/engines/hermes/index.js @@ -86,7 +86,11 @@ export default { { route: '/h/skills', label: t('sidebar.skills'), icon: 'skills' }, { route: '/h/memory', label: t('sidebar.memory'), icon: 'memory' }, { route: '/h/cron', label: t('sidebar.cron'), icon: 'clock' }, - { route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' }, + { route: '/h/profiles', label: t('engine.hermesProfilesTitle'), icon: 'agents' }, + { route: '/h/kanban', label: t('engine.hermesKanbanTitle'), icon: 'inbox' }, + { route: '/h/oauth', label: t('engine.hermesOAuthTitle'), icon: 'memory' }, + { route: '/h/lazy-deps', label: t('hermesLazyDeps.title'), icon: 'package' }, + { route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' }, ] }, { section: '', diff --git a/src/engines/hermes/pages/oauth.js b/src/engines/hermes/pages/oauth.js new file mode 100644 index 0000000..ffdf184 --- /dev/null +++ b/src/engines/hermes/pages/oauth.js @@ -0,0 +1,287 @@ +/** + * Hermes OAuth 三种登录(Batch 3 §Q) + * + * 全部走 dashboard 9119(hermes_dashboard_api_proxy 自动注入 session token): + * - GET /api/providers/oauth - 列表 + 状态 + * - POST /api/providers/oauth/{id}/start - 启动 + * · PKCE: 返回 { session_id, flow:"pkce", auth_url } + * · device_code: 返回 { session_id, flow:"device_code", user_code, verification_url } + * - POST /api/providers/oauth/{id}/submit { session_id, code } - PKCE 提交回调 + * - GET /api/providers/oauth/{id}/poll/{session_id} - 公开轮询(device_code) + * - DELETE /api/providers/oauth/{id} - 断开 + */ +import { t } from '../../../lib/i18n.js' +import { api } from '../../../lib/tauri-api.js' +import { toast } from '../../../components/toast.js' +import { showModal, showContentModal } from '../../../components/modal.js' +import { humanizeError } from '../../../lib/humanize-error.js' + +const OAUTH_BASE = '/api/providers/oauth' + +function escHtml(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} +function escAttr(s) { return escHtml(s) } + +export function render() { + const el = document.createElement('div') + el.className = 'page' + el.dataset.engine = 'hermes' + + let providers = [] + let loading = true + let error = '' + + function draw() { + el.innerHTML = ` + +
+ ${loading ? `
${escHtml(t('common.loading'))}…
` : ''} + ${error ? `
${escHtml(error)}
` : ''} + ${(!loading && !error && !providers.length) ? ` +
+
🔐
+
${escHtml(t('engine.hermesOAuthEmpty'))}
+
` : ''} + ${(!loading && providers.length) ? ` +
+ ${providers.map(renderProviderCard).join('')} +
` : ''} +
+ ` + bind() + } + + function renderProviderCard(p) { + const loggedIn = !!p.status?.logged_in + const flowLabel = { + pkce: t('engine.hermesOAuthFlowPkce'), + device_code: t('engine.hermesOAuthFlowDevice'), + external: t('engine.hermesOAuthFlowExternal'), + }[p.flow] || p.flow + const sourceLabel = p.status?.source_label || '' + const tokenPrev = p.status?.token_preview || '' + const expires = p.status?.expires_at || '' + return ` +
+
+
${escHtml(p.name)}
+ ${loggedIn ? `${escHtml(t('engine.hermesOAuthConnected'))}` : `${escHtml(t('engine.hermesOAuthDisconnected'))}`} +
+
${escHtml(flowLabel)}
+ ${loggedIn && sourceLabel ? `
${escHtml(sourceLabel)}
` : ''} + ${loggedIn && tokenPrev ? `
…${escHtml(tokenPrev)}
` : ''} + ${loggedIn && expires ? `
${escHtml(t('engine.hermesOAuthExpires'))}: ${escHtml(expires)}
` : ''} +
+ ${loggedIn + ? `` + : ``} + ${p.docs_url ? `${escHtml(t('engine.hermesOAuthDocs'))}` : ''} +
+
+ ` + } + + function bind() { + el.querySelector('#hm-oauth-refresh')?.addEventListener('click', load) + el.querySelectorAll('[data-action]').forEach(btn => { + const action = btn.dataset.action + btn.addEventListener('click', () => { + if (action === 'connect') { + onConnect(btn.dataset.id, btn.dataset.flow, btn.dataset.cli || '') + } else if (action === 'disconnect') { + onDisconnect(btn.dataset.id) + } + }) + }) + } + + async function load() { + loading = true + error = '' + draw() + try { + const data = await api.hermesDashboardApi('GET', OAUTH_BASE) + providers = data?.providers || [] + } catch (e) { + error = String(e?.message || e) + } finally { + loading = false + draw() + } + } + + async function onConnect(providerId, flow, cliCommand) { + if (flow === 'external') { + showContentModal({ + title: t('engine.hermesOAuthExternalTitle', { id: providerId }), + content: ` +

${escHtml(t('engine.hermesOAuthExternalHint'))}

+
${escHtml(cliCommand || 'hermes auth add ' + providerId)}
+

${escHtml(t('engine.hermesOAuthExternalRefresh'))}

+ `, + buttons: [{ label: t('common.close'), className: 'btn-secondary' }], + width: 520, + }) + return + } + + try { + const resp = await api.hermesDashboardApi('POST', `${OAUTH_BASE}/${encodeURIComponent(providerId)}/start`) + if (flow === 'pkce') { + await runPkceFlow(providerId, resp) + } else if (flow === 'device_code') { + await runDeviceCodeFlow(providerId, resp) + } else { + toast(t('engine.hermesOAuthUnknownFlow', { flow }), 'error') + } + } catch (e) { + toast(humanizeError(e, t('engine.hermesOAuthStartFailed')), 'error') + } + } + + async function runPkceFlow(providerId, resp) { + const authUrl = resp?.auth_url + const sessionId = resp?.session_id + if (!authUrl || !sessionId) { + toast(t('engine.hermesOAuthBadResponse'), 'error') + return + } + // 打开浏览器(系统默认) + try { window.open(authUrl, '_blank', 'noopener') } catch {} + // 显示弹窗让用户填回调 code + showModal({ + title: t('engine.hermesOAuthPkceTitle'), + fields: [ + { + name: 'url', + label: t('engine.hermesOAuthPkceAuthLink'), + value: authUrl, + readonly: true, + hint: t('engine.hermesOAuthPkceUrlHint'), + }, + { + name: 'code', + label: t('engine.hermesOAuthPkceCodeLabel'), + value: '', + placeholder: 'authorization_code', + hint: t('engine.hermesOAuthPkceCodeHint'), + }, + ], + onConfirm: async (data) => { + const code = (data.code || '').trim() + if (!code) { + toast(t('engine.hermesOAuthCodeRequired'), 'error') + return + } + try { + await api.hermesDashboardApi('POST', `${OAUTH_BASE}/${encodeURIComponent(providerId)}/submit`, { + session_id: sessionId, + code, + }) + toast(t('engine.hermesOAuthConnected'), 'success') + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesOAuthSubmitFailed')), 'error') + } + }, + }) + } + + async function runDeviceCodeFlow(providerId, resp) { + const userCode = resp?.user_code + const verifUrl = resp?.verification_url + const sessionId = resp?.session_id + if (!userCode || !verifUrl || !sessionId) { + toast(t('engine.hermesOAuthBadResponse'), 'error') + return + } + // 打开浏览器 + try { window.open(verifUrl, '_blank', 'noopener') } catch {} + // 显示 user_code + 自动轮询 + const modal = showContentModal({ + title: t('engine.hermesOAuthDeviceTitle'), + content: ` +

${escHtml(t('engine.hermesOAuthDeviceHint'))}

+
+
${escHtml(userCode)}
+
+

${escHtml(verifUrl)}

+
${escHtml(t('engine.hermesOAuthDeviceWaiting'))}
+ `, + buttons: [{ label: t('common.close'), className: 'btn-secondary' }], + width: 520, + }) + + // 轮询 + let stopped = false + modal.addEventListener?.('click', (e) => { + if (e.target.dataset?.action === 'close' || e.target.closest?.('[data-action="close"]')) stopped = true + }) + // 兜底:modal 移除时停轮询 + const observer = new MutationObserver(() => { + if (!modal.isConnected) { + stopped = true + observer.disconnect() + } + }) + if (modal.parentNode) observer.observe(modal.parentNode, { childList: true }) + + const startTime = Date.now() + const TIMEOUT_MS = 10 * 60 * 1000 // 10 min + while (!stopped) { + await new Promise(r => setTimeout(r, 2500)) + if (stopped) break + if (Date.now() - startTime > TIMEOUT_MS) { + const statusEl = modal.querySelector?.('#hm-oauth-device-status') + if (statusEl) statusEl.innerHTML = `${escHtml(t('engine.hermesOAuthDeviceTimeout'))}` + break + } + try { + const st = await api.hermesDashboardApi('GET', `${OAUTH_BASE}/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`) + const status = String(st?.status || '') + if (status === 'success') { + toast(t('engine.hermesOAuthConnected'), 'success') + modal.remove?.() + await load() + break + } + if (status === 'failed' || status === 'expired') { + const statusEl = modal.querySelector?.('#hm-oauth-device-status') + if (statusEl) { + const errMsg = st?.error_message || t('engine.hermesOAuthDeviceFailed') + statusEl.innerHTML = `${escHtml(errMsg)}` + } + break + } + // 仍 pending — 继续轮询 + } catch (e) { + // 404 = session 已 GC,停轮询 + if (String(e?.message).includes('404')) break + // 其他错误:继续轮询(短暂网络问题) + } + } + } + + async function onDisconnect(providerId) { + try { + await api.hermesDashboardApi('DELETE', `${OAUTH_BASE}/${encodeURIComponent(providerId)}`) + toast(t('engine.hermesOAuthDisconnected'), 'success') + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesOAuthDisconnectFailed')), 'error') + } + } + + draw() + load() + return el +} diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index d3732d2..c721066 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -523,6 +523,40 @@ export default { chatSpeak: _('朗读', 'Speak', '朗讀'), chatSpeakShort: _('朗读', 'Speak', '朗讀'), chatSpeakFailed: _('朗读失败(浏览器不支持或无可用语音)', 'TTS failed (browser unsupported or no voices)', '朗讀失敗(瀏覽器不支援或無可用語音)'), + // Batch 3 §Q: OAuth 三种登录(PKCE / device_code / external CLI) + hermesOAuthTitle: _('OAuth 登录', 'OAuth Login', 'OAuth 登入'), + hermesOAuthDesc: _('用 OAuth 连接 Claude / Qwen / GitHub Copilot 等服务,免去手填 API Key', 'Connect to Claude / Qwen / GitHub Copilot via OAuth — no manual API key needed', '用 OAuth 連接 Claude / Qwen / GitHub Copilot 等服務,免去手填 API Key'), + hermesOAuthEmpty: _('未检测到 OAuth Provider(请先启动 Dashboard)', 'No OAuth providers (start Dashboard first)', '未偵測到 OAuth Provider(請先啟動 Dashboard)'), + hermesOAuthConnect: _('登录', 'Connect', '登入'), + hermesOAuthDisconnect: _('断开', 'Disconnect', '中斷'), + hermesOAuthDocs: _('文档', 'Docs', '文件'), + hermesOAuthConnected: _('已连接', 'Connected', '已連線'), + hermesOAuthDisconnected: _('未连接', 'Disconnected', '未連線'), + hermesOAuthExpires: _('过期', 'Expires', '到期'), + hermesOAuthFlowPkce: _('PKCE 授权', 'PKCE flow', 'PKCE 授權'), + hermesOAuthFlowDevice: _('设备码流程', 'Device code flow', '裝置碼流程'), + hermesOAuthFlowExternal: _('需通过 CLI 登录', 'CLI-only flow', '需透過 CLI 登入'), + hermesOAuthExternalTitle: _('{id} 需用 CLI 登录', '{id} requires CLI login', '{id} 需用 CLI 登入'), + hermesOAuthExternalHint: _('请在终端运行以下命令完成登录:', 'Please run this command in your terminal:', '請在終端執行以下指令完成登入:'), + hermesOAuthExternalRefresh: _('登录完成后回到此页面点「刷新」', 'After login, click Refresh on this page', '登入完成後回到此頁面點「重新整理」'), + hermesOAuthStartFailed: _('启动登录失败', 'Start login failed', '啟動登入失敗'), + hermesOAuthBadResponse: _('OAuth 响应缺少必要字段', 'OAuth response missing required fields', 'OAuth 回應缺少必要欄位'), + hermesOAuthUnknownFlow: _('未知流程: {flow}', 'Unknown flow: {flow}', '未知流程: {flow}'), + // PKCE + hermesOAuthPkceTitle: _('完成 PKCE 授权', 'Complete PKCE authorization', '完成 PKCE 授權'), + hermesOAuthPkceAuthLink: _('授权链接(已自动打开)', 'Authorization URL (already opened)', '授權連結(已自動開啟)'), + hermesOAuthPkceUrlHint: _('如未自动打开浏览器,请手动复制', 'If browser did not open, copy this URL', '如未自動開啟瀏覽器,請手動複製'), + hermesOAuthPkceCodeLabel: _('粘贴回调 Code', 'Paste callback code', '貼上回呼 Code'), + hermesOAuthPkceCodeHint: _('授权后浏览器会跳转到一个回调页,从 URL 或页面文字复制 code', 'After authorizing, the browser redirects to a callback page — copy the code from URL or text', '授權後瀏覽器會跳轉到一個回呼頁,從 URL 或頁面文字複製 code'), + hermesOAuthCodeRequired: _('请填写授权 code', 'Code is required', '請填寫授權 code'), + hermesOAuthSubmitFailed: _('提交回调 code 失败', 'Submit code failed', '提交回呼 code 失敗'), + // device code + hermesOAuthDeviceTitle: _('完成设备码登录', 'Complete device code login', '完成裝置碼登入'), + hermesOAuthDeviceHint: _('1) 浏览器会自动打开授权页 2) 输入下方 6 位用户码 3) 完成后此处自动刷新', '1) Browser opens the auth page 2) Enter the user code below 3) Page auto-refreshes when done', '1) 瀏覽器會自動開啟授權頁 2) 輸入下方 6 位用戶碼 3) 完成後此處自動重新整理'), + hermesOAuthDeviceWaiting: _('等待你在浏览器完成登录…', 'Waiting for you to complete login in browser…', '等待你在瀏覽器完成登入…'), + hermesOAuthDeviceTimeout: _('登录超时(10 分钟未完成)', 'Login timed out (10 min)', '登入逾時(10 分鐘未完成)'), + hermesOAuthDeviceFailed: _('授权失败', 'Authorization failed', '授權失敗'), + hermesOAuthDisconnectFailed: _('断开失败', 'Disconnect failed', '中斷失敗'), // Web 模式(远程浏览器)下流式聊天暂不可用 chatWebModeStreamingUnsupported: _( 'Web 模式暂不支持 Hermes 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',