Merge pull request #4282 from janhq/fix/message-attachments-preview
This commit is contained in:
commit
0cd0ff0443
@ -80,8 +80,8 @@ export abstract class OAIEngine extends AIEngine {
|
|||||||
role: ChatCompletionRole.Assistant,
|
role: ChatCompletionRole.Assistant,
|
||||||
content: [],
|
content: [],
|
||||||
status: MessageStatus.Pending,
|
status: MessageStatus.Pending,
|
||||||
created: timestamp,
|
created_at: timestamp,
|
||||||
updated: timestamp,
|
completed_at: timestamp,
|
||||||
object: 'thread.message',
|
object: 'thread.message',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -194,8 +194,8 @@ export const createMessage = async (threadId: string, message: any) => {
|
|||||||
id: msgId,
|
id: msgId,
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
status: MessageStatus.Ready,
|
status: MessageStatus.Ready,
|
||||||
created: createdAt,
|
created_at: createdAt,
|
||||||
updated: createdAt,
|
completed_at: createdAt,
|
||||||
object: 'thread.message',
|
object: 'thread.message',
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
@ -27,9 +27,9 @@ export type ThreadMessage = {
|
|||||||
/** The status of this message. **/
|
/** The status of this message. **/
|
||||||
status: MessageStatus
|
status: MessageStatus
|
||||||
/** The timestamp indicating when this message was created. Represented in Unix time. **/
|
/** The timestamp indicating when this message was created. Represented in Unix time. **/
|
||||||
created: number
|
created_at: number
|
||||||
/** The timestamp indicating when this message was updated. Represented in Unix time. **/
|
/** The timestamp indicating when this message was updated. Represented in Unix time. **/
|
||||||
updated: number
|
completed_at: number
|
||||||
/** The additional metadata of this message. **/
|
/** The additional metadata of this message. **/
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { openFileExplorer, joinPath, baseName } from '@janhq/core'
|
import { openFileExplorer, joinPath, baseName, fs } from '@janhq/core'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
|
import { getFileInfo } from '@/utils/file'
|
||||||
|
|
||||||
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
|
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
|
||||||
import { activeAssistantAtom } from '@/helpers/atoms/Assistant.atom'
|
import { activeAssistantAtom } from '@/helpers/atoms/Assistant.atom'
|
||||||
import { selectedModelAtom } from '@/helpers/atoms/Model.atom'
|
import { selectedModelAtom } from '@/helpers/atoms/Model.atom'
|
||||||
@ -47,13 +49,23 @@ export const usePath = () => {
|
|||||||
const onViewFile = async (id: string) => {
|
const onViewFile = async (id: string) => {
|
||||||
if (!activeThread) return
|
if (!activeThread) return
|
||||||
|
|
||||||
let filePath = undefined
|
|
||||||
|
|
||||||
id = await baseName(id)
|
id = await baseName(id)
|
||||||
filePath = await joinPath(['threads', `${activeThread.id}/files`, `${id}`])
|
|
||||||
if (!filePath) return
|
// New ID System
|
||||||
const fullPath = await joinPath([janDataFolderPath, filePath])
|
if (!id.startsWith('file-')) {
|
||||||
openFileExplorer(fullPath)
|
const threadFilePath = await joinPath([
|
||||||
|
janDataFolderPath,
|
||||||
|
'threads',
|
||||||
|
`${activeThread.id}/files`,
|
||||||
|
id,
|
||||||
|
])
|
||||||
|
openFileExplorer(threadFilePath)
|
||||||
|
} else {
|
||||||
|
id = id.split('.')[0]
|
||||||
|
const fileName = (await getFileInfo(id)).filename
|
||||||
|
const filesPath = await joinPath([janDataFolderPath, 'files', fileName])
|
||||||
|
openFileExplorer(filesPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onViewFileContainer = async () => {
|
const onViewFileContainer = async () => {
|
||||||
|
|||||||
@ -181,7 +181,7 @@ export default function useSendChatMessage() {
|
|||||||
// Update thread state
|
// Update thread state
|
||||||
const updatedThread: Thread = {
|
const updatedThread: Thread = {
|
||||||
...activeThreadRef.current,
|
...activeThreadRef.current,
|
||||||
updated: newMessage.created,
|
updated: newMessage.created_at,
|
||||||
metadata: {
|
metadata: {
|
||||||
...activeThreadRef.current.metadata,
|
...activeThreadRef.current.metadata,
|
||||||
lastMessage: prompt,
|
lastMessage: prompt,
|
||||||
|
|||||||
@ -126,7 +126,7 @@ const ChatInput = () => {
|
|||||||
const renderPreview = (fileUpload: any) => {
|
const renderPreview = (fileUpload: any) => {
|
||||||
if (fileUpload) {
|
if (fileUpload) {
|
||||||
if (fileUpload.type === 'image') {
|
if (fileUpload.type === 'image') {
|
||||||
return <ImageUploadPreview file={fileUpload[0].file} />
|
return <ImageUploadPreview file={fileUpload.file} />
|
||||||
} else {
|
} else {
|
||||||
return <FileUploadPreview />
|
return <FileUploadPreview />
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,44 +1,39 @@
|
|||||||
import { memo } from 'react'
|
import { memo, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Tooltip } from '@janhq/joi'
|
|
||||||
|
|
||||||
import { FolderOpenIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { usePath } from '@/hooks/usePath'
|
import { usePath } from '@/hooks/usePath'
|
||||||
|
|
||||||
import { toGibibytes } from '@/utils/converter'
|
import { getFileInfo } from '@/utils/file'
|
||||||
import { openFileTitle } from '@/utils/titleUtils'
|
|
||||||
|
|
||||||
import Icon from '../FileUploadPreview/Icon'
|
import Icon from '../FileUploadPreview/Icon'
|
||||||
|
|
||||||
const DocMessage = ({ id, name }: { id: string; name?: string }) => {
|
const DocMessage = ({ id }: { id: string }) => {
|
||||||
const { onViewFile, onViewFileContainer } = usePath()
|
const { onViewFile } = usePath()
|
||||||
|
const [fileInfo, setFileInfo] = useState<
|
||||||
|
{ filename: string; id: string } | undefined
|
||||||
|
>()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fileInfo) {
|
||||||
|
getFileInfo(id).then((data) => {
|
||||||
|
setFileInfo(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [fileInfo, id])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/file bg-secondary relative mb-2 inline-flex w-60 cursor-pointer gap-x-3 overflow-hidden rounded-lg p-4">
|
<div className="group/file bg-secondary relative mb-2 inline-flex w-60 cursor-pointer gap-x-3 overflow-hidden rounded-lg p-4">
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-0 z-20 hidden h-full w-full bg-black/20 backdrop-blur-sm group-hover/file:inline-block"
|
className="absolute left-0 top-0 z-20 hidden h-full w-full bg-black/20 opacity-50 group-hover/file:inline-block"
|
||||||
onClick={() => onViewFile(`${id}.pdf`)}
|
onClick={() => onViewFile(`${id}.pdf`)}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
|
||||||
trigger={
|
|
||||||
<div
|
|
||||||
className="absolute right-2 top-2 z-20 hidden h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-[hsla(var(--app-bg))] group-hover/file:flex"
|
|
||||||
onClick={onViewFileContainer}
|
|
||||||
>
|
|
||||||
<FolderOpenIcon size={20} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
content={<span>{openFileTitle()}</span>}
|
|
||||||
/>
|
|
||||||
<Icon type="pdf" />
|
<Icon type="pdf" />
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<h6 className="line-clamp-1 w-4/5 font-medium">
|
<h6 className="line-clamp-1 w-4/5 overflow-hidden font-medium">
|
||||||
{name?.replaceAll(/[-._]/g, ' ')}
|
{fileInfo?.filename}
|
||||||
</h6>
|
</h6>
|
||||||
{/* <p className="text-[hsla(var(--text-secondary)]">
|
<p className="text-[hsla(var(--text-secondary)] line-clamp-1 overflow-hidden truncate">
|
||||||
{toGibibytes(Number(size))}
|
{fileInfo?.id ?? id}
|
||||||
</p> */}
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,34 +1,11 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
|
||||||
import { Tooltip } from '@janhq/joi'
|
|
||||||
|
|
||||||
import { FolderOpenIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { usePath } from '@/hooks/usePath'
|
|
||||||
|
|
||||||
import { openFileTitle } from '@/utils/titleUtils'
|
|
||||||
|
|
||||||
import { RelativeImage } from '../TextMessage/RelativeImage'
|
import { RelativeImage } from '../TextMessage/RelativeImage'
|
||||||
|
|
||||||
const ImageMessage = ({ image }: { image: string }) => {
|
const ImageMessage = ({ image }: { image: string }) => {
|
||||||
const { onViewFile, onViewFileContainer } = usePath()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/image relative mb-2 inline-flex cursor-pointer overflow-hidden rounded-xl">
|
<div className="group/file relative mb-2 inline-flex overflow-hidden rounded-xl">
|
||||||
<div className="left-0 top-0 z-20 h-full w-full group-hover/image:inline-block">
|
<RelativeImage src={image} />
|
||||||
<RelativeImage src={image} onClick={() => onViewFile(image)} />
|
|
||||||
</div>
|
|
||||||
<Tooltip
|
|
||||||
trigger={
|
|
||||||
<div
|
|
||||||
className="absolute right-2 top-2 z-20 hidden h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-[hsla(var(--app-bg))] group-hover/image:flex"
|
|
||||||
onClick={onViewFileContainer}
|
|
||||||
>
|
|
||||||
<FolderOpenIcon size={20} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
content={<span>{openFileTitle()}</span>}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export const RelativeImage = ({
|
|||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
src: string
|
src: string
|
||||||
onClick: () => void
|
onClick?: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const [path, setPath] = useState<string>('')
|
const [path, setPath] = useState<string>('')
|
||||||
|
|
||||||
@ -17,9 +17,12 @@ export const RelativeImage = ({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick}>
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={onClick ? 'cursor-pointer' : 'cursor-default'}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
className="aspect-auto h-[300px] cursor-pointer"
|
className="aspect-auto h-[300px]"
|
||||||
alt={src}
|
alt={src}
|
||||||
src={src.includes('files/') ? `file://${path}/${src}` : src}
|
src={src.includes('files/') ? `file://${path}/${src}` : src}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -91,7 +91,7 @@ const MessageContainer: React.FC<
|
|||||||
: (activeAssistant?.assistant_name ?? props.role)}
|
: (activeAssistant?.assistant_name ?? props.role)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-medium text-gray-400">
|
<p className="text-xs font-medium text-gray-400">
|
||||||
{props.created && displayDate(props.created ?? new Date())}
|
{props.created_at && displayDate(props.created_at ?? new Date())}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -125,7 +125,9 @@ const MessageContainer: React.FC<
|
|||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
{image && <ImageMessage image={image} />}
|
{image && <ImageMessage image={image} />}
|
||||||
{attachedFile && <DocMessage id={props.id} name={props.id} />}
|
{attachedFile && (
|
||||||
|
<DocMessage id={props.attachments?.[0]?.file_id ?? props.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
{editMessage === props.id ? (
|
{editMessage === props.id ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,27 +1,28 @@
|
|||||||
|
import { displayDate } from './datetime'
|
||||||
|
import { isToday } from './datetime'
|
||||||
|
|
||||||
import { displayDate } from './datetime';
|
test("should return only time for today's timestamp", () => {
|
||||||
import { isToday } from './datetime';
|
const today = new Date()
|
||||||
|
const timestamp = today.getTime()
|
||||||
test('should return only time for today\'s timestamp', () => {
|
const expectedTime = `${today.toLocaleDateString(undefined, {
|
||||||
const today = new Date();
|
day: '2-digit',
|
||||||
const timestamp = today.getTime();
|
month: 'short',
|
||||||
const expectedTime = today.toLocaleTimeString(undefined, {
|
year: 'numeric',
|
||||||
|
})}, ${today.toLocaleTimeString(undefined, {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit',
|
second: '2-digit',
|
||||||
hour12: true,
|
hour12: true,
|
||||||
});
|
})}`
|
||||||
expect(displayDate(timestamp)).toBe(expectedTime);
|
expect(displayDate(timestamp / 1000)).toBe(expectedTime)
|
||||||
});
|
})
|
||||||
|
|
||||||
|
|
||||||
test('should return N/A for undefined timestamp', () => {
|
test('should return N/A for undefined timestamp', () => {
|
||||||
expect(displayDate()).toBe('N/A');
|
expect(displayDate()).toBe('N/A')
|
||||||
});
|
})
|
||||||
|
|
||||||
|
test("should return true for today's timestamp", () => {
|
||||||
test('should return true for today\'s timestamp', () => {
|
const today = new Date()
|
||||||
const today = new Date();
|
const timestamp = today.setHours(0, 0, 0, 0)
|
||||||
const timestamp = today.setHours(0, 0, 0, 0);
|
expect(isToday(timestamp)).toBe(true)
|
||||||
expect(isToday(timestamp)).toBe(true);
|
})
|
||||||
});
|
|
||||||
|
|||||||
@ -6,7 +6,10 @@ export const isToday = (timestamp: number) => {
|
|||||||
export const displayDate = (timestamp?: string | number | Date) => {
|
export const displayDate = (timestamp?: string | number | Date) => {
|
||||||
if (!timestamp) return 'N/A'
|
if (!timestamp) return 'N/A'
|
||||||
|
|
||||||
const date = new Date(timestamp)
|
const date =
|
||||||
|
typeof timestamp === 'number'
|
||||||
|
? new Date(timestamp * 1000)
|
||||||
|
: new Date(timestamp)
|
||||||
|
|
||||||
let displayDate = `${date.toLocaleDateString(undefined, {
|
let displayDate = `${date.toLocaleDateString(undefined, {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
|
|||||||
@ -47,3 +47,12 @@ export const uploader = () => {
|
|||||||
})
|
})
|
||||||
return uppy
|
return uppy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the file information from the server.
|
||||||
|
*/
|
||||||
|
export const getFileInfo = (id: string) => {
|
||||||
|
return fetch(`${API_BASE_URL}/v1/files/${id}`)
|
||||||
|
.then((e) => e.json())
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|||||||
@ -23,8 +23,8 @@ describe('ThreadMessageBuilder', () => {
|
|||||||
expect(result.thread_id).toBe(msgRequest.thread.id)
|
expect(result.thread_id).toBe(msgRequest.thread.id)
|
||||||
expect(result.role).toBe(ChatCompletionRole.User)
|
expect(result.role).toBe(ChatCompletionRole.User)
|
||||||
expect(result.status).toBe(MessageStatus.Ready)
|
expect(result.status).toBe(MessageStatus.Ready)
|
||||||
expect(result.created).toBeDefined()
|
expect(result.created_at).toBeDefined()
|
||||||
expect(result.updated).toBeDefined()
|
expect(result.completed_at).toBeDefined()
|
||||||
expect(result.object).toBe('thread.message')
|
expect(result.object).toBe('thread.message')
|
||||||
expect(result.content).toEqual([])
|
expect(result.content).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@ -29,8 +29,8 @@ export class ThreadMessageBuilder {
|
|||||||
attachments: this.attachments,
|
attachments: this.attachments,
|
||||||
role: ChatCompletionRole.User,
|
role: ChatCompletionRole.User,
|
||||||
status: MessageStatus.Ready,
|
status: MessageStatus.Ready,
|
||||||
created: timestamp,
|
created_at: timestamp,
|
||||||
updated: timestamp,
|
completed_at: timestamp,
|
||||||
object: 'thread.message',
|
object: 'thread.message',
|
||||||
content: this.content,
|
content: this.content,
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user