feat(hermes): Batch 2 §I + Batch 3 §M - 流恢复 + Kanban 看板

## 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 ✓
This commit is contained in:
晴天
2026-05-14 05:12:37 +08:00
parent 3168551569
commit 3c8c315402
10 changed files with 468 additions and 4 deletions

View File

@@ -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') },

View File

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

View File

@@ -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')

View File

@@ -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, '&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 board = null // { columns: [{name, tasks: []}], ... }
let boards = []
let loading = true
let error = ''
function draw() {
el.innerHTML = `
<div class="page-header">
<div>
<h1 class="page-title">${escHtml(t('engine.hermesKanbanTitle'))}</h1>
<p class="page-desc">${escHtml(t('engine.hermesKanbanDesc'))}</p>
</div>
<div class="config-actions">
${boards.length > 1 ? `
<select class="form-input" id="hm-kanban-board-switch" style="max-width:200px">
${boards.map(b => `<option value="${escAttr(b.slug || b.name)}" ${b.is_current ? 'selected' : ''}>${escHtml(b.name || b.slug)}</option>`).join('')}
</select>` : ''}
<button class="btn btn-secondary btn-sm" id="hm-kanban-refresh">${escHtml(t('hermesLazyDeps.refresh'))}</button>
<button class="btn btn-primary btn-sm" id="hm-kanban-new-task">+ ${escHtml(t('engine.hermesKanbanNewTask'))}</button>
</div>
</div>
<div id="hm-kanban-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 && board) ? renderBoard() : ''}
</div>
`
bind()
}
function renderBoard() {
if (!board?.columns?.length) {
return `<div class="empty-state empty-compact"><div class="empty-icon">📋</div><div class="empty-title">${escHtml(t('engine.hermesKanbanEmpty'))}</div></div>`
}
return `
<div class="hm-kanban-board">
${board.columns.map(col => `
<div class="hm-kanban-col" data-col="${escAttr(col.name)}">
<div class="hm-kanban-col-head">
<span class="hm-kanban-col-name">${escHtml(colLabel(col.name))}</span>
<span class="hm-kanban-col-count">${col.tasks?.length || 0}</span>
</div>
<div class="hm-kanban-col-body">
${(col.tasks || []).map(renderTask).join('')}
${!col.tasks?.length ? `<div class="hm-kanban-col-empty">${escHtml(t('engine.hermesKanbanColEmpty'))}</div>` : ''}
</div>
</div>
`).join('')}
</div>
`
}
function renderTask(task) {
const priorityBadge = task.priority > 1 ? `<span class="hm-kanban-task-prio">P${escHtml(task.priority)}</span>` : ''
const assignee = task.assignee ? `<span class="hm-kanban-task-assignee">@${escHtml(task.assignee)}</span>` : ''
return `
<div class="hm-kanban-task" data-task-id="${escAttr(task.id)}">
<div class="hm-kanban-task-title">${escHtml(task.title)}</div>
${task.summary ? `<div class="hm-kanban-task-summary">${escHtml(task.summary)}</div>` : ''}
<div class="hm-kanban-task-meta">
${priorityBadge}
${assignee}
${task.comment_count ? `<span class="hm-kanban-task-meta-item">💬 ${task.comment_count}</span>` : ''}
</div>
</div>
`
}
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: `
<div class="hm-kanban-detail">
<div class="hm-kanban-detail-row"><b>${escHtml(t('engine.hermesKanbanStatusLabel'))}:</b> ${escHtml(colLabel(task.status))}</div>
${task.priority ? `<div class="hm-kanban-detail-row"><b>${escHtml(t('engine.hermesKanbanPriorityLabel'))}:</b> P${escHtml(task.priority)}</div>` : ''}
${task.assignee ? `<div class="hm-kanban-detail-row"><b>${escHtml(t('engine.hermesKanbanAssigneeLabel'))}:</b> @${escHtml(task.assignee)}</div>` : ''}
${task.summary ? `<div class="hm-kanban-detail-row"><b>${escHtml(t('engine.hermesKanbanSummaryLabel'))}:</b><br>${escHtml(task.summary)}</div>` : ''}
${task.description ? `<div class="hm-kanban-detail-row"><b>${escHtml(t('engine.hermesKanbanDescLabel'))}:</b><pre style="white-space:pre-wrap;font-family:var(--font-mono);font-size:12px;margin:6px 0 0">${escHtml(task.description)}</pre></div>` : ''}
${task.latest_summary ? `<div class="hm-kanban-detail-row"><b>${escHtml(t('engine.hermesKanbanRunSummary'))}:</b><pre style="white-space:pre-wrap;font-family:var(--font-mono);font-size:11px;margin:6px 0 0;max-height:160px;overflow:auto">${escHtml(task.latest_summary)}</pre></div>` : ''}
</div>`,
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
}

View File

@@ -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;

View File

@@ -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 代理

View File

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