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

@@ -54,6 +54,11 @@ class Send(BaseTool):
if not path: if not path:
return ToolResult.fail("Error: path parameter is required") return ToolResult.fail("Error: path parameter is required")
# Pass through remote URLs directly (no local file check): the client
# renders the link inline, so no download is needed.
if path.lower().startswith(("http://", "https://")):
return self._build_url_result(path, message)
# Resolve path # Resolve path
absolute_path = self._resolve_path(path) absolute_path = self._resolve_path(path)
@@ -112,6 +117,46 @@ class Send(BaseTool):
return ToolResult.success(result) return ToolResult.success(result)
def _build_url_result(self, url: str, message: str) -> ToolResult:
"""Build a file_to_send result for a remote http(s) URL.
The URL is passed through as both ``path`` and ``url`` so downstream
channels render it inline without downloading it locally.
"""
# Infer file type from the URL path extension (ignore query string).
from urllib.parse import urlparse
url_path = urlparse(url).path
file_ext = Path(url_path).suffix.lower()
file_name = Path(url_path).name or "file"
if file_ext in self.image_extensions:
file_type = "image"
mime_type = self._get_image_mime_type(file_ext)
elif file_ext in self.video_extensions:
file_type = "video"
mime_type = self._get_video_mime_type(file_ext)
elif file_ext in self.audio_extensions:
file_type = "audio"
mime_type = self._get_audio_mime_type(file_ext)
elif file_ext in self.document_extensions:
file_type = "document"
mime_type = self._get_document_mime_type(file_ext)
else:
# Default to image: most pass-through URLs are generated images.
file_type = "image"
mime_type = "image/jpeg"
result = {
"type": "file_to_send",
"file_type": file_type,
"path": url,
"url": url,
"file_name": file_name,
"mime_type": mime_type,
"message": message or f"正在发送 {file_name}",
}
return ToolResult.success(result)
def _resolve_path(self, path: str) -> str: def _resolve_path(self, path: str) -> str:
"""Resolve path to absolute path""" """Resolve path to absolute path"""
path = expand_path(path) path = expand_path(path)

View File

@@ -684,11 +684,21 @@ class AgentBridge:
""" """
file_type = file_info.get("file_type", "file") file_type = file_info.get("file_type", "file")
file_path = file_info.get("path") file_path = file_info.get("path")
# Remote URLs are passed through as-is; local paths get a file:// prefix
# so the channel can read them from disk.
remote_url = file_info.get("url", "")
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
def _to_channel_url(p: str) -> str:
if is_remote:
return remote_url
if p and p.lower().startswith(("http://", "https://")):
return p
return f"file://{p}"
# For images, use IMAGE_URL type (channel will handle upload) # For images, use IMAGE_URL type (channel will handle upload)
if file_type == "image": if file_type == "image":
# Convert local path to file:// URL for channel processing file_url = _to_channel_url(file_path)
file_url = f"file://{file_path}"
logger.info(f"[AgentBridge] Sending image: {file_url}") logger.info(f"[AgentBridge] Sending image: {file_url}")
reply = Reply(ReplyType.IMAGE_URL, file_url) reply = Reply(ReplyType.IMAGE_URL, file_url)
# Attach text message if present (for channels that support text+image) # Attach text message if present (for channels that support text+image)
@@ -698,7 +708,7 @@ class AgentBridge:
# For all file types (document, video, audio), use FILE type # For all file types (document, video, audio), use FILE type
if file_type in ["document", "video", "audio"]: if file_type in ["document", "video", "audio"]:
file_url = f"file://{file_path}" file_url = _to_channel_url(file_path)
logger.info(f"[AgentBridge] Sending {file_type}: {file_url}") logger.info(f"[AgentBridge] Sending {file_type}: {file_url}")
reply = Reply(ReplyType.FILE, file_url) reply = Reply(ReplyType.FILE, file_url)
reply.file_name = file_info.get("file_name", os.path.basename(file_path)) reply.file_name = file_info.get("file_name", os.path.basename(file_path))
@@ -708,7 +718,7 @@ class AgentBridge:
return reply return reply
# For all other file types (tar.gz, zip, etc.), also use FILE type # For all other file types (tar.gz, zip, etc.), also use FILE type
file_url = f"file://{file_path}" file_url = _to_channel_url(file_path)
logger.info(f"[AgentBridge] Sending generic file: {file_url}") logger.info(f"[AgentBridge] Sending generic file: {file_url}")
reply = Reply(ReplyType.FILE, file_url) reply = Reply(ReplyType.FILE, file_url)
reply.file_name = file_info.get("file_name", os.path.basename(file_path)) reply.file_name = file_info.get("file_name", os.path.basename(file_path))

View File

@@ -556,14 +556,29 @@ class WebChannel(ChatChannel):
file_path = data.get("path", "") file_path = data.get("path", "")
file_name = data.get("file_name", os.path.basename(file_path)) file_name = data.get("file_name", os.path.basename(file_path))
file_type = data.get("file_type", "file") file_type = data.get("file_type", "file")
# Remote URLs are passed through as-is; local files are served
# via the backend /api/file endpoint.
remote_url = data.get("url", "")
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
if is_remote:
web_url = remote_url
else:
from urllib.parse import quote from urllib.parse import quote
web_url = f"/api/file?path={quote(file_path)}" web_url = f"/api/file?path={quote(file_path)}"
is_image = file_type == "image" is_image = file_type == "image"
q.put({ payload = {
"type": "image" if is_image else "file", "type": "image" if is_image else "file",
"content": web_url, "content": web_url,
"file_name": file_name, "file_name": file_name,
}) # Preserve the concrete media kind (image/video/audio/...)
# so richer clients can render an inline player.
"file_type": file_type,
}
# Expose the local absolute path so the desktop client can open
# the file directly (Finder / default app) instead of the browser.
if not is_remote and file_path:
payload["abs_path"] = file_path
q.put(payload)
return on_event return on_event

View File

@@ -219,6 +219,15 @@ function setupIPC() {
return result.canceled ? null : result.filePaths[0] 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) // Custom window controls (used by Windows frameless titlebar)
ipcMain.handle('window-minimize', () => mainWindow?.minimize()) ipcMain.handle('window-minimize', () => mainWindow?.minimize())
ipcMain.handle('window-maximize', () => { ipcMain.handle('window-maximize', () => {

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react' 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 type { ChatMessage } from '../types'
import { t } from '../i18n' import { t } from '../i18n'
import apiClient from '../api/client' import apiClient from '../api/client'
@@ -11,6 +11,9 @@ interface MessageBubbleProps {
onRegenerate?: (id: string) => void onRegenerate?: (id: string) => void
onEdit?: (id: string) => void onEdit?: (id: string) => void
onDelete?: (msg: ChatMessage) => 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 { function fmtTime(ts: number): string {
@@ -36,7 +39,7 @@ const HoverAction: React.FC<{ onClick: () => void; title: string; danger?: boole
</button> </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 isUser = message.role === 'user'
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@@ -46,6 +49,16 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
setTimeout(() => setCopied(false), 1800) 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) { if (isUser) {
return ( return (
<div className="group flex flex-col items-end px-4 sm:px-6 py-2"> <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>
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity"> <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> <span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
{onEdit && message.userSeq != null && ( {/* Edit entry hidden: editing a past question cascade-deletes all
<HoverAction onClick={() => onEdit(message.id)} title={t('msg_edit')}> subsequent turns, which surprises users. Kept off until we support
<Pencil size={13} /> non-destructive editing. */}
</HoverAction>
)}
{onDelete && message.userSeq != null && ( {onDelete && message.userSeq != null && (
<HoverAction onClick={() => onDelete(message)} title={t('msg_delete')} danger> <HoverAction onClick={() => onDelete(message)} title={t('msg_delete')} danger>
<Trash2 size={13} /> <Trash2 size={13} />
@@ -118,6 +129,42 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
{/* Final answer */} {/* Final answer */}
{message.content && <Markdown content={message.content} />} {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 && ( {showCursor && (
<div className="flex items-center gap-1 py-0.5"> <div className="flex items-center gap-1 py-0.5">
<span className="typing-dot" /> <span className="typing-dot" />

View File

@@ -182,6 +182,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
[deleteMessage, activeId] [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( const handleScroll = useCallback(
async (e: React.UIEvent<HTMLDivElement>) => { async (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget const el = e.currentTarget
@@ -250,6 +257,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onMediaLoad={handleMediaLoad}
/> />
))} ))}
<div ref={bottomRef} /> <div ref={bottomRef} />

View File

@@ -218,6 +218,31 @@ export const useChatStore = create<ChatState>((set, get) => {
})) }))
break 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': case 'cancelled':
updateMsg(sid, botId, (m) => ({ ...m, isCancelled: true })) updateMsg(sid, botId, (m) => ({ ...m, isCancelled: true }))
break break

View File

@@ -8,6 +8,8 @@ export interface ElectronAPI {
restartBackend: () => Promise<boolean> restartBackend: () => Promise<boolean>
selectDirectory: () => Promise<string | null> selectDirectory: () => Promise<string | null>
selectFile: (filters?: { name: string; extensions: string[] }[]) => 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. // Listener registrars return an unsubscribe fn for cleanup.
onBackendStatus: (callback: (data: BackendStatusEvent) => void) => () => void onBackendStatus: (callback: (data: BackendStatusEvent) => void) => () => void
onBackendLog: (callback: (line: string) => void) => () => void onBackendLog: (callback: (line: string) => void) => () => void
@@ -94,6 +96,9 @@ export interface Attachment {
file_name: string file_name: string
file_type: 'image' | 'video' | 'file' | 'directory' file_type: 'image' | 'video' | 'file' | 'directory'
preview_url?: string 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. */ /** Live tool event during SSE streaming. */
@@ -137,6 +142,7 @@ export interface StreamEvent {
execution_time?: number execution_time?: number
has_tool_calls?: boolean has_tool_calls?: boolean
path?: string path?: string
abs_path?: string
file_name?: string file_name?: string
file_type?: string file_type?: string
web_url?: string web_url?: string