feat(desktop): render sent images/videos/files

This commit is contained in:
zhayujie
2026-07-06 18:35:33 +08:00
parent a427586b89
commit 75f3952ac6
9 changed files with 182 additions and 16 deletions

View File

@@ -219,6 +219,15 @@ function setupIPC() {
return result.canceled ? null : result.filePaths[0]
})
// Open a local file with the OS default app; falls back to revealing it in
// the file manager when no handler exists. Returns '' on success.
ipcMain.handle('open-path', async (_event, targetPath: string) => {
if (!targetPath) return 'empty path'
const err = await shell.openPath(targetPath)
if (err) shell.showItemInFolder(targetPath)
return err
})
// Custom window controls (used by Windows frameless titlebar)
ipcMain.handle('window-minimize', () => mainWindow?.minimize())
ipcMain.handle('window-maximize', () => {

View File

@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
restartBackend: () => ipcRenderer.invoke('restart-backend'),
selectDirectory: () => ipcRenderer.invoke('select-directory'),
selectFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('select-file', filters),
openPath: (targetPath: string) => ipcRenderer.invoke('open-path', targetPath) as Promise<string>,
// Each listener registrar returns an unsubscribe fn so renderers can clean
// up on unmount / effect re-run and avoid accumulating duplicate handlers.

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { Copy, Check, RefreshCw, Pencil, Trash2, File as FileIcon, Sprout } from 'lucide-react'
import { Copy, Check, RefreshCw, Trash2, File as FileIcon, Sprout } from 'lucide-react'
import type { ChatMessage } from '../types'
import { t } from '../i18n'
import apiClient from '../api/client'
@@ -11,6 +11,9 @@ interface MessageBubbleProps {
onRegenerate?: (id: string) => void
onEdit?: (id: string) => void
onDelete?: (msg: ChatMessage) => void
/** Fired when an inline image/video finishes loading, so the parent can
* re-scroll to the bottom (async media changes bubble height after mount). */
onMediaLoad?: () => void
}
function fmtTime(ts: number): string {
@@ -36,7 +39,7 @@ const HoverAction: React.FC<{ onClick: () => void; title: string; danger?: boole
</button>
)
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete }) => {
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete, onMediaLoad }) => {
const isUser = message.role === 'user'
const [copied, setCopied] = useState(false)
@@ -46,6 +49,16 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
setTimeout(() => setCopied(false), 1800)
}
// Open a sent file: prefer the local path via Electron (Finder / default
// app); fall back to the served URL in a browser when unavailable.
const openAttachment = (att: { abs_path?: string; preview_url?: string; file_path: string }) => {
if (att.abs_path && window.electronAPI?.openPath) {
window.electronAPI.openPath(att.abs_path)
return
}
window.open(apiClient.getFileUrl(att.preview_url || att.file_path), '_blank')
}
if (isUser) {
return (
<div className="group flex flex-col items-end px-4 sm:px-6 py-2">
@@ -73,11 +86,9 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
</div>
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
{onEdit && message.userSeq != null && (
<HoverAction onClick={() => onEdit(message.id)} title={t('msg_edit')}>
<Pencil size={13} />
</HoverAction>
)}
{/* Edit entry hidden: editing a past question cascade-deletes all
subsequent turns, which surprises users. Kept off until we support
non-destructive editing. */}
{onDelete && message.userSeq != null && (
<HoverAction onClick={() => onDelete(message)} title={t('msg_delete')} danger>
<Trash2 size={13} />
@@ -118,6 +129,42 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
{/* Final answer */}
{message.content && <Markdown content={message.content} />}
{/* Media attachments sent via the `send` tool (images / files). */}
{message.attachments && message.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{message.attachments.map((att, i) =>
att.file_type === 'image' ? (
<img
key={i}
src={apiClient.getFileUrl(att.preview_url || att.file_path)}
alt={att.file_name}
onLoad={() => onMediaLoad?.()}
onClick={() => window.open(apiClient.getFileUrl(att.preview_url || att.file_path), '_blank')}
className="max-w-[320px] w-full rounded-xl border border-default cursor-zoom-in"
/>
) : att.file_type === 'video' ? (
<video
key={i}
src={apiClient.getFileUrl(att.preview_url || att.file_path)}
controls
onLoadedData={() => onMediaLoad?.()}
className="max-w-[360px] w-full rounded-xl border border-default"
/>
) : (
<button
key={i}
type="button"
onClick={() => openAttachment(att)}
className="flex items-center gap-1.5 px-3 py-2 bg-surface-2 rounded-xl text-xs text-content-secondary hover:text-content cursor-pointer"
>
<FileIcon size={13} />
{att.file_name}
</button>
)
)}
</div>
)}
{showCursor && (
<div className="flex items-center gap-1 py-0.5">
<span className="typing-dot" />

View File

@@ -182,6 +182,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
[deleteMessage, activeId]
)
// Inline images/videos load asynchronously and grow the bubble after mount,
// so a scroll triggered on message change fires before the final height is
// known. Re-scroll once media loads, but only while following the bottom.
const handleMediaLoad = useCallback(() => {
if (followBottomRef.current) scrollToBottom(false)
}, [scrollToBottom])
const handleScroll = useCallback(
async (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
@@ -250,6 +257,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
onRegenerate={handleRegenerate}
onEdit={handleEdit}
onDelete={handleDelete}
onMediaLoad={handleMediaLoad}
/>
))}
<div ref={bottomRef} />

View File

@@ -218,6 +218,31 @@ export const useChatStore = create<ChatState>((set, get) => {
}))
break
case 'image':
case 'file': {
// Media pushed by the `send` tool (file_to_send). `content` is either
// a backend /api/file?path=... URL or a passed-through http(s) URL.
const url = data.content || ''
if (!url) break
// Prefer the concrete media kind from the backend (image/video/...);
// fall back to the coarse SSE event type.
const kind = data.file_type || (data.type === 'image' ? 'image' : 'file')
const attType: Attachment['file_type'] =
kind === 'image' ? 'image' : kind === 'video' ? 'video' : 'file'
const att: Attachment = {
file_path: url,
file_name: data.file_name || 'file',
file_type: attType,
preview_url: url,
abs_path: data.abs_path,
}
updateMsg(sid, botId, (m) => ({
...m,
attachments: [...(m.attachments || []), att],
}))
break
}
case 'cancelled':
updateMsg(sid, botId, (m) => ({ ...m, isCancelled: true }))
break

View File

@@ -8,6 +8,8 @@ export interface ElectronAPI {
restartBackend: () => Promise<boolean>
selectDirectory: () => Promise<string | null>
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>
/** Open a local file with the OS default app. Resolves to '' on success. */
openPath: (targetPath: string) => Promise<string>
// Listener registrars return an unsubscribe fn for cleanup.
onBackendStatus: (callback: (data: BackendStatusEvent) => void) => () => void
onBackendLog: (callback: (line: string) => void) => () => void
@@ -94,6 +96,9 @@ export interface Attachment {
file_name: string
file_type: 'image' | 'video' | 'file' | 'directory'
preview_url?: string
/** Local absolute path (set for files sent via the `send` tool) so the
* desktop client can open them directly with the OS default app. */
abs_path?: string
}
/** Live tool event during SSE streaming. */
@@ -137,6 +142,7 @@ export interface StreamEvent {
execution_time?: number
has_tool_calls?: boolean
path?: string
abs_path?: string
file_name?: string
file_type?: string
web_url?: string