feat(hermes): Batch 3 §Q - OAuth 三种登录 (PKCE / device_code / external)

校对发现端点 + 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 ✓
This commit is contained in:
晴天
2026-05-14 05:23:02 +08:00
parent 64f4668522
commit d483e5bff9
3 changed files with 326 additions and 1 deletions

View File

@@ -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: '',

View File

@@ -0,0 +1,287 @@
/**
* Hermes OAuth 三种登录Batch 3 §Q
*
* 全部走 dashboard 9119hermes_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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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 = `
<div class="page-header">
<div>
<h1 class="page-title">${escHtml(t('engine.hermesOAuthTitle'))}</h1>
<p class="page-desc">${escHtml(t('engine.hermesOAuthDesc'))}</p>
</div>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" id="hm-oauth-refresh">${escHtml(t('hermesLazyDeps.refresh'))}</button>
</div>
</div>
<div id="hm-oauth-content">
${loading ? `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escHtml(t('common.loading'))}…</div>` : ''}
${error ? `<div style="color:var(--error);padding:20px">${escHtml(error)}</div>` : ''}
${(!loading && !error && !providers.length) ? `
<div class="empty-state empty-compact">
<div class="empty-icon">🔐</div>
<div class="empty-title">${escHtml(t('engine.hermesOAuthEmpty'))}</div>
</div>` : ''}
${(!loading && providers.length) ? `
<div class="lazy-deps-grid">
${providers.map(renderProviderCard).join('')}
</div>` : ''}
</div>
`
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 `
<div class="lazy-deps-card">
<div class="lazy-deps-card-head">
<div class="lazy-deps-card-title" title="${escAttr(p.id)}">${escHtml(p.name)}</div>
${loggedIn ? `<span class="lazy-deps-badge ok">${escHtml(t('engine.hermesOAuthConnected'))}</span>` : `<span class="lazy-deps-badge">${escHtml(t('engine.hermesOAuthDisconnected'))}</span>`}
</div>
<div class="lazy-deps-card-meta">${escHtml(flowLabel)}</div>
${loggedIn && sourceLabel ? `<div class="lazy-deps-card-meta" title="${escAttr(sourceLabel)}">${escHtml(sourceLabel)}</div>` : ''}
${loggedIn && tokenPrev ? `<div class="lazy-deps-card-meta" style="font-family:var(--font-mono);font-size:11px">…${escHtml(tokenPrev)}</div>` : ''}
${loggedIn && expires ? `<div class="lazy-deps-card-meta">${escHtml(t('engine.hermesOAuthExpires'))}: ${escHtml(expires)}</div>` : ''}
<div class="lazy-deps-card-actions" style="gap:6px">
${loggedIn
? `<button class="btn btn-secondary btn-sm" data-action="disconnect" data-id="${escAttr(p.id)}" style="color:var(--error)">${escHtml(t('engine.hermesOAuthDisconnect'))}</button>`
: `<button class="btn btn-primary btn-sm" data-action="connect" data-id="${escAttr(p.id)}" data-flow="${escAttr(p.flow)}" data-cli="${escAttr(p.cli_command || '')}">${escHtml(t('engine.hermesOAuthConnect'))}</button>`}
${p.docs_url ? `<a class="btn btn-secondary btn-sm" href="${escAttr(p.docs_url)}" target="_blank" rel="noopener">${escHtml(t('engine.hermesOAuthDocs'))}</a>` : ''}
</div>
</div>
`
}
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: `
<p>${escHtml(t('engine.hermesOAuthExternalHint'))}</p>
<pre style="background:var(--surface-1);padding:12px;border-radius:6px;font-family:var(--font-mono);font-size:12px">${escHtml(cliCommand || 'hermes auth add ' + providerId)}</pre>
<p style="color:var(--text-tertiary);font-size:12px;margin-top:12px">${escHtml(t('engine.hermesOAuthExternalRefresh'))}</p>
`,
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: `
<p>${escHtml(t('engine.hermesOAuthDeviceHint'))}</p>
<div style="text-align:center;margin:16px 0">
<div style="font-size:28px;font-weight:600;letter-spacing:4px;font-family:var(--font-mono);background:var(--surface-1);padding:14px 20px;border-radius:8px;display:inline-block">${escHtml(userCode)}</div>
</div>
<p style="font-size:12px;color:var(--text-tertiary);text-align:center">${escHtml(verifUrl)}</p>
<div id="hm-oauth-device-status" style="margin-top:16px;padding:12px;background:var(--surface-1);border-radius:6px;font-size:13px;color:var(--text-secondary);text-align:center">${escHtml(t('engine.hermesOAuthDeviceWaiting'))}</div>
`,
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 = `<span style="color:var(--error)">${escHtml(t('engine.hermesOAuthDeviceTimeout'))}</span>`
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 = `<span style="color:var(--error)">${escHtml(errMsg)}</span>`
}
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
}

View File

@@ -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 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',