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