feat(hermes): Batch 3 §K - 多模态图片上传(chat attach + 拖拽 + 粘贴 + base64)

Hermes Agent 已支持 OpenAI 多模态格式(tests/gateway/test_api_server_multimodal.py),
ClawPanel 前端补 attach UI 即可对接 Claude 3.5 / GPT-4o / Gemini 等视觉模型。

## 后端
- HermesAttachment 结构:{ kind, mime, name?, data_base64 }
- build_multimodal_input(text, attachments) 把 text 转成
  [{type:"text",text}, {type:"image_url",image_url:{url:"data:..;base64,.."}}, ...]
- hermes_agent_run 加 attachments 参数(向后兼容:未传时走原 string input)

## 前端
- tauri-api.js: hermesAgentRun 加 attachments 参数
- chat-store sendMessage:
  · 允许 text 空但有 attachments 也能发
  · user 消息记录 attachments[].dataUrl 用于气泡内渲染
  · isTauriRuntime 时 await api.hermesAgentRun(...,atts)
- chat.js 渲染层:
  · renderMessage 在内容前插入 .hm-chat-msg-attachments + .hm-chat-msg-image(点击放大)
  · 输入栏前面增加 .hm-chat-attach-preview 预览条(缩略图 + 文件名 + × 移除)
  · 输入框左侧加 paperclip attach 按钮 + 隐藏 <input type="file" accept="image/*" multiple>
  · 发送按钮 disabled 条件改为 (!text && !attachments)
- chat.js 交互:
  · fileToBase64 用 FileReader 转纯 base64
  · addAttachmentFromFile 校验 image/* + 10MB + 最多 5 张
  · attach 按钮 click → 触发文件选择器
  · 拖拽到输入区 → dragover 高亮 + drop 加附件
  · 粘贴图片 → clipboardData 读 image 文件
  · 移除按钮 splice
- chat-store.sendMessage 后清 pendingAttachments

## 限制
- 单图最大 10 MB(base64 后约 13 MB)
- 一次最多 5 张
- 超限 toast 友好提示
- 非图片格式拒收

## CSS
- .hm-chat-attach-btn / hover / disabled
- .hm-chat-attach-preview / chip / chip-name / chip-remove
- .hm-chat-input-wrap--dragover(拖拽虚线高亮)
- .hm-chat-msg-attachments / msg-image / msg-image--zoom(点击放大模式)

## i18n
- engine.chatAttach / chatAttachRemove / chatAttachOnlyImage
- chatAttachTooBig / chatAttachTooMany / chatAttachReadFailed
- 3 语言(zh-CN/en/zh-TW)

## 注意
- Web 模式(dev-api 走 SSE)暂不支持 attachments 透传(hermes_agent_run_stream 没改),
  原因:Web 模式当前 chat 是 stream-only 路径,需要单独改 dev-api 的 hermes_agent_run_stream handler
- 桌面端 Tauri 模式开箱可用
- 累计变动:6 个文件,~120 行新代码,6 个 i18n 键
- cargo check ✓ + npm build ✓
This commit is contained in:
晴天
2026-05-14 04:59:36 +08:00
parent 112963b2b7
commit 8eb8a7666e
6 changed files with 266 additions and 7 deletions

View File

@@ -1063,18 +1063,23 @@ function createStore() {
async function sendMessage(content, opts = {}) {
const text = (content || '').trim()
if (!text || state.streaming) return
const atts = Array.isArray(opts.attachments) ? opts.attachments : []
if ((!text && !atts.length) || state.streaming) return
let s = activeSession()
if (!s) {
s = createLocalSession()
}
// Append user message.
// Batch 3 §K: 多模态附件(仅图片)— 保存 dataUrl 用于气泡内渲染
s.messages.push({
id: uid(),
role: 'user',
content: text,
timestamp: Date.now(),
attachments: atts.length
? atts.map(a => ({ kind: a.kind, mime: a.mime, name: a.name || '', dataUrl: `data:${a.mime};base64,${a.data_base64}` }))
: undefined,
})
updateSessionTitleFromFirstUser(s)
s.updatedAt = Date.now()
@@ -1096,7 +1101,7 @@ function createStore() {
if (isTauriRuntime()) {
await attachStreamListeners(s.id)
await api.hermesAgentRun(text, s.id, history.length ? history : null, opts.instructions || null)
await api.hermesAgentRun(text, s.id, history.length ? history : null, opts.instructions || null, atts.length ? atts : null)
} else {
streamAbortController = new AbortController()
await api.hermesAgentRunStream(

View File

@@ -288,6 +288,10 @@ export function render() {
let inputValue = ''
let inputFocused = false
let inputCaret = 0 // caret position restored after re-render
// Batch 3 §K: 多模态图片附件(仅 chat 这一帧暂存,发送后清掉)
let pendingAttachments = [] // [{ kind:'image', mime, name, data_base64 }]
const MAX_ATTACHMENTS = 5
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10 MB
let lastActiveSessionId = store.state.activeSessionId
let forceScrollBottom = true
@@ -676,7 +680,22 @@ export function render() {
<span class="hm-chat-usage-value">${escHtml(formatCost(cost))}</span>
</span>` : ''}
</div>` : ''}
${pendingAttachments.length ? `
<div class="hm-chat-attach-preview">
${pendingAttachments.map((a, i) => `
<div class="hm-chat-attach-chip">
<img src="data:${escAttr(a.mime)};base64,${escAttr(a.data_base64)}" alt="${escAttr(a.name)}">
<span class="hm-chat-attach-chip-name" title="${escAttr(a.name)}">${escHtml(a.name)}</span>
<button class="hm-chat-attach-chip-remove" data-attach-remove="${i}" title="${escHtml(t('engine.chatAttachRemove'))}">×</button>
</div>
`).join('')}
</div>
` : ''}
<div class="hm-chat-input-wrap ${streaming ? 'is-streaming' : ''}">
<button class="hm-chat-attach-btn" id="hm-chat-attach" title="${escHtml(t('engine.chatAttach'))}" ${streaming ? 'disabled' : ''}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
</button>
<input type="file" id="hm-chat-attach-input" accept="image/*" multiple hidden>
<textarea id="hm-chat-input" class="hm-chat-input"
placeholder="${escAttr(placeholder)}"
rows="1">${escHtml(inputValue)}</textarea>
@@ -686,7 +705,7 @@ export function render() {
${ICONS.stop}
</button>`
: `<button class="hm-chat-send-btn" id="hm-chat-send"
${(!active || !inputValue.trim() || hermesInstalled === false || !gwOnline) ? 'disabled' : ''}
${(!active || (!inputValue.trim() && !pendingAttachments.length) || hermesInstalled === false || !gwOnline) ? 'disabled' : ''}
title="${escHtml(hermesInstalled === false ? t('engine.chatHealthInstallMissing') : !gwOnline ? t('engine.chatHealthGatewayDown') : t('engine.chatSend'))}">
${ICONS.send}
</button>`}
@@ -1134,6 +1153,59 @@ export function render() {
})
})
// Batch 3 §K: attach 按钮 / 文件 input / 拖拽 / 移除附件
const attachBtn = el.querySelector('#hm-chat-attach')
const attachInput = el.querySelector('#hm-chat-attach-input')
attachBtn?.addEventListener('click', () => attachInput?.click())
attachInput?.addEventListener('change', async (e) => {
const files = Array.from(e.target.files || [])
for (const f of files) await addAttachmentFromFile(f)
e.target.value = '' // reset 让用户能重选同一张图
})
// 移除附件
el.querySelectorAll('[data-attach-remove]').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.attachRemove, 10)
if (Number.isFinite(idx) && idx >= 0 && idx < pendingAttachments.length) {
pendingAttachments.splice(idx, 1)
draw()
}
})
})
// 拖拽到输入区域
const dropZone = el.querySelector('.hm-chat-input-wrap')
if (dropZone && !dropZone.dataset.dragBound) {
dropZone.dataset.dragBound = '1'
dropZone.addEventListener('dragover', (e) => {
if (e.dataTransfer && Array.from(e.dataTransfer.items || []).some(it => it.kind === 'file')) {
e.preventDefault()
dropZone.classList.add('hm-chat-input-wrap--dragover')
}
})
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('hm-chat-input-wrap--dragover'))
dropZone.addEventListener('drop', async (e) => {
e.preventDefault()
dropZone.classList.remove('hm-chat-input-wrap--dragover')
if (store.state.streaming) return
for (const f of e.dataTransfer.files || []) {
if (f.type.startsWith('image/')) await addAttachmentFromFile(f)
}
})
// 粘贴图片
dropZone.addEventListener('paste', async (e) => {
if (store.state.streaming) return
const items = e.clipboardData?.items || []
let handled = false
for (const it of items) {
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile()
if (f) { await addAttachmentFromFile(f); handled = true }
}
}
if (handled) e.preventDefault()
})
}
el.querySelectorAll('.hm-chat-slash-item').forEach(item => {
item.addEventListener('click', () => {
const cmd = item.dataset.cmd
@@ -1215,7 +1287,8 @@ export function render() {
async function handleSend() {
const text = inputValue.trim()
if (!text || store.state.streaming) return
// Batch 3 §K: 允许只发图片text 为空但有 attachments
if ((!text && !pendingAttachments.length) || store.state.streaming) return
// Local slash commands short-circuit before going to the agent.
if (text === '/clear') {
@@ -1273,9 +1346,55 @@ export function render() {
// Normal user message → start agent run.
forceScrollBottom = true
// Batch 3 §K: 在 resetInput 前先把 attachments 复制下来再清空
const sendAttachments = pendingAttachments.slice()
pendingAttachments = []
resetInput()
draw()
await store.sendMessage(text)
await store.sendMessage(text, { attachments: sendAttachments })
}
// Batch 3 §K: 把 File → base64FileReader
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => {
const result = r.result || ''
// dataURL 形如 "data:image/png;base64,xxxx" — 我们要纯 base64
const commaIdx = String(result).indexOf(',')
resolve(commaIdx >= 0 ? String(result).slice(commaIdx + 1) : String(result))
}
r.onerror = () => reject(r.error || new Error('FileReader failed'))
r.readAsDataURL(file)
})
}
async function addAttachmentFromFile(file) {
if (!file) return
if (!file.type.startsWith('image/')) {
toast(t('engine.chatAttachOnlyImage'), 'error')
return
}
if (file.size > MAX_ATTACHMENT_SIZE) {
toast(t('engine.chatAttachTooBig'), 'error')
return
}
if (pendingAttachments.length >= MAX_ATTACHMENTS) {
toast(t('engine.chatAttachTooMany'), 'error')
return
}
try {
const data_base64 = await fileToBase64(file)
pendingAttachments.push({
kind: 'image',
mime: file.type,
name: file.name || 'image',
data_base64,
})
draw()
} catch (err) {
toast(t('engine.chatAttachReadFailed'), 'error')
}
}
// ----------------------------------------------------------- search modal

View File

@@ -5098,6 +5098,98 @@ body[data-active-engine="hermes"][data-theme="dark"] {
background: rgba(255, 255, 255, 0.04);
}
/* ---- Batch 3 §K: 多模态图片附件 ---- */
[data-engine="hermes"] .hm-chat-attach-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: 0;
background: transparent;
color: var(--hm-text-tertiary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
[data-engine="hermes"] .hm-chat-attach-btn:hover:not(:disabled) {
background: var(--hm-surface-1);
color: var(--hm-accent);
}
[data-engine="hermes"] .hm-chat-attach-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
[data-engine="hermes"] .hm-chat-attach-preview {
display: flex;
gap: 8px;
padding: 8px 12px;
flex-wrap: wrap;
border-bottom: 1px solid var(--hm-border);
}
[data-engine="hermes"] .hm-chat-attach-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px 4px 4px;
background: var(--hm-surface-1);
border-radius: 8px;
max-width: 220px;
}
[data-engine="hermes"] .hm-chat-attach-chip img {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 4px;
}
[data-engine="hermes"] .hm-chat-attach-chip-name {
font-size: 12px;
color: var(--hm-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
[data-engine="hermes"] .hm-chat-attach-chip-remove {
border: 0;
background: transparent;
color: var(--hm-text-tertiary);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
[data-engine="hermes"] .hm-chat-attach-chip-remove:hover {
color: var(--hm-error, #ef4444);
}
[data-engine="hermes"] .hm-chat-input-wrap--dragover {
outline: 2px dashed var(--hm-accent);
outline-offset: -2px;
background: rgba(99, 102, 241, 0.04);
}
/* 消息气泡内的图片渲染 */
[data-engine="hermes"] .hm-chat-msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
[data-engine="hermes"] .hm-chat-msg-image {
max-width: 240px;
max-height: 240px;
border-radius: 8px;
cursor: zoom-in;
object-fit: contain;
background: var(--hm-surface-1);
}
[data-engine="hermes"] .hm-chat-msg-image--zoom {
max-width: 100%;
max-height: 80vh;
cursor: zoom-out;
}
[data-engine="hermes"] .hm-chat-live-tools {
display: flex;
flex-direction: column;

View File

@@ -473,7 +473,7 @@ export const api = {
hermesHealthCheck: () => invoke('hermes_health_check'),
hermesCapabilities: () => invoke('hermes_capabilities'),
hermesApiProxy: (method, path, body, headers) => invoke('hermes_api_proxy', { method, path, body: body || null, headers: headers || null }),
hermesAgentRun: (input, sessionId, conversationHistory, instructions) => invoke('hermes_agent_run', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null }),
hermesAgentRun: (input, sessionId, conversationHistory, instructions, attachments) => invoke('hermes_agent_run', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null, attachments: attachments && attachments.length ? attachments : null }),
hermesAgentRunStream: (input, sessionId, conversationHistory, instructions, onEvent, options) => webStreamInvoke('hermes_agent_run_stream', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null }, onEvent, options),
// Batch 1 §D + §C-bis: 真正中断 + Approval Flow用 run_id
hermesRunStop: (runId) => invoke('hermes_run_stop', { runId }),

View File

@@ -453,6 +453,13 @@ export default {
chatApprovalAlways: _('永久信任', 'Always', '永久信任'),
chatApprovalDeny: _('拒绝', 'Deny', '拒絕'),
chatApprovalFailed: _('批准失败', 'Approval failed', '批准失敗'),
// Batch 3 §K: 多模态图片
chatAttach: _('附加图片', 'Attach image', '附加圖片'),
chatAttachRemove: _('移除', 'Remove', '移除'),
chatAttachOnlyImage: _('只支持图片格式', 'Only image files are supported', '只支援圖片格式'),
chatAttachTooBig: _('图片过大(最大 10 MB', 'Image too large (max 10 MB)', '圖片過大(最大 10 MB'),
chatAttachTooMany: _('最多 5 张图片', 'Up to 5 images', '最多 5 張圖片'),
chatAttachReadFailed: _('读取图片失败', 'Failed to read image', '讀取圖片失敗'),
// Web 模式(远程浏览器)下流式聊天暂不可用
chatWebModeStreamingUnsupported: _(
'Web 模式暂不支持 Hermes 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',