mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): render sent images/videos/files
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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")
|
||||||
from urllib.parse import quote
|
# Remote URLs are passed through as-is; local files are served
|
||||||
web_url = f"/api/file?path={quote(file_path)}"
|
# 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
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user