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:
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user