Commit 5dc3658e authored by StyleZhang's avatar StyleZhang

feat: support upload image

parent ae70d2fe
...@@ -6,10 +6,11 @@ export async function POST(request: NextRequest) { ...@@ -6,10 +6,11 @@ export async function POST(request: NextRequest) {
const { const {
inputs, inputs,
query, query,
files,
conversation_id: conversationId, conversation_id: conversationId,
response_mode: responseMode, response_mode: responseMode,
} = body } = body
const { user } = getInfo(request) const { user } = getInfo(request)
const res = await client.createChatMessage(inputs, query, user, responseMode, conversationId) const res = await client.createChatMessage(inputs, query, user, responseMode, conversationId, files)
return new Response(res.data as any) return new Response(res.data as any)
} }
import { type NextRequest } from 'next/server'
import { client, getInfo } from '@/app/api/utils/common'
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const { user } = getInfo(request)
formData.append('user', user)
const res = await client.fileUpload(formData)
return new Response(res.data.id as any)
}
catch (e: any) {
return new Response(e.message)
}
}
import { forwardRef } from 'react'
import { generate } from './utils'
import type { AbstractNode } from './utils'
export type IconData = {
name: string
icon: AbstractNode
}
export type IconBaseProps = {
data: IconData
className?: string
onClick?: React.MouseEventHandler<SVGElement>
style?: React.CSSProperties
}
const IconBase = forwardRef<React.MutableRefObject<HTMLOrSVGElement>, IconBaseProps>((props, ref) => {
const { data, className, onClick, style, ...restProps } = props
return generate(data.icon, `svg-${data.name}`, {
className,
onClick,
style,
'data-icon': data.name,
'aria-hidden': 'true',
...restProps,
'ref': ref,
})
})
export default IconBase
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "image-plus"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Icon",
"d": "M8.33333 2.00016H5.2C4.0799 2.00016 3.51984 2.00016 3.09202 2.21815C2.71569 2.4099 2.40973 2.71586 2.21799 3.09218C2 3.52001 2 4.08006 2 5.20016V10.8002C2 11.9203 2 12.4803 2.21799 12.9081C2.40973 13.2845 2.71569 13.5904 3.09202 13.7822C3.51984 14.0002 4.07989 14.0002 5.2 14.0002H11.3333C11.9533 14.0002 12.2633 14.0002 12.5176 13.932C13.2078 13.7471 13.7469 13.208 13.9319 12.5178C14 12.2635 14 11.9535 14 11.3335M12.6667 5.3335V1.3335M10.6667 3.3335H14.6667M7 5.66683C7 6.40321 6.40305 7.00016 5.66667 7.00016C4.93029 7.00016 4.33333 6.40321 4.33333 5.66683C4.33333 4.93045 4.93029 4.3335 5.66667 4.3335C6.40305 4.3335 7 4.93045 7 5.66683ZM9.99336 7.94559L4.3541 13.0722C4.03691 13.3605 3.87831 13.5047 3.86429 13.6296C3.85213 13.7379 3.89364 13.8453 3.97546 13.9172C4.06985 14.0002 4.28419 14.0002 4.71286 14.0002H10.9707C11.9301 14.0002 12.4098 14.0002 12.7866 13.839C13.2596 13.6366 13.6365 13.2598 13.8388 12.7868C14 12.41 14 11.9303 14 10.9708C14 10.648 14 10.4866 13.9647 10.3363C13.9204 10.1474 13.8353 9.9704 13.7155 9.81776C13.6202 9.6963 13.4941 9.59546 13.242 9.3938L11.3772 7.90194C11.1249 7.7001 10.9988 7.59919 10.8599 7.56357C10.7374 7.53218 10.6086 7.53624 10.4884 7.57529C10.352 7.61959 10.2324 7.72826 9.99336 7.94559Z",
"stroke": "currentColor",
"stroke-width": "1.25",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
}
]
}
]
},
"name": "ImagePlus"
}
\ No newline at end of file
import * as React from 'react'
import data from './data.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'ImagePlus'
export default Icon
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "17",
"height": "16",
"viewBox": "0 0 17 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "link-03"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "Solid"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M9.01569 1.83378C9.7701 1.10515 10.7805 0.701975 11.8293 0.711089C12.8781 0.720202 13.8813 1.14088 14.623 1.88251C15.3646 2.62414 15.7853 3.62739 15.7944 4.67618C15.8035 5.72497 15.4003 6.73538 14.6717 7.48979L14.6636 7.49805L12.6637 9.49796C12.2581 9.90362 11.7701 10.2173 11.2327 10.4178C10.6953 10.6183 10.1211 10.7008 9.54897 10.6598C8.97686 10.6189 8.42025 10.4553 7.91689 10.1803C7.41354 9.90531 6.97522 9.52527 6.63165 9.06596C6.41112 8.77113 6.47134 8.35334 6.76618 8.1328C7.06101 7.91226 7.4788 7.97249 7.69934 8.26732C7.92838 8.57353 8.2206 8.82689 8.55617 9.01023C8.89174 9.19356 9.26281 9.30259 9.64422 9.3299C10.0256 9.35722 10.4085 9.30219 10.7667 9.16854C11.125 9.0349 11.4503 8.82576 11.7207 8.55532L13.7164 6.55956C14.1998 6.05705 14.4672 5.38513 14.4611 4.68777C14.455 3.98857 14.1746 3.31974 13.6802 2.82532C13.1857 2.3309 12.5169 2.05045 11.8177 2.04437C11.12 2.03831 10.4478 2.30591 9.94526 2.78967L8.80219 3.92609C8.54108 4.18568 8.11898 4.18445 7.85939 3.92334C7.5998 3.66223 7.60103 3.24012 7.86214 2.98053L9.0088 1.84053L9.01569 1.83378Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M5.76493 5.58217C6.30234 5.3817 6.87657 5.29915 7.44869 5.34012C8.0208 5.3811 8.57741 5.54463 9.08077 5.81964C9.58412 6.09465 10.0224 6.47469 10.366 6.93399C10.5865 7.22882 10.5263 7.64662 10.2315 7.86715C9.93665 8.08769 9.51886 8.02746 9.29832 7.73263C9.06928 7.42643 8.77706 7.17307 8.44149 6.98973C8.10592 6.80639 7.73485 6.69737 7.35344 6.67005C6.97203 6.64274 6.58921 6.69777 6.23094 6.83141C5.87266 6.96506 5.54733 7.17419 5.27699 7.44463L3.28123 9.44039C2.79787 9.94291 2.5305 10.6148 2.53656 11.3122C2.54263 12.0114 2.82309 12.6802 3.31751 13.1746C3.81193 13.6691 4.48076 13.9495 5.17995 13.9556C5.87732 13.9616 6.54923 13.6943 7.05174 13.2109L8.18743 12.0752C8.44777 11.8149 8.86988 11.8149 9.13023 12.0752C9.39058 12.3356 9.39058 12.7577 9.13023 13.018L7.99023 14.158L7.98197 14.1662C7.22756 14.8948 6.21715 15.298 5.16837 15.2889C4.11958 15.2798 3.11633 14.8591 2.3747 14.1174C1.63307 13.3758 1.21239 12.3726 1.20328 11.3238C1.19416 10.275 1.59734 9.26458 2.32597 8.51017L2.33409 8.50191L4.33401 6.50199C4.33398 6.50202 4.33404 6.50196 4.33401 6.50199C4.7395 6.09638 5.22756 5.78262 5.76493 5.58217Z",
"fill": "currentColor"
},
"children": []
}
]
}
]
}
]
},
"name": "Link03"
}
\ No newline at end of file
import * as React from 'react'
import data from './data.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'Link03'
export default Icon
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"clip-path": "url(#clip0_6037_51601)"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M7.99992 1.33398V4.00065M7.99992 12.0007V14.6673M3.99992 8.00065H1.33325M14.6666 8.00065H11.9999M12.7189 12.7196L10.8333 10.834M12.7189 3.33395L10.8333 5.21956M3.28097 12.7196L5.16659 10.834M3.28097 3.33395L5.16659 5.21956",
"stroke": "currentColor",
"stroke-width": "1.25",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
}
]
},
{
"type": "element",
"name": "defs",
"attributes": {},
"children": [
{
"type": "element",
"name": "clipPath",
"attributes": {
"id": "clip0_6037_51601"
},
"children": [
{
"type": "element",
"name": "rect",
"attributes": {
"width": "16",
"height": "16",
"fill": "white"
},
"children": []
}
]
}
]
}
]
},
"name": "Loading02"
}
\ No newline at end of file
import * as React from 'react'
import data from './data.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'Loading02'
export default Icon
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "24",
"height": "24",
"viewBox": "0 0 24 24",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M2 10C2 10 4.00498 7.26822 5.63384 5.63824C7.26269 4.00827 9.5136 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.89691 21 4.43511 18.2543 3.35177 14.5M2 10V4M2 10H8",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
}
]
},
"name": "RefreshCcw01"
}
\ No newline at end of file
import * as React from 'react'
import data from './data.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'RefreshCcw01'
export default Icon
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "Left Icon",
"clip-path": "url(#clip0_12728_40636)"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Icon",
"d": "M10.6654 8.00016L7.9987 5.3335M7.9987 5.3335L5.33203 8.00016M7.9987 5.3335V10.6668M14.6654 8.00016C14.6654 11.6821 11.6806 14.6668 7.9987 14.6668C4.3168 14.6668 1.33203 11.6821 1.33203 8.00016C1.33203 4.31826 4.3168 1.3335 7.9987 1.3335C11.6806 1.3335 14.6654 4.31826 14.6654 8.00016Z",
"stroke": "currentColor",
"stroke-width": "1.5",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
}
]
},
{
"type": "element",
"name": "defs",
"attributes": {},
"children": [
{
"type": "element",
"name": "clipPath",
"attributes": {
"id": "clip0_12728_40636"
},
"children": [
{
"type": "element",
"name": "rect",
"attributes": {
"width": "16",
"height": "16",
"fill": "white"
},
"children": []
}
]
}
]
}
]
},
"name": "Upload03"
}
\ No newline at end of file
import * as React from 'react'
import data from './data.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'Upload03'
export default Icon
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "x-close"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Icon",
"d": "M12 4L4 12M4 4L12 12",
"stroke": "currentColor",
"stroke-width": "1.25",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
}
]
}
]
},
"name": "XClose"
}
\ No newline at end of file
import * as React from 'react'
import data from './data.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'XClose'
export default Icon
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "12",
"height": "12",
"viewBox": "0 0 12 12",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "alert-triangle"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Solid",
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M6.40616 0.834185C6.14751 0.719172 5.85222 0.719172 5.59356 0.834185C5.3938 0.923011 5.26403 1.07947 5.17373 1.20696C5.08495 1.3323 4.9899 1.49651 4.88536 1.67711L0.751783 8.81693C0.646828 8.99818 0.551451 9.16289 0.486781 9.30268C0.421056 9.44475 0.349754 9.63572 0.372478 9.85369C0.401884 10.1357 0.549654 10.392 0.779012 10.5588C0.956259 10.6877 1.15726 10.7217 1.31314 10.736C1.46651 10.75 1.65684 10.75 1.86628 10.75H10.1334C10.3429 10.75 10.5332 10.75 10.6866 10.736C10.8425 10.7217 11.0435 10.6877 11.2207 10.5588C11.4501 10.392 11.5978 10.1357 11.6272 9.85369C11.65 9.63572 11.5787 9.44475 11.5129 9.30268C11.4483 9.1629 11.3529 8.9982 11.248 8.81697L7.11436 1.67709C7.00983 1.49651 6.91477 1.3323 6.82599 1.20696C6.73569 1.07947 6.60593 0.923011 6.40616 0.834185ZM6.49988 4.5C6.49988 4.22386 6.27602 4 5.99988 4C5.72374 4 5.49988 4.22386 5.49988 4.5V6.5C5.49988 6.77614 5.72374 7 5.99988 7C6.27602 7 6.49988 6.77614 6.49988 6.5V4.5ZM5.99988 8C5.72374 8 5.49988 8.22386 5.49988 8.5C5.49988 8.77614 5.72374 9 5.99988 9H6.00488C6.28102 9 6.50488 8.77614 6.50488 8.5C6.50488 8.22386 6.28102 8 6.00488 8H5.99988Z",
"fill": "currentColor"
},
"children": []
}
]
}
]
},
"name": "AlertTriangle"
}
\ No newline at end of file
import * as React from 'react'
import data from './data.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'AlertTriangle'
export default Icon
import React from 'react'
export type AbstractNode = {
name: string
attributes: {
[key: string]: string
}
children?: AbstractNode[]
}
export type Attrs = {
[key: string]: string
}
export function normalizeAttrs(attrs: Attrs = {}): Attrs {
return Object.keys(attrs).reduce((acc: Attrs, key) => {
const val = attrs[key]
key = key.replace(/([-]\w)/g, (g: string) => g[1].toUpperCase())
key = key.replace(/([:]\w)/g, (g: string) => g[1].toUpperCase())
switch (key) {
case 'class':
acc.className = val
delete acc.class
break
case 'style':
(acc.style as any) = val.split(';').reduce((prev, next) => {
const pairs = next?.split(':')
if (pairs[0] && pairs[1]) {
const k = pairs[0].replace(/([-]\w)/g, (g: string) => g[1].toUpperCase())
prev[k] = pairs[1]
}
return prev
}, {} as Attrs)
break
default:
acc[key] = val
}
return acc
}, {})
}
export function generate(
node: AbstractNode,
key: string,
rootProps?: { [key: string]: any } | false,
): any {
if (!rootProps) {
return React.createElement(
node.name,
{ key, ...normalizeAttrs(node.attributes) },
(node.children || []).map((child, index) => generate(child, `${key}-${node.name}-${index}`)),
)
}
return React.createElement(
node.name,
{
key,
...normalizeAttrs(node.attributes),
...rootProps,
},
(node.children || []).map((child, index) => generate(child, `${key}-${node.name}-${index}`)),
)
}
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import cn from 'classnames'
import s from './style.module.css'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
type Props = {
srcs: string[]
}
const getWidthStyle = (imgNum: number) => {
if (imgNum === 1) {
return {
maxWidth: '100%',
}
}
if (imgNum === 2 || imgNum === 4) {
return {
width: 'calc(50% - 4px)',
}
}
return {
width: 'calc(33.3333% - 5.3333px)',
}
}
const ImageGallery: FC<Props> = ({
srcs,
}) => {
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const imgNum = srcs.length
const imgStyle = getWidthStyle(imgNum)
return (
<div className={cn(s[`img-${imgNum}`], 'flex flex-wrap')}>
{/* TODO: support preview */}
{srcs.map((src, index) => (
<img
key={index}
className={s.item}
style={imgStyle}
src={src}
alt=''
onClick={() => setImagePreviewUrl(src)}
/>
))}
{
imagePreviewUrl && (
<ImagePreview
url={imagePreviewUrl}
onCancel={() => setImagePreviewUrl('')}
/>
)
}
</div>
)
}
export default React.memo(ImageGallery)
export const ImageGalleryTest = () => {
const imgGallerySrcs = (() => {
const srcs = []
for (let i = 0; i < 6; i++)
// srcs.push('https://placekitten.com/640/360')
// srcs.push('https://placekitten.com/360/640')
srcs.push('https://placekitten.com/360/360')
return srcs
})()
return (
<div className='space-y-2'>
{imgGallerySrcs.map((_, index) => (
<div key={index} className='p-4 pb-2 rounded-lg bg-[#D1E9FF80]'>
<ImageGallery srcs={imgGallerySrcs.slice(0, index + 1)} />
</div>
))}
</div>
)
}
.item {
height: 200px;
margin-right: 8px;
margin-bottom: 8px;
object-fit: cover;
object-position: center;
border-radius: 8px;
cursor: pointer;
}
.item:nth-child(3n) {
margin-right: 0;
}
.img-2 .item:nth-child(2n),
.img-4 .item:nth-child(2n) {
margin-right: 0;
}
.img-4 .item:nth-child(3n) {
margin-right: 8px;
}
\ No newline at end of file
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from './uploader'
import ImageLinkInput from './image-link-input'
import ImagePlus from '@/app/components/base/icons/line/image-plus'
import { TransferMethod } from '@/types/app'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Upload03 from '@/app/components/base/icons/line/upload-03'
import type { ImageFile, VisionSettings } from '@/types/app'
type UploadOnlyFromLocalProps = {
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
limit?: number
}
const UploadOnlyFromLocal: FC<UploadOnlyFromLocalProps> = ({
onUpload,
disabled,
limit,
}) => {
return (
<Uploader onUpload={onUpload} disabled={disabled} limit={limit}>
{
hovering => (
<div className={`
relative flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer
${hovering && 'bg-gray-100'}
`}>
<ImagePlus className='w-4 h-4 text-gray-500' />
</div>
)
}
</Uploader>
)
}
type UploaderButtonProps = {
methods: VisionSettings['transfer_methods']
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
limit?: number
}
const UploaderButton: FC<UploaderButtonProps> = ({
methods,
onUpload,
disabled,
limit,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const hasUploadFromLocal = methods.find(method => method === TransferMethod.local_file)
const handleUpload = (imageFile: ImageFile) => {
setOpen(false)
onUpload(imageFile)
}
const handleToggle = () => {
if (disabled)
return
setOpen(v => !v)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-start'
>
<PortalToFollowElemTrigger onClick={handleToggle}>
<div className={`
relative flex items-center justify-center w-8 h-8 hover:bg-gray-100 rounded-lg
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
`}>
<ImagePlus className='w-4 h-4 text-gray-500' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<div className='p-2 w-[260px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'>
<ImageLinkInput onUpload={handleUpload} />
{
hasUploadFromLocal && (
<>
<div className='flex items-center mt-2 px-2 text-xs font-medium text-gray-400'>
<div className='mr-3 w-[93px] h-[1px] bg-gradient-to-l from-[#F3F4F6]' />
OR
<div className='ml-3 w-[93px] h-[1px] bg-gradient-to-r from-[#F3F4F6]' />
</div>
<Uploader onUpload={handleUpload} limit={limit}>
{
hovering => (
<div className={`
flex items-center justify-center h-8 text-[13px] font-medium text-[#155EEF] rounded-lg cursor-pointer
${hovering && 'bg-primary-50'}
`}>
<Upload03 className='mr-1 w-4 h-4' />
{t('common.imageUploader.uploadFromComputer')}
</div>
)
}
</Uploader>
</>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
type ChatImageUploaderProps = {
settings: VisionSettings
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
}
const ChatImageUploader: FC<ChatImageUploaderProps> = ({
settings,
onUpload,
disabled,
}) => {
const onlyUploadLocal = settings.transfer_methods.length === 1 && settings.transfer_methods[0] === TransferMethod.local_file
if (onlyUploadLocal) {
return (
<UploadOnlyFromLocal
onUpload={onUpload}
disabled={disabled}
limit={+settings.image_file_size_limit!}
/>
)
}
return (
<UploaderButton
methods={settings.transfer_methods}
onUpload={onUpload}
disabled={disabled}
limit={+settings.image_file_size_limit!}
/>
)
}
export default ChatImageUploader
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUpload } from './utils'
import Toast from '@/app/components/base/toast'
import type { ImageFile } from '@/types/app'
export const useImageFiles = () => {
const { t } = useTranslation()
const { notify } = Toast
const [files, setFiles] = useState<ImageFile[]>([])
const filesRef = useRef<ImageFile[]>([])
const handleUpload = (imageFile: ImageFile) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFile._id)
if (index > -1) {
const currentFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentFile, ...imageFile }, ...files.slice(index + 1)]
setFiles(newFiles)
filesRef.current = newFiles
}
else {
const newFiles = [...files, imageFile]
setFiles(newFiles)
filesRef.current = newFiles
}
}
const handleRemove = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentFile, deleted: true }, ...files.slice(index + 1)]
setFiles(newFiles)
filesRef.current = newFiles
}
}
const handleImageLinkLoadError = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentFile, progress: -1 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
}
}
const handleImageLinkLoadSuccess = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentImageFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: 100 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
}
}
const handleReUpload = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentImageFile = files[index]
imageUpload({
file: currentImageFile.file!,
onProgressCallback: (progress) => {
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
},
onSuccessCallback: (res) => {
const newFiles = [...files.slice(0, index), { ...currentImageFile, fileId: res.id, progress: 100 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
},
onErrorCallback: () => {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') })
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: -1 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
},
})
}
}
const handleClear = () => {
setFiles([])
filesRef.current = []
}
const filteredFiles = useMemo(() => {
return files.filter(file => !file.deleted)
}, [files])
return {
files: filteredFiles,
onUpload: handleUpload,
onRemove: handleRemove,
onImageLinkLoadError: handleImageLinkLoadError,
onImageLinkLoadSuccess: handleImageLinkLoadSuccess,
onReUpload: handleReUpload,
onClear: handleClear,
}
}
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app'
type ImageLinkInputProps = {
onUpload: (imageFile: ImageFile) => void
}
const regex = /^(https?|ftp):\/\//
const ImageLinkInput: FC<ImageLinkInputProps> = ({
onUpload,
}) => {
const { t } = useTranslation()
const [imageLink, setImageLink] = useState('')
const handleClick = () => {
const imageFile = {
type: TransferMethod.remote_url,
_id: `${Date.now()}`,
fileId: '',
progress: regex.test(imageLink) ? 0 : -1,
url: imageLink,
}
onUpload(imageFile)
}
return (
<div className='flex items-center pl-1.5 pr-1 h-8 border border-gray-200 bg-white shadow-xs rounded-lg'>
<input
className='grow mr-0.5 px-1 h-[18px] text-[13px] outline-none appearance-none'
value={imageLink}
onChange={e => setImageLink(e.target.value)}
placeholder={t('common.imageUploader.pasteImageLinkInputPlaceholder') || ''}
/>
<Button
type='primary'
className='!h-6 text-xs font-medium'
disabled={!imageLink}
onClick={handleClick}
>
{t('common.operation.ok')}
</Button>
</div>
)
}
export default ImageLinkInput
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading02 from '@/app/components/base/icons/line/loading-02'
import XClose from '@/app/components/base/icons/line/x-close'
import RefreshCcw01 from '@/app/components/base/icons/line/refresh-ccw-01'
import AlertTriangle from '@/app/components/base/icons/solid/alert-triangle'
import TooltipPlus from '@/app/components/base/tooltip-plus'
import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
type ImageListProps = {
list: ImageFile[]
readonly?: boolean
onRemove?: (imageFileId: string) => void
onReUpload?: (imageFileId: string) => void
onImageLinkLoadSuccess?: (imageFileId: string) => void
onImageLinkLoadError?: (imageFileId: string) => void
}
const ImageList: FC<ImageListProps> = ({
list,
readonly,
onRemove,
onReUpload,
onImageLinkLoadSuccess,
onImageLinkLoadError,
}) => {
const { t } = useTranslation()
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const handleImageLinkLoadSuccess = (item: ImageFile) => {
if (item.type === TransferMethod.remote_url && onImageLinkLoadSuccess && item.progress !== -1)
onImageLinkLoadSuccess(item._id)
}
const handleImageLinkLoadError = (item: ImageFile) => {
if (item.type === TransferMethod.remote_url && onImageLinkLoadError)
onImageLinkLoadError(item._id)
}
return (
<div className='flex flex-wrap'>
{
list.map(item => (
<div
key={item._id}
className='group relative mr-1 border-[0.5px] border-black/5 rounded-lg'
>
{
item.type === TransferMethod.local_file && item.progress !== 100 && (
<>
<div
className='absolute inset-0 flex items-center justify-center z-[1] bg-black/30'
style={{ left: item.progress > -1 ? `${item.progress}%` : 0 }}
>
{
item.progress === -1 && (
<RefreshCcw01 className='w-5 h-5 text-white' onClick={() => onReUpload && onReUpload(item._id)} />
)
}
</div>
{
item.progress > -1 && (
<span className='absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] text-sm text-white mix-blend-lighten z-[1]'>{item.progress}%</span>
)
}
</>
)
}
{
item.type === TransferMethod.remote_url && item.progress !== 100 && (
<div className={`
absolute inset-0 flex items-center justify-center rounded-lg z-[1] border
${item.progress === -1 ? 'bg-[#FEF0C7] border-[#DC6803]' : 'bg-black/[0.16] border-transparent'}
`}>
{
item.progress > -1 && (
<Loading02 className='animate-spin w-5 h-5 text-white' />
)
}
{
item.progress === -1 && (
<TooltipPlus popupContent={t('common.imageUploader.pasteImageLinkInvalid')}>
<AlertTriangle className='w-4 h-4 text-[#DC6803]' />
</TooltipPlus>
)
}
</div>
)
}
<img
className='w-16 h-16 rounded-lg object-cover cursor-pointer border-[0.5px] border-black/5'
alt=''
onLoad={() => handleImageLinkLoadSuccess(item)}
onError={() => handleImageLinkLoadError(item)}
src={item.type === TransferMethod.remote_url ? item.url : item.base64Url}
onClick={() => item.progress === 100 && setImagePreviewUrl((item.type === TransferMethod.remote_url ? item.url : item.base64Url) as string)}
/>
{
!readonly && (
<div
className={`
absolute z-10 -top-[9px] -right-[9px] items-center justify-center w-[18px] h-[18px]
bg-white hover:bg-gray-50 border-[0.5px] border-black/[0.02] rounded-2xl shadow-lg
cursor-pointer
${item.progress === -1 ? 'flex' : 'hidden group-hover:flex'}
`}
onClick={() => onRemove && onRemove(item._id)}
>
<XClose className='w-3 h-3 text-gray-500' />
</div>
)
}
</div>
))
}
{
imagePreviewUrl && (
<ImagePreview
url={imagePreviewUrl}
onCancel={() => setImagePreviewUrl('')}
/>
)
}
</div>
)
}
export default ImageList
import type { FC } from 'react'
import { createPortal } from 'react-dom'
import XClose from '@/app/components/base/icons/line/x-close'
type ImagePreviewProps = {
url: string
onCancel: () => void
}
const ImagePreview: FC<ImagePreviewProps> = ({
url,
onCancel,
}) => {
return createPortal(
<div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]' onClick={e => e.stopPropagation()}>
<img
alt='preview image'
src={url}
className='max-w-full max-h-full'
/>
<div
className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer'
onClick={onCancel}
>
<XClose className='w-4 h-4 text-white' />
</div>
</div>,
document.body,
)
}
export default ImagePreview
'use client'
import type { ChangeEvent, FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUpload } from './utils'
import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app'
import Toast from '@/app/components/base/toast'
type UploaderProps = {
children: (hovering: boolean) => JSX.Element
onUpload: (imageFile: ImageFile) => void
limit?: number
disabled?: boolean
}
const Uploader: FC<UploaderProps> = ({
children,
onUpload,
limit,
disabled,
}) => {
const [hovering, setHovering] = useState(false)
const { notify } = Toast
const { t } = useTranslation()
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file)
return
if (limit && file.size > limit * 1024 * 1024) {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: limit }) })
return
}
const reader = new FileReader()
reader.addEventListener(
'load',
() => {
const imageFile = {
type: TransferMethod.local_file,
_id: `${Date.now()}`,
fileId: '',
file,
url: reader.result as string,
base64Url: reader.result as string,
progress: 0,
}
onUpload(imageFile)
imageUpload({
file: imageFile.file,
onProgressCallback: (progress) => {
onUpload({ ...imageFile, progress })
},
onSuccessCallback: (res) => {
onUpload({ ...imageFile, fileId: res.id, progress: 100 })
},
onErrorCallback: () => {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') })
onUpload({ ...imageFile, progress: -1 })
},
})
},
false,
)
reader.addEventListener(
'error',
() => {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerReadError') })
},
false,
)
reader.readAsDataURL(file)
}
return (
<div
className='relative'
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
{children(hovering)}
<input
className={`
absolute block inset-0 opacity-0 text-[0] w-full
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
`}
onClick={e => (e.target as HTMLInputElement).value = ''}
type='file'
accept='.png, .jpg, .jpeg, .webp, .gif'
onChange={handleChange}
disabled={disabled}
/>
</div>
)
}
export default Uploader
'use client'
import { upload } from '@/service/base'
type ImageUploadParams = {
file: File
onProgressCallback: (progress: number) => void
onSuccessCallback: (res: { id: string }) => void
onErrorCallback: () => void
}
type ImageUpload = (v: ImageUploadParams) => void
export const imageUpload: ImageUpload = ({
file,
onProgressCallback,
onSuccessCallback,
onErrorCallback,
}) => {
const formData = new FormData()
formData.append('file', file)
const onProgress = (e: ProgressEvent) => {
if (e.lengthComputable) {
const percent = Math.floor(e.loaded / e.total * 100)
onProgressCallback(percent)
}
}
upload({
xhr: new XMLHttpRequest(),
data: formData,
onprogress: onProgress,
})
.then((res: { id: string }) => {
onSuccessCallback(res)
})
.catch(() => {
onErrorCallback()
})
}
'use client'
import React from 'react'
import {
FloatingPortal,
autoUpdate,
flip,
offset,
shift,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useMergeRefs,
useRole,
} from '@floating-ui/react'
import type { OffsetOptions, Placement } from '@floating-ui/react'
type PortalToFollowElemOptions = {
/*
* top, bottom, left, right
* start, end. Default is middle
* combine: top-start, top-end
*/
placement?: Placement
open?: boolean
offset?: number | OffsetOptions
onOpenChange?: (open: boolean) => void
}
export function usePortalToFollowElem({
placement = 'bottom',
open,
offset: offsetValue = 0,
onOpenChange: setControlledOpen,
}: PortalToFollowElemOptions = {}) {
const setOpen = setControlledOpen
const data = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [
offset(offsetValue),
flip({
crossAxis: placement.includes('-'),
fallbackAxisSideDirection: 'start',
padding: 5,
}),
shift({ padding: 5 }),
],
})
const context = data.context
const hover = useHover(context, {
move: false,
enabled: open == null,
})
const focus = useFocus(context, {
enabled: open == null,
})
const dismiss = useDismiss(context)
const role = useRole(context, { role: 'tooltip' })
const interactions = useInteractions([hover, focus, dismiss, role])
return React.useMemo(
() => ({
open,
setOpen,
...interactions,
...data,
}),
[open, setOpen, interactions, data],
)
}
type ContextType = ReturnType<typeof usePortalToFollowElem> | null
const PortalToFollowElemContext = React.createContext<ContextType>(null)
export function usePortalToFollowElemContext() {
const context = React.useContext(PortalToFollowElemContext)
if (context == null)
throw new Error('PortalToFollowElem components must be wrapped in <PortalToFollowElem />')
return context
}
export function PortalToFollowElem({
children,
...options
}: { children: React.ReactNode } & PortalToFollowElemOptions) {
// This can accept any props as options, e.g. `placement`,
// or other positioning options.
const tooltip = usePortalToFollowElem(options)
return (
<PortalToFollowElemContext.Provider value={tooltip}>
{children}
</PortalToFollowElemContext.Provider>
)
}
export const PortalToFollowElemTrigger = React.forwardRef<
HTMLElement,
React.HTMLProps<HTMLElement> & { asChild?: boolean }
>(({ children, asChild = false, ...props }, propRef) => {
const context = usePortalToFollowElemContext()
const childrenRef = (children as any).ref
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
// `asChild` allows the user to pass any element as the anchor
if (asChild && React.isValidElement(children)) {
return React.cloneElement(
children,
context.getReferenceProps({
ref,
...props,
...children.props,
'data-state': context.open ? 'open' : 'closed',
}),
)
}
return (
<div
ref={ref}
className='inline-block'
// The user can style the trigger based on the state
data-state={context.open ? 'open' : 'closed'}
{...context.getReferenceProps(props)}
>
{children}
</div>
)
})
PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
export const PortalToFollowElemContent = React.forwardRef<
HTMLDivElement,
React.HTMLProps<HTMLDivElement>
>(({ style, ...props }, propRef) => {
const context = usePortalToFollowElemContext()
const ref = useMergeRefs([context.refs.setFloating, propRef])
if (!context.open)
return null
return (
<FloatingPortal>
<div
ref={ref}
style={{
...context.floatingStyles,
...style,
}}
{...context.getFloatingProps(props)}
/>
</FloatingPortal>
)
})
PortalToFollowElemContent.displayName = 'PortalToFollowElemContent'
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
InformationCircleIcon, InformationCircleIcon,
XCircleIcon, XCircleIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import { createContext } from 'use-context-selector' import { createContext, useContext } from 'use-context-selector'
export type IToastProps = { export type IToastProps = {
type?: 'success' | 'error' | 'warning' | 'info' type?: 'success' | 'error' | 'warning' | 'info'
...@@ -24,6 +24,8 @@ type IToastContext = { ...@@ -24,6 +24,8 @@ type IToastContext = {
const defaultDuring = 3000 const defaultDuring = 3000
export const ToastContext = createContext<IToastContext>({} as IToastContext) export const ToastContext = createContext<IToastContext>({} as IToastContext)
export const useToastContext = () => useContext(ToastContext)
const Toast = ({ const Toast = ({
type = 'info', type = 'info',
duration, duration,
......
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
export type TooltipProps = {
position?: 'top' | 'right' | 'bottom' | 'left'
triggerMethod?: 'hover' | 'click'
popupContent: React.ReactNode
children: React.ReactNode
}
const arrow = (
<svg className="absolute text-white h-2 w-full left-0 top-full" x="0px" y="0px" viewBox="0 0 255 255"><polygon className="fill-current" points="0,0 127.5,127.5 255,0"></polygon></svg>
)
const Tooltip: FC< TooltipProps> = ({
position = 'top',
triggerMethod = 'hover',
popupContent,
children,
}) => {
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement={position}
offset={10}
>
<PortalToFollowElemTrigger
onClick={() => triggerMethod === 'click' && setOpen(v => !v)}
onMouseEnter={() => triggerMethod === 'hover' && setOpen(true)}
onMouseLeave={() => triggerMethod === 'hover' && setOpen(false)}
>
{children}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent
className="z-[999]"
>
<div className='relative px-3 py-2 text-xs font-normal text-gray-700 bg-white rounded-md shadow-lg'>
{popupContent}
{arrow}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(Tooltip)
...@@ -4,14 +4,19 @@ import React, { useEffect, useRef } from 'react' ...@@ -4,14 +4,19 @@ import React, { useEffect, useRef } from 'react'
import cn from 'classnames' import cn from 'classnames'
import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline' import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Textarea from 'rc-textarea'
import s from './style.module.css' import s from './style.module.css'
import LoadingAnim from './loading-anim' import LoadingAnim from './loading-anim'
import { randomString } from '@/utils/string' import { randomString } from '@/utils/string'
import type { Feedbacktype, MessageRating } from '@/types/app' import type { Feedbacktype, MessageRating, VisionFile, VisionSettings } from '@/types/app'
import { TransferMethod } from '@/types/app'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
import { Markdown } from '@/app/components/base/markdown' import { Markdown } from '@/app/components/base/markdown'
import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
import ImageList from '@/app/components/base/image-uploader/image-list'
import { useImageFiles } from '@/app/components/base/image-uploader/hooks'
import ImageGallery from '@/app/components/base/image-gallery'
export type FeedbackFunc = (messageId: string, feedback: Feedbacktype) => Promise<any> export type FeedbackFunc = (messageId: string, feedback: Feedbacktype) => Promise<any>
...@@ -27,11 +32,11 @@ export type IChatProps = { ...@@ -27,11 +32,11 @@ export type IChatProps = {
isHideSendInput?: boolean isHideSendInput?: boolean
onFeedback?: FeedbackFunc onFeedback?: FeedbackFunc
checkCanSend?: () => boolean checkCanSend?: () => boolean
onSend?: (message: string) => void onSend?: (message: string, files: VisionFile[]) => void
useCurrentUserAvatar?: boolean useCurrentUserAvatar?: boolean
isResponsing?: boolean isResponsing?: boolean
controlClearQuery?: number controlClearQuery?: number
controlFocus?: number visionConfig?: VisionSettings
} }
export type IChatItem = { export type IChatItem = {
...@@ -52,6 +57,7 @@ export type IChatItem = { ...@@ -52,6 +57,7 @@ export type IChatItem = {
isIntroduction?: boolean isIntroduction?: boolean
useCurrentUserAvatar?: boolean useCurrentUserAvatar?: boolean
isOpeningStatement?: boolean isOpeningStatement?: boolean
message_files?: VisionFile[]
} }
const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => ( const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => (
...@@ -205,9 +211,11 @@ const Answer: FC<IAnswerProps> = ({ item, feedbackDisabled = false, onFeedback, ...@@ -205,9 +211,11 @@ const Answer: FC<IAnswerProps> = ({ item, feedbackDisabled = false, onFeedback,
) )
} }
type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'useCurrentUserAvatar'> type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'useCurrentUserAvatar'> & {
imgSrcs?: string[]
}
const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar }) => { const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar, imgSrcs }) => {
const userName = '' const userName = ''
return ( return (
<div className='flex items-start justify-end' key={id}> <div className='flex items-start justify-end' key={id}>
...@@ -216,6 +224,9 @@ const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar }) => ...@@ -216,6 +224,9 @@ const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar }) =>
<div <div
className={'mr-2 py-3 px-4 bg-blue-500 rounded-tl-2xl rounded-b-2xl'} className={'mr-2 py-3 px-4 bg-blue-500 rounded-tl-2xl rounded-b-2xl'}
> >
{imgSrcs && imgSrcs.length > 0 && (
<ImageGallery srcs={imgSrcs} />
)}
<Markdown content={content} /> <Markdown content={content} />
</div> </div>
</div> </div>
...@@ -243,7 +254,7 @@ const Chat: FC<IChatProps> = ({ ...@@ -243,7 +254,7 @@ const Chat: FC<IChatProps> = ({
useCurrentUserAvatar, useCurrentUserAvatar,
isResponsing, isResponsing,
controlClearQuery, controlClearQuery,
controlFocus, visionConfig,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { notify } = Toast const { notify } = Toast
...@@ -271,13 +282,31 @@ const Chat: FC<IChatProps> = ({ ...@@ -271,13 +282,31 @@ const Chat: FC<IChatProps> = ({
if (controlClearQuery) if (controlClearQuery)
setQuery('') setQuery('')
}, [controlClearQuery]) }, [controlClearQuery])
const {
files,
onUpload,
onRemove,
onReUpload,
onImageLinkLoadError,
onImageLinkLoadSuccess,
onClear,
} = useImageFiles()
const handleSend = () => { const handleSend = () => {
if (!valid() || (checkCanSend && !checkCanSend())) if (!valid() || (checkCanSend && !checkCanSend()))
return return
onSend(query) onSend(query, files.filter(file => file.progress !== -1).map(fileItem => ({
if (!isResponsing) type: 'image',
setQuery('') transfer_method: fileItem.type,
url: fileItem.url,
upload_file_id: fileItem.fileId,
})))
if (!files.find(item => item.type === TransferMethod.local_file && !item.fileId)) {
if (files.length)
onClear()
if (!isResponsing)
setQuery('')
}
} }
const handleKeyUp = (e: any) => { const handleKeyUp = (e: any) => {
...@@ -289,7 +318,7 @@ const Chat: FC<IChatProps> = ({ ...@@ -289,7 +318,7 @@ const Chat: FC<IChatProps> = ({
} }
} }
const haneleKeyDown = (e: any) => { const handleKeyDown = (e: any) => {
isUseInputMethod.current = e.nativeEvent.isComposing isUseInputMethod.current = e.nativeEvent.isComposing
if (e.code === 'Enter' && !e.shiftKey) { if (e.code === 'Enter' && !e.shiftKey) {
setQuery(query.replace(/\n$/, '')) setQuery(query.replace(/\n$/, ''))
...@@ -312,24 +341,56 @@ const Chat: FC<IChatProps> = ({ ...@@ -312,24 +341,56 @@ const Chat: FC<IChatProps> = ({
isResponsing={isResponsing && isLast} isResponsing={isResponsing && isLast}
/> />
} }
return <Question key={item.id} id={item.id} content={item.content} useCurrentUserAvatar={useCurrentUserAvatar} /> return (
<Question
key={item.id}
id={item.id}
content={item.content}
useCurrentUserAvatar={useCurrentUserAvatar}
imgSrcs={(item.message_files && item.message_files?.length > 0) ? item.message_files.map(item => item.url) : []}
/>
)
})} })}
</div> </div>
{ {
!isHideSendInput && ( !isHideSendInput && (
<div className={cn(!feedbackDisabled && '!left-3.5 !right-3.5', 'absolute z-10 bottom-0 left-0 right-0')}> <div className={cn(!feedbackDisabled && '!left-3.5 !right-3.5', 'absolute z-10 bottom-0 left-0 right-0')}>
<div className="positive"> <div className='p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto'>
<AutoHeightTextarea {
visionConfig?.enabled && (
<>
<div className='absolute bottom-2 left-2 flex items-center'>
<ChatImageUploader
settings={visionConfig}
onUpload={onUpload}
disabled={files.length >= visionConfig.number_limits}
/>
<div className='mx-1 w-[1px] h-4 bg-black/5' />
</div>
<div className='pl-[52px]'>
<ImageList
list={files}
onRemove={onRemove}
onReUpload={onReUpload}
onImageLinkLoadSuccess={onImageLinkLoadSuccess}
onImageLinkLoadError={onImageLinkLoadError}
/>
</div>
</>
)
}
<Textarea
className={`
block w-full px-2 pr-[118px] py-[7px] leading-5 max-h-none text-sm text-gray-700 outline-none appearance-none resize-none
${visionConfig?.enabled && 'pl-12'}
`}
value={query} value={query}
onChange={handleContentChange} onChange={handleContentChange}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onKeyDown={haneleKeyDown} onKeyDown={handleKeyDown}
minHeight={48} autoSize
autoFocus
controlFocus={controlFocus}
className={`${cn(s.textArea)} resize-none block w-full pl-3 bg-gray-50 border border-gray-200 rounded-md focus:outline-none sm:text-sm text-gray-700`}
/> />
<div className="absolute top-0 right-2 flex items-center h-[48px]"> <div className="absolute bottom-2 right-2 flex items-center h-8">
<div className={`${s.count} mr-4 h-5 leading-5 text-sm bg-gray-50 text-gray-500`}>{query.trim().length}</div> <div className={`${s.count} mr-4 h-5 leading-5 text-sm bg-gray-50 text-gray-500`}>{query.trim().length}</div>
<Tooltip <Tooltip
selector='send-tip' selector='send-tip'
......
...@@ -11,7 +11,8 @@ import Sidebar from '@/app/components/sidebar' ...@@ -11,7 +11,8 @@ import Sidebar from '@/app/components/sidebar'
import ConfigSence from '@/app/components/config-scence' import ConfigSence from '@/app/components/config-scence'
import Header from '@/app/components/header' import Header from '@/app/components/header'
import { fetchAppParams, fetchChatList, fetchConversations, sendChatMessage, updateFeedback } from '@/service' import { fetchAppParams, fetchChatList, fetchConversations, sendChatMessage, updateFeedback } from '@/service'
import type { ConversationItem, Feedbacktype, IChatItem, PromptConfig } from '@/types/app' import type { ConversationItem, Feedbacktype, IChatItem, PromptConfig, VisionFile, VisionSettings } from '@/types/app'
import { TransferMethod } from '@/types/app'
import Chat from '@/app/components/chat' import Chat from '@/app/components/chat'
import { setLocaleOnClient } from '@/i18n/client' import { setLocaleOnClient } from '@/i18n/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
...@@ -35,6 +36,7 @@ const Main: FC = () => { ...@@ -35,6 +36,7 @@ const Main: FC = () => {
const [inited, setInited] = useState<boolean>(false) const [inited, setInited] = useState<boolean>(false)
// in mobile, show sidebar by click button // in mobile, show sidebar by click button
const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false) const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false)
const [visionConfig, setVisionConfig] = useState<VisionSettings | undefined>(undefined)
useEffect(() => { useEffect(() => {
if (APP_INFO?.title) if (APP_INFO?.title)
...@@ -113,6 +115,7 @@ const Main: FC = () => { ...@@ -113,6 +115,7 @@ const Main: FC = () => {
id: `question-${item.id}`, id: `question-${item.id}`,
content: item.query, content: item.query,
isAnswer: false, isAnswer: false,
message_files: item.message_files,
}) })
newChatList.push({ newChatList.push({
id: item.id, id: item.id,
...@@ -127,8 +130,6 @@ const Main: FC = () => { ...@@ -127,8 +130,6 @@ const Main: FC = () => {
if (isNewConversation && isChatStarted) if (isNewConversation && isChatStarted)
setChatList(generateNewChatListWithOpenstatement()) setChatList(generateNewChatListWithOpenstatement())
setControlFocus(Date.now())
} }
useEffect(handleConversationSwitch, [currConversationId, inited]) useEffect(handleConversationSwitch, [currConversationId, inited])
...@@ -208,7 +209,7 @@ const Main: FC = () => { ...@@ -208,7 +209,7 @@ const Main: FC = () => {
const isNotNewConversation = conversations.some(item => item.id === _conversationId) const isNotNewConversation = conversations.some(item => item.id === _conversationId)
// fetch new conversation info // fetch new conversation info
const { user_input_form, opening_statement: introduction }: any = appParams const { user_input_form, opening_statement: introduction, file_upload, system_parameters }: any = appParams
setLocaleOnClient(APP_INFO.default_language, true) setLocaleOnClient(APP_INFO.default_language, true)
setNewConversationInfo({ setNewConversationInfo({
name: t('app.chat.newChatDefaultName'), name: t('app.chat.newChatDefaultName'),
...@@ -219,7 +220,10 @@ const Main: FC = () => { ...@@ -219,7 +220,10 @@ const Main: FC = () => {
prompt_template: promptTemplate, prompt_template: promptTemplate,
prompt_variables, prompt_variables,
} as PromptConfig) } as PromptConfig)
setVisionConfig({
...file_upload?.image,
image_file_size_limit: system_parameters?.system_parameters || 0,
})
setConversationList(conversations as ConversationItem[]) setConversationList(conversations as ConversationItem[])
if (isNotNewConversation) if (isNotNewConversation)
...@@ -263,24 +267,36 @@ const Main: FC = () => { ...@@ -263,24 +267,36 @@ const Main: FC = () => {
return true return true
} }
const [controlFocus, setControlFocus] = useState(0) const handleSend = async (message: string, files?: VisionFile[]) => {
const handleSend = async (message: string) => {
if (isResponsing) { if (isResponsing) {
notify({ type: 'info', message: t('app.errorMessage.waitForResponse') }) notify({ type: 'info', message: t('app.errorMessage.waitForResponse') })
return return
} }
const data = { const data: Record<string, any> = {
inputs: currInputs, inputs: currInputs,
query: message, query: message,
conversation_id: isNewConversation ? null : currConversationId, conversation_id: isNewConversation ? null : currConversationId,
} }
if (visionConfig?.enabled && files && files?.length > 0) {
data.files = files.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
// qustion // qustion
const questionId = `question-${Date.now()}` const questionId = `question-${Date.now()}`
const questionItem = { const questionItem = {
id: questionId, id: questionId,
content: message, content: message,
isAnswer: false, isAnswer: false,
message_files: files,
} }
const placeholderAnswerId = `answer-placeholder-${Date.now()}` const placeholderAnswerId = `answer-placeholder-${Date.now()}`
...@@ -423,7 +439,7 @@ const Main: FC = () => { ...@@ -423,7 +439,7 @@ const Main: FC = () => {
onFeedback={handleFeedback} onFeedback={handleFeedback}
isResponsing={isResponsing} isResponsing={isResponsing}
checkCanSend={checkCanSend} checkCanSend={checkCanSend}
controlFocus={controlFocus} visionConfig={visionConfig}
/> />
</div> </div>
</div>) </div>)
......
...@@ -16,6 +16,17 @@ const translation = { ...@@ -16,6 +16,17 @@ const translation = {
lineBreak: 'Line break', lineBreak: 'Line break',
like: 'like', like: 'like',
dislike: 'dislike', dislike: 'dislike',
ok: 'OK',
},
imageUploader: {
uploadFromComputer: 'Upload from Computer',
uploadFromComputerReadError: 'Image reading failed, please try again.',
uploadFromComputerUploadError: 'Image upload failed, please upload again.',
uploadFromComputerLimit: 'Upload images cannot exceed {{size}} MB',
pasteImageLink: 'Paste image link',
pasteImageLinkInputPlaceholder: 'Paste image link here',
pasteImageLinkInvalid: 'Invalid image link',
imageUpload: 'Image Upload',
}, },
} }
......
...@@ -16,6 +16,17 @@ const translation = { ...@@ -16,6 +16,17 @@ const translation = {
lineBreak: '换行', lineBreak: '换行',
like: '赞同', like: '赞同',
dislike: '反对', dislike: '反对',
ok: '好的',
},
imageUploader: {
uploadFromComputer: '从本地上传',
uploadFromComputerReadError: '图片读取失败,请重新选择。',
uploadFromComputerUploadError: '图片上传失败,请重新上传。',
uploadFromComputerLimit: '上传图片不能超过 {{size}} MB',
pasteImageLink: '粘贴图片链接',
pasteImageLinkInputPlaceholder: '将图像链接粘贴到此处',
pasteImageLinkInvalid: '图片链接无效',
imageUpload: '图片上传',
}, },
} }
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
"prepare": "husky install ./.husky" "prepare": "husky install ./.husky"
}, },
"dependencies": { "dependencies": {
"@floating-ui/react": "^0.26.2",
"@formatjs/intl-localematcher": "^0.2.32", "@formatjs/intl-localematcher": "^0.2.32",
"@headlessui/react": "^1.7.13", "@headlessui/react": "^1.7.13",
"@heroicons/react": "^2.0.16", "@heroicons/react": "^2.0.16",
...@@ -26,7 +27,7 @@ ...@@ -26,7 +27,7 @@
"axios": "^1.3.5", "axios": "^1.3.5",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"dify-client": "2.0.0", "dify-client": "^2.1.0",
"eslint": "8.36.0", "eslint": "8.36.0",
"eslint-config-next": "13.4.0", "eslint-config-next": "13.4.0",
"eventsource-parser": "^1.0.0", "eventsource-parser": "^1.0.0",
...@@ -38,6 +39,7 @@ ...@@ -38,6 +39,7 @@
"katex": "^0.16.7", "katex": "^0.16.7",
"negotiator": "^0.6.3", "negotiator": "^0.6.3",
"next": "13.4.0", "next": "13.4.0",
"rc-textarea": "^1.5.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-error-boundary": "^4.0.2", "react-error-boundary": "^4.0.2",
......
...@@ -66,7 +66,8 @@ const handleStream = (response: any, onData: IOnData, onCompleted?: IOnCompleted ...@@ -66,7 +66,8 @@ const handleStream = (response: any, onData: IOnData, onCompleted?: IOnCompleted
return return
try { try {
bufferObj = JSON.parse(message.substring(6)) // remove data: and parse as json bufferObj = JSON.parse(message.substring(6)) // remove data: and parse as json
} catch (e) { }
catch (e) {
// mute handle message cut off // mute handle message cut off
onData('', isFirstMessage, { onData('', isFirstMessage, {
conversationId: bufferObj?.conversation_id, conversationId: bufferObj?.conversation_id,
...@@ -181,6 +182,38 @@ const baseFetch = (url: string, fetchOptions: any, { needAllResponseContent }: I ...@@ -181,6 +182,38 @@ const baseFetch = (url: string, fetchOptions: any, { needAllResponseContent }: I
]) ])
} }
export const upload = (fetchOptions: any): Promise<any> => {
const urlPrefix = API_PREFIX
const urlWithPrefix = `${urlPrefix}/file-upload`
const defaultOptions = {
method: 'POST',
url: `${urlWithPrefix}`,
data: {},
}
const options = {
...defaultOptions,
...fetchOptions,
}
return new Promise((resolve, reject) => {
const xhr = options.xhr
xhr.open(options.method, options.url)
for (const key in options.headers)
xhr.setRequestHeader(key, options.headers[key])
xhr.withCredentials = true
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200)
resolve({ id: xhr.response })
else
reject(xhr)
}
}
xhr.upload.onprogress = options.onprogress
xhr.send(options.data)
})
}
export const ssePost = (url: string, fetchOptions: any, { onData, onCompleted, onError }: IOtherOptions) => { export const ssePost = (url: string, fetchOptions: any, { onData, onCompleted, onError }: IOtherOptions) => {
const options = Object.assign({}, baseOptions, { const options = Object.assign({}, baseOptions, {
method: 'POST', method: 'POST',
......
...@@ -77,6 +77,7 @@ export type IChatItem = { ...@@ -77,6 +77,7 @@ export type IChatItem = {
isIntroduction?: boolean isIntroduction?: boolean
useCurrentUserAvatar?: boolean useCurrentUserAvatar?: boolean
isOpeningStatement?: boolean isOpeningStatement?: boolean
message_files?: VisionFile[]
} }
export type ResponseHolder = {} export type ResponseHolder = {}
...@@ -95,3 +96,41 @@ export type AppInfo = { ...@@ -95,3 +96,41 @@ export type AppInfo = {
copyright?: string copyright?: string
privacy_policy?: string privacy_policy?: string
} }
export enum Resolution {
low = 'low',
high = 'high',
}
export enum TransferMethod {
all = 'all',
local_file = 'local_file',
remote_url = 'remote_url',
}
export type VisionSettings = {
enabled: boolean
number_limits: number
detail: Resolution
transfer_methods: TransferMethod[]
image_file_size_limit?: number | string
}
export type ImageFile = {
type: TransferMethod
_id: string
fileId: string
file?: File
progress: number
url: string
base64Url?: string
deleted?: boolean
}
export type VisionFile = {
id?: string
type: string
transfer_method: TransferMethod
url: string
upload_file_id: string
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment