mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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 → base64(FileReader)
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',
|
||||
|
||||
Reference in New Issue
Block a user