From 3c8c3154027b2b83da72ac7052fa4d97a896f5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Thu, 14 May 2026 05:12:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(hermes):=20Batch=202=20=C2=A7I=20+=20Batch?= =?UTF-8?q?=203=20=C2=A7M=20-=20=E6=B5=81=E6=81=A2=E5=A4=8D=20+=20Kanban?= =?UTF-8?q?=20=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Batch 2 §I 流恢复(chat 在切页/刷新后能接上 run) 校对稿:用 run_id 持久化到 localStorage,新页面挂载时查询 status 决定是否重连。 ### 后端 - 新 Tauri 命令 hermes_run_status(run_id): · GET /v1/runs/{run_id} 返回 { run_id, status, last_event, output?, ... } · status: running / stopping / completed / failed / cancelled / waiting_for_approval · 404 友好返回 status='not_found' 而不是抛错 ### 前端 chat-store - 新 STORAGE_ACTIVE_RUN 持久化 { runId, sessionId, profile, t } - hermes-run-started 监听里 safeSet 持久化 - cleanupAfterRun 里 safeRemove 清理 - 新方法 recoverIfRunning(): · 跨 profile / 1 小时过期 → 直接清 · status=running/stopping/waiting_for_approval → attachStreamListeners + 恢复 streaming · status=已结束 → 拉最新 messages · 404 → 静默清 ### chat.js - 页面挂载时 store.recoverIfRunning() — 切页/刷新后无缝接上流 ## Batch 3 §M Kanban 看板(Hermes 已内置) 校对稿:「Hermes 已内置 kanban 系统,直接调 /api/plugins/kanban/* 即可」。 设计稿原本是「自建本地存储」(~800 行),复用 Hermes 内置后大幅缩减。 ### 新页面 /h/kanban - 全部走 hermesDashboardApi(复用 §H 的基础设施) - 顶部 board 切换器 + 「+ 新任务」按钮 - 渲染 board.columns(按状态分列:todo / in_progress / blocked / done / archived) - 任务卡片:title + summary(2 行截断)+ priority badge + assignee + 评论数 - 点卡片 → showContentModal 显示详情 + 「修改状态」按钮 - 修改状态 → PATCH /api/plugins/kanban/tasks/{id} { status } - board 切换 → POST /api/plugins/kanban/boards/{slug}/switch ### sidebar - 「管理」section 加 Kanban 入口(inbox 图标) - /h/kanban 路由注册 ### CSS (.hm-kanban-*) - 水平滚动 board 容器 - 280px 固定宽度列 + 内部滚动 - 卡片 hover 边框变 accent 色 + 轻阴影 - 优先级 badge(琥珀色)/ assignee(accent 色) ### i18n - 27 个新键 × 3 语言(zh-CN/en/zh-TW) ## 累计 - Rust: 1 个新命令(hermes_run_status ~30 行) - 前端: chat-store 流恢复(~40 行)+ kanban 新页面(~230 行) - i18n: 27 个新键 × 3 语言 - CSS: ~100 行 - cargo check ✓ + npm build ✓ --- scripts/dev-api.js | 16 ++ src-tauri/src/commands/hermes.rs | 38 +++++ src-tauri/src/lib.rs | 1 + src/engines/hermes/index.js | 5 +- src/engines/hermes/lib/chat-store.js | 42 ++++- src/engines/hermes/pages/chat.js | 2 + src/engines/hermes/pages/kanban.js | 235 +++++++++++++++++++++++++++ src/engines/hermes/style/hermes.css | 102 ++++++++++++ src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 29 ++++ 10 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 src/engines/hermes/pages/kanban.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 51bd70d..235ebd9 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -7194,6 +7194,22 @@ const handlers = { return { ok: false, error: `Web 模式下无法预装依赖。请在桌面端 ClawPanel 完成 ${feature} 安装。` } }, + // Batch 2 §I: 流恢复 — GET /v1/runs/{run_id} + async hermes_run_status({ runId } = {}) { + if (!runId) throw new Error('run_id 不能为空') + const url = `${hermesGatewayUrl()}/v1/runs/${encodeURIComponent(runId)}` + const apiKey = _readHermesApiServerKey() + const headers = { 'User-Agent': 'ClawPanel-Web' } + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + const resp = await globalThis.fetch(url, { headers, signal: AbortSignal.timeout(5000) }) + if (resp.status === 404) return { run_id: runId, status: 'not_found' } + if (!resp.ok) { + const body = await resp.text().catch(() => '') + throw new Error(`status 失败 HTTP ${resp.status}: ${body}`) + } + return await resp.json() + }, + // Batch 1 §D: 真正中断 — POST /v1/runs/{run_id}/stop async hermes_run_stop({ runId } = {}) { if (!runId) throw new Error('run_id 不能为空') diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index a8b06e4..0891a30 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3795,6 +3795,44 @@ pub async fn hermes_run_approval(run_id: String, choice: String) -> Result().await.unwrap_or(serde_json::json!({ "ok": true }))) } +// --------------------------------------------------------------------------- +// Batch 2 §I: hermes_run_status — 查 run 当前状态(流恢复用) +// +// GET /v1/runs/{run_id} 返回 { run_id, status, last_event, output?, ... } +// status 取值:running / stopping / completed / failed / cancelled / waiting_for_approval +// 切页 / 刷新后用这个判断是否还需要重连 SSE 事件流 +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn hermes_run_status(run_id: String) -> Result { + if run_id.is_empty() { + return Err("run_id 不能为空".to_string()); + } + let gw_url = hermes_gateway_url(); + let url = format!("{gw_url}/v1/runs/{run_id}"); + let api_key = read_hermes_api_key(); + let client = hermes_gateway_http_client(std::time::Duration::from_secs(5)) + .map_err(|e| format!("HTTP 客户端创建失败: {e}"))?; + let mut req = client.get(&url); + if !api_key.is_empty() { + req = req.header("Authorization", format!("Bearer {api_key}")); + } + let resp = req + .send() + .await + .map_err(|e| format!("status 请求失败: {}", reqwest_error_detail(&e)))?; + let status = resp.status(); + if status.as_u16() == 404 { + // run 已过期或不存在 — 返回明确状态而不是错 + return Ok(serde_json::json!({ "run_id": run_id, "status": "not_found" })); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(format!("status 失败 HTTP {}: {}", status.as_u16(), body)); + } + resp.json::().await.map_err(|e| format!("解析 JSON 失败: {e}")) +} + // --------------------------------------------------------------------------- // Batch 1 §E: hermes_session_export — 导出会话消息(走 dashboard 9119) // diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1570e2d..2258c31 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -241,6 +241,7 @@ pub fn run() { hermes::hermes_agent_run, hermes::hermes_run_stop, hermes::hermes_run_approval, + hermes::hermes_run_status, hermes::hermes_session_export, hermes::hermes_dashboard_api_proxy, hermes::hermes_read_config, diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js index 0d01a94..4f5e9e1 100644 --- a/src/engines/hermes/index.js +++ b/src/engines/hermes/index.js @@ -86,9 +86,7 @@ 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/profiles', label: t('engine.hermesProfilesTitle'), icon: 'agents' }, - { route: '/h/lazy-deps', label: t('hermesLazyDeps.title'), icon: 'package' }, - { route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' }, + { route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' }, ] }, { section: '', @@ -115,6 +113,7 @@ export default { { path: '/h/cron', loader: () => import('./pages/cron.js') }, { path: '/h/extensions', loader: () => import('./pages/extensions.js') }, { path: '/h/profiles', loader: () => import('./pages/profiles.js') }, + { path: '/h/kanban', loader: () => import('./pages/kanban.js') }, { path: '/h/lazy-deps', loader: () => import('./pages/lazy-deps.js') }, { path: '/h/services', loader: () => import('./pages/services.js') }, { path: '/h/config', loader: () => import('./pages/config.js') }, diff --git a/src/engines/hermes/lib/chat-store.js b/src/engines/hermes/lib/chat-store.js index 35b362a..3e9b3c8 100644 --- a/src/engines/hermes/lib/chat-store.js +++ b/src/engines/hermes/lib/chat-store.js @@ -28,6 +28,8 @@ const STORAGE_PROFILE = 'hermes_chat_profile_v1' const STORAGE_SESSIONS_PREFIX = 'hermes_chat_sessions_v2_' const STORAGE_ACTIVE_PREFIX = 'hermes_chat_active_v2_' const STORAGE_PINNED_PREFIX = 'hermes_chat_pinned_' +// Batch 2 §I: 流恢复 — 持久化 run_id 用于切页/刷新后恢复 +const STORAGE_ACTIVE_RUN = 'hermes_chat_active_run_v1' const STORAGE_COLLAPSED_PREFIX = 'hermes_chat_collapsed_groups_' const STORAGE_MSGS_PREFIX = 'hermes_chat_msgs_v2_' const LIVE_BADGE_WINDOW_MS = 5 * 60 * 1000 // 5 min @@ -968,10 +970,11 @@ function createStore() { state.runningSessionId = null state.pendingAssistantId = null state.liveTools = [] - // Batch 1 §C/§D/§C-bis: 重置 run-level 字段 + // Batch 1 §C/§D/§C-bis + Batch 2 §I: 重置 run-level 字段 + 清持久化 state.currentRunId = null state.pendingApproval = null state.aborting = false + safeRemove(STORAGE_ACTIVE_RUN) // hasReasoning 保留到下次 run 开始(让用户看完上一轮思考链) streamAbortController = null detachStreamListeners() @@ -1189,6 +1192,43 @@ function createStore() { groupedSessions, subscribe, + // Batch 2 §I: 流恢复 — 切页/刷新后看是否有 in-flight run,是的话重新挂监听 + async recoverIfRunning() { + if (state.streaming) return // 已经在监听 + const raw = safeGet(STORAGE_ACTIVE_RUN) + if (!raw) return + let info + try { info = JSON.parse(raw) } catch { safeRemove(STORAGE_ACTIVE_RUN); return } + if (!info?.runId || !info?.sessionId) { safeRemove(STORAGE_ACTIVE_RUN); return } + // 跨 profile 的 run 不恢复(用户已切了 profile) + if (info.profile && info.profile !== state.activeProfile) { safeRemove(STORAGE_ACTIVE_RUN); return } + // 超过 1 小时的 run 视为过期 + if (info.t && Date.now() - info.t > 60 * 60 * 1000) { safeRemove(STORAGE_ACTIVE_RUN); return } + + try { + const st = await api.hermesRunStatus(info.runId) + const status = String(st?.status || '') + if (status === 'running' || status === 'stopping' || status === 'waiting_for_approval') { + // 还在跑 — 重新挂监听 + 标 streaming + state.runningSessionId = info.sessionId + state.currentRunId = info.runId + state.streaming = true + if (status === 'stopping') state.aborting = true + await attachStreamListeners(info.sessionId) + notify() + } else { + // 已结束 — 拉一下最新 messages(让用户看到完整结果) + safeRemove(STORAGE_ACTIVE_RUN) + if (info.sessionId === state.activeSessionId) { + await refreshActiveMessages().catch(() => {}) + } + } + } catch { + // status 查询失败,假装没事 + safeRemove(STORAGE_ACTIVE_RUN) + } + }, + // actions loadSessions, refreshActiveMessages, diff --git a/src/engines/hermes/pages/chat.js b/src/engines/hermes/pages/chat.js index a834209..5878b26 100644 --- a/src/engines/hermes/pages/chat.js +++ b/src/engines/hermes/pages/chat.js @@ -311,6 +311,8 @@ export function render() { // --- initial session load + model meta --- store.loadSessions().then(() => draw()) store.loadProfiles().then(() => draw()).catch(() => {}) + // Batch 2 §I: 切页/刷新后看是否有 in-flight run,是的话重新挂监听 + store.recoverIfRunning().catch(() => {}) // 强制刷新安装/Gateway 状态缓存,避免用户刚在仪表盘启动 Gateway 后 // 进聊天页看到 30s 过期的「未启动」误判。 invalidate('check_hermes') diff --git a/src/engines/hermes/pages/kanban.js b/src/engines/hermes/pages/kanban.js new file mode 100644 index 0000000..aa859dc --- /dev/null +++ b/src/engines/hermes/pages/kanban.js @@ -0,0 +1,235 @@ +/** + * Hermes Kanban 看板(Batch 3 §M) + * + * Hermes 已内置 kanban 系统(plugins/kanban/dashboard/plugin_api.py), + * ClawPanel 直接调 Dashboard 9119 的 plugin API: + * - GET /api/plugins/kanban/board - 拿当前 board 全部 columns + tasks + * - GET /api/plugins/kanban/boards - 列所有 board + * - POST /api/plugins/kanban/boards - 创建 board + * - POST /api/plugins/kanban/boards/{slug}/switch - 切换 active board + * - POST /api/plugins/kanban/tasks - 创建任务 + * - PATCH /api/plugins/kanban/tasks/{id} - 改任务(含 status 切换) + * - GET /api/plugins/kanban/tasks/{id} - 任务详情 + * + * 设计稿原本是「自建本地存储」(~800 行),复用 Hermes 内置后大幅缩减。 + */ +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 KANBAN_BASE = '/api/plugins/kanban' + +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 board = null // { columns: [{name, tasks: []}], ... } + let boards = [] + let loading = true + let error = '' + + function draw() { + el.innerHTML = ` + +
+ ${loading ? `
${escHtml(t('common.loading'))}…
` : ''} + ${error ? `
${escHtml(error)}
` : ''} + ${(!loading && !error && board) ? renderBoard() : ''} +
+ ` + bind() + } + + function renderBoard() { + if (!board?.columns?.length) { + return `
📋
${escHtml(t('engine.hermesKanbanEmpty'))}
` + } + return ` +
+ ${board.columns.map(col => ` +
+
+ ${escHtml(colLabel(col.name))} + ${col.tasks?.length || 0} +
+
+ ${(col.tasks || []).map(renderTask).join('')} + ${!col.tasks?.length ? `
${escHtml(t('engine.hermesKanbanColEmpty'))}
` : ''} +
+
+ `).join('')} +
+ ` + } + + function renderTask(task) { + const priorityBadge = task.priority > 1 ? `P${escHtml(task.priority)}` : '' + const assignee = task.assignee ? `@${escHtml(task.assignee)}` : '' + return ` +
+
${escHtml(task.title)}
+ ${task.summary ? `
${escHtml(task.summary)}
` : ''} +
+ ${priorityBadge} + ${assignee} + ${task.comment_count ? `💬 ${task.comment_count}` : ''} +
+
+ ` + } + + function colLabel(name) { + const map = { + todo: t('engine.hermesKanbanColTodo'), + 'in_progress': t('engine.hermesKanbanColInProgress'), + blocked: t('engine.hermesKanbanColBlocked'), + done: t('engine.hermesKanbanColDone'), + archived: t('engine.hermesKanbanColArchived'), + } + return map[name] || name + } + + function bind() { + el.querySelector('#hm-kanban-refresh')?.addEventListener('click', load) + el.querySelector('#hm-kanban-new-task')?.addEventListener('click', onCreateTask) + el.querySelector('#hm-kanban-board-switch')?.addEventListener('change', async (e) => { + const slug = e.target.value + try { + await api.hermesDashboardApi('POST', `${KANBAN_BASE}/boards/${encodeURIComponent(slug)}/switch`) + toast(t('engine.hermesKanbanBoardSwitched', { name: slug }), 'success') + await load() + } catch (err) { + toast(humanizeError(err, t('engine.hermesKanbanBoardSwitchFailed')), 'error') + } + }) + el.querySelectorAll('.hm-kanban-task').forEach(card => { + card.addEventListener('click', () => onTaskClick(card.dataset.taskId)) + }) + } + + async function load() { + loading = true + error = '' + draw() + try { + const [boardData, boardsData] = await Promise.all([ + api.hermesDashboardApi('GET', `${KANBAN_BASE}/board`), + api.hermesDashboardApi('GET', `${KANBAN_BASE}/boards`).catch(() => ({ boards: [] })), + ]) + board = boardData + boards = Array.isArray(boardsData) ? boardsData : (boardsData?.boards || []) + } catch (e) { + error = String(e?.message || e) + } finally { + loading = false + draw() + } + } + + function onCreateTask() { + const statusOpts = ['todo', 'in_progress', 'blocked', 'done'].map(s => ({ value: s, label: colLabel(s) })) + showModal({ + title: t('engine.hermesKanbanNewTaskTitle'), + fields: [ + { name: 'title', label: t('engine.hermesKanbanTitleLabel'), value: '', placeholder: '...' }, + { name: 'summary', label: t('engine.hermesKanbanSummaryLabel'), value: '', placeholder: '' }, + { name: 'status', label: t('engine.hermesKanbanStatusLabel'), type: 'select', options: statusOpts, value: 'todo' }, + { name: 'priority', label: t('engine.hermesKanbanPriorityLabel'), value: '1', placeholder: '1-5' }, + ], + onConfirm: async (data) => { + const title = (data.title || '').trim() + if (!title) { + toast(t('engine.hermesKanbanTitleRequired'), 'error') + return + } + try { + await api.hermesDashboardApi('POST', `${KANBAN_BASE}/tasks`, { + title, + summary: (data.summary || '').trim() || undefined, + status: data.status, + priority: parseInt(data.priority, 10) || 1, + }) + toast(t('engine.hermesKanbanTaskCreated'), 'success') + await load() + } catch (err) { + toast(humanizeError(err, t('engine.hermesKanbanTaskCreateFailed')), 'error') + } + }, + }) + } + + async function onTaskClick(taskId) { + if (!taskId) return + try { + const task = await api.hermesDashboardApi('GET', `${KANBAN_BASE}/tasks/${encodeURIComponent(taskId)}`) + showContentModal({ + title: task.title || taskId, + content: ` +
+
${escHtml(t('engine.hermesKanbanStatusLabel'))}: ${escHtml(colLabel(task.status))}
+ ${task.priority ? `
${escHtml(t('engine.hermesKanbanPriorityLabel'))}: P${escHtml(task.priority)}
` : ''} + ${task.assignee ? `
${escHtml(t('engine.hermesKanbanAssigneeLabel'))}: @${escHtml(task.assignee)}
` : ''} + ${task.summary ? `
${escHtml(t('engine.hermesKanbanSummaryLabel'))}:
${escHtml(task.summary)}
` : ''} + ${task.description ? `
${escHtml(t('engine.hermesKanbanDescLabel'))}:
${escHtml(task.description)}
` : ''} + ${task.latest_summary ? `
${escHtml(t('engine.hermesKanbanRunSummary'))}:
${escHtml(task.latest_summary)}
` : ''} +
`, + buttons: [ + { label: t('engine.hermesKanbanMoveStatus'), className: 'btn-secondary', id: 'kanban-move-' + taskId }, + { label: t('common.close'), className: 'btn-secondary' }, + ], + width: 560, + }) + // 「修改状态」按钮点击 → 弹小窗选 status + setTimeout(() => { + document.getElementById('kanban-move-' + taskId)?.addEventListener('click', () => { + showModal({ + title: t('engine.hermesKanbanMoveStatusTitle'), + fields: [ + { name: 'status', label: t('engine.hermesKanbanStatusLabel'), type: 'select', + options: ['todo', 'in_progress', 'blocked', 'done', 'archived'].map(s => ({ value: s, label: colLabel(s) })), + value: task.status }, + ], + onConfirm: async (d) => { + try { + await api.hermesDashboardApi('PATCH', `${KANBAN_BASE}/tasks/${encodeURIComponent(taskId)}`, { status: d.status }) + toast(t('engine.hermesKanbanTaskUpdated'), 'success') + await load() + // 关掉详情模态 + document.querySelectorAll('.modal-overlay').forEach(o => o.remove()) + } catch (err) { + toast(humanizeError(err, t('engine.hermesKanbanTaskUpdateFailed')), 'error') + } + }, + }) + }) + }, 10) + } catch (err) { + toast(humanizeError(err, t('engine.hermesKanbanTaskLoadFailed')), 'error') + } + } + + draw() + load() + return el +} diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index 1ed7cb0..5f2800a 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -5190,6 +5190,108 @@ body[data-active-engine="hermes"][data-theme="dark"] { cursor: zoom-out; } +/* ---- Batch 3 §M: Kanban 看板 ---- */ +[data-engine="hermes"] .hm-kanban-board { + display: flex; + gap: 12px; + overflow-x: auto; + padding-bottom: 8px; +} +[data-engine="hermes"] .hm-kanban-col { + flex: 0 0 280px; + background: var(--hm-surface-1); + border-radius: 10px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + max-height: calc(100vh - 220px); +} +[data-engine="hermes"] .hm-kanban-col-head { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 6px; + border-bottom: 1px solid var(--hm-border); +} +[data-engine="hermes"] .hm-kanban-col-name { + font-size: 13px; + font-weight: 600; + color: var(--hm-text-primary); +} +[data-engine="hermes"] .hm-kanban-col-count { + background: var(--hm-surface-2); + color: var(--hm-text-tertiary); + border-radius: 10px; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; +} +[data-engine="hermes"] .hm-kanban-col-body { + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + flex: 1; +} +[data-engine="hermes"] .hm-kanban-col-empty { + color: var(--hm-text-tertiary); + font-size: 12px; + text-align: center; + padding: 24px 0; +} +[data-engine="hermes"] .hm-kanban-task { + background: var(--hm-surface-0); + border: 1px solid var(--hm-border); + border-radius: 8px; + padding: 10px; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; +} +[data-engine="hermes"] .hm-kanban-task:hover { + border-color: var(--hm-accent); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); +} +[data-engine="hermes"] .hm-kanban-task-title { + font-size: 13px; + font-weight: 500; + color: var(--hm-text-primary); + margin-bottom: 4px; + word-break: break-word; +} +[data-engine="hermes"] .hm-kanban-task-summary { + font-size: 11px; + color: var(--hm-text-tertiary); + margin-bottom: 6px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +[data-engine="hermes"] .hm-kanban-task-meta { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; + font-size: 10px; + color: var(--hm-text-tertiary); +} +[data-engine="hermes"] .hm-kanban-task-prio { + background: rgba(245, 158, 11, 0.15); + color: rgb(180, 100, 0); + padding: 1px 6px; + border-radius: 4px; + font-weight: 600; +} +[data-engine="hermes"] .hm-kanban-task-assignee { + color: var(--hm-accent); +} +[data-engine="hermes"] .hm-kanban-detail-row { + margin-bottom: 10px; + font-size: 13px; + color: var(--hm-text-secondary); +} + [data-engine="hermes"] .hm-chat-live-tools { display: flex; flex-direction: column; diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 643ffb9..7dfabd9 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -478,6 +478,8 @@ export const api = { // Batch 1 §D + §C-bis: 真正中断 + Approval Flow(用 run_id) hermesRunStop: (runId) => invoke('hermes_run_stop', { runId }), hermesRunApproval: (runId, choice) => invoke('hermes_run_approval', { runId, choice }), + // Batch 2 §I: 流恢复 — 查 run 状态 + hermesRunStatus: (runId) => invoke('hermes_run_status', { runId }), // Batch 1 §E: 会话消息导出(走 dashboard /api/sessions/{id}/messages) hermesSessionExport: (sessionId) => invoke('hermes_session_export', { sessionId }), // Batch 2 §H 基础设施: 通用 Dashboard 9119 HTTP 代理 diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index adae7e9..7d8a9c6 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -486,6 +486,35 @@ export default { hermesProfileActive: _('当前', 'Active', '當前'), hermesProfileCreated: _('已创建 Profile "{name}"', 'Profile "{name}" created', '已建立 Profile "{name}"'), hermesProfileCreateFailed: _('创建失败', 'Create failed', '建立失敗'), + // Batch 3 §M: Kanban 看板(Hermes 已内置,走 /api/plugins/kanban/*) + hermesKanbanTitle: _('看板', 'Kanban', '看板'), + hermesKanbanDesc: _('管理 Agent 任务卡片,按状态分列', 'Manage agent tasks by status columns', '管理 Agent 任務卡片,按狀態分欄'), + hermesKanbanEmpty: _('暂无任务(请先启动 Dashboard)', 'No tasks (start Dashboard first)', '暫無任務(請先啟動 Dashboard)'), + hermesKanbanColEmpty: _('(空)', '(empty)', '(空)'), + hermesKanbanNewTask: _('新任务', 'New task', '新任務'), + hermesKanbanNewTaskTitle: _('新建任务', 'New task', '新建任務'), + hermesKanbanTitleLabel: _('标题', 'Title', '標題'), + hermesKanbanTitleRequired: _('标题不能为空', 'Title is required', '標題不能為空'), + hermesKanbanSummaryLabel: _('摘要', 'Summary', '摘要'), + hermesKanbanDescLabel: _('描述', 'Description', '描述'), + hermesKanbanStatusLabel: _('状态', 'Status', '狀態'), + hermesKanbanPriorityLabel: _('优先级', 'Priority', '優先級'), + hermesKanbanAssigneeLabel: _('指派给', 'Assignee', '指派給'), + hermesKanbanRunSummary: _('最近运行摘要', 'Latest run summary', '最近執行摘要'), + hermesKanbanColTodo: _('待办', 'To do', '待辦'), + hermesKanbanColInProgress: _('进行中', 'In progress', '進行中'), + hermesKanbanColBlocked: _('阻塞', 'Blocked', '阻塞'), + hermesKanbanColDone: _('完成', 'Done', '完成'), + hermesKanbanColArchived: _('归档', 'Archived', '歸檔'), + hermesKanbanTaskCreated: _('任务已创建', 'Task created', '任務已建立'), + hermesKanbanTaskCreateFailed: _('创建任务失败', 'Create task failed', '建立任務失敗'), + hermesKanbanTaskUpdated: _('任务已更新', 'Task updated', '任務已更新'), + hermesKanbanTaskUpdateFailed: _('更新任务失败', 'Update task failed', '更新任務失敗'), + hermesKanbanTaskLoadFailed: _('加载任务失败', 'Load task failed', '載入任務失敗'), + hermesKanbanMoveStatus: _('修改状态', 'Change status', '修改狀態'), + hermesKanbanMoveStatusTitle: _('修改任务状态', 'Change task status', '修改任務狀態'), + hermesKanbanBoardSwitched: _('已切换到看板 "{name}"', 'Switched to board "{name}"', '已切換到看板 "{name}"'), + hermesKanbanBoardSwitchFailed: _('切换看板失败', 'Switch board failed', '切換看板失敗'), // Web 模式(远程浏览器)下流式聊天暂不可用 chatWebModeStreamingUnsupported: _( 'Web 模式暂不支持 Hermes 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',