From 5dc3658e19d911215fd587457cecd8b4a0f3af22 Mon Sep 17 00:00:00 2001 From: StyleZhang Date: Wed, 22 Nov 2023 11:08:09 +0800 Subject: [PATCH] feat: support upload image --- app/api/chat-messages/route.ts | 3 +- app/api/file-upload/route.ts | 15 ++ app/components/base/icons/IconBase.tsx | 31 ++++ .../base/icons/line/image-plus/data.json | 39 ++++ .../base/icons/line/image-plus/index.tsx | 13 ++ .../base/icons/line/link-03/data.json | 57 ++++++ .../base/icons/line/link-03/index.tsx | 13 ++ .../base/icons/line/loading-02/data.json | 64 +++++++ .../base/icons/line/loading-02/index.tsx | 13 ++ .../base/icons/line/refresh-ccw-01/data.json | 29 +++ .../base/icons/line/refresh-ccw-01/index.tsx | 13 ++ .../base/icons/line/upload-03/data.json | 66 +++++++ .../base/icons/line/upload-03/index.tsx | 13 ++ .../base/icons/line/x-close/data.json | 39 ++++ .../base/icons/line/x-close/index.tsx | 13 ++ .../base/icons/solid/alert-triangle/data.json | 38 ++++ .../base/icons/solid/alert-triangle/index.tsx | 13 ++ app/components/base/icons/utils.tsx | 66 +++++++ app/components/base/image-gallery/index.tsx | 83 +++++++++ .../base/image-gallery/style.module.css | 22 +++ .../image-uploader/chat-image-uploader.tsx | 150 ++++++++++++++++ app/components/base/image-uploader/hooks.ts | 108 +++++++++++ .../base/image-uploader/image-link-input.tsx | 50 ++++++ .../base/image-uploader/image-list.tsx | 130 ++++++++++++++ .../base/image-uploader/image-preview.tsx | 31 ++++ .../base/image-uploader/uploader.tsx | 101 +++++++++++ app/components/base/image-uploader/utils.ts | 38 ++++ .../base/portal-to-follow-elem/index.tsx | 167 ++++++++++++++++++ app/components/base/toast/index.tsx | 4 +- app/components/base/tooltip-plus/index.tsx | 50 ++++++ app/components/chat/index.tsx | 101 ++++++++--- app/components/index.tsx | 34 +++- i18n/lang/common.en.ts | 11 ++ i18n/lang/common.zh.ts | 11 ++ package.json | 4 +- service/base.ts | 35 +++- types/app.ts | 39 ++++ 37 files changed, 1674 insertions(+), 33 deletions(-) create mode 100644 app/api/file-upload/route.ts create mode 100644 app/components/base/icons/IconBase.tsx create mode 100644 app/components/base/icons/line/image-plus/data.json create mode 100644 app/components/base/icons/line/image-plus/index.tsx create mode 100644 app/components/base/icons/line/link-03/data.json create mode 100644 app/components/base/icons/line/link-03/index.tsx create mode 100644 app/components/base/icons/line/loading-02/data.json create mode 100644 app/components/base/icons/line/loading-02/index.tsx create mode 100644 app/components/base/icons/line/refresh-ccw-01/data.json create mode 100644 app/components/base/icons/line/refresh-ccw-01/index.tsx create mode 100644 app/components/base/icons/line/upload-03/data.json create mode 100644 app/components/base/icons/line/upload-03/index.tsx create mode 100644 app/components/base/icons/line/x-close/data.json create mode 100644 app/components/base/icons/line/x-close/index.tsx create mode 100644 app/components/base/icons/solid/alert-triangle/data.json create mode 100644 app/components/base/icons/solid/alert-triangle/index.tsx create mode 100644 app/components/base/icons/utils.tsx create mode 100644 app/components/base/image-gallery/index.tsx create mode 100644 app/components/base/image-gallery/style.module.css create mode 100644 app/components/base/image-uploader/chat-image-uploader.tsx create mode 100644 app/components/base/image-uploader/hooks.ts create mode 100644 app/components/base/image-uploader/image-link-input.tsx create mode 100644 app/components/base/image-uploader/image-list.tsx create mode 100644 app/components/base/image-uploader/image-preview.tsx create mode 100644 app/components/base/image-uploader/uploader.tsx create mode 100644 app/components/base/image-uploader/utils.ts create mode 100644 app/components/base/portal-to-follow-elem/index.tsx create mode 100644 app/components/base/tooltip-plus/index.tsx diff --git a/app/api/chat-messages/route.ts b/app/api/chat-messages/route.ts index 378f7dd..849be1f 100644 --- a/app/api/chat-messages/route.ts +++ b/app/api/chat-messages/route.ts @@ -6,10 +6,11 @@ export async function POST(request: NextRequest) { const { inputs, query, + files, conversation_id: conversationId, response_mode: responseMode, } = body 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) } diff --git a/app/api/file-upload/route.ts b/app/api/file-upload/route.ts new file mode 100644 index 0000000..fa09906 --- /dev/null +++ b/app/api/file-upload/route.ts @@ -0,0 +1,15 @@ +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) + } +} diff --git a/app/components/base/icons/IconBase.tsx b/app/components/base/icons/IconBase.tsx new file mode 100644 index 0000000..994cd98 --- /dev/null +++ b/app/components/base/icons/IconBase.tsx @@ -0,0 +1,31 @@ +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 + style?: React.CSSProperties +} + +const IconBase = forwardRef, 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 diff --git a/app/components/base/icons/line/image-plus/data.json b/app/components/base/icons/line/image-plus/data.json new file mode 100644 index 0000000..cd265bd --- /dev/null +++ b/app/components/base/icons/line/image-plus/data.json @@ -0,0 +1,39 @@ +{ + "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 diff --git a/app/components/base/icons/line/image-plus/index.tsx b/app/components/base/icons/line/image-plus/index.tsx new file mode 100644 index 0000000..6277a85 --- /dev/null +++ b/app/components/base/icons/line/image-plus/index.tsx @@ -0,0 +1,13 @@ +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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'ImagePlus' + +export default Icon diff --git a/app/components/base/icons/line/link-03/data.json b/app/components/base/icons/line/link-03/data.json new file mode 100644 index 0000000..5723e9e --- /dev/null +++ b/app/components/base/icons/line/link-03/data.json @@ -0,0 +1,57 @@ +{ + "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 diff --git a/app/components/base/icons/line/link-03/index.tsx b/app/components/base/icons/line/link-03/index.tsx new file mode 100644 index 0000000..d03bd4a --- /dev/null +++ b/app/components/base/icons/line/link-03/index.tsx @@ -0,0 +1,13 @@ +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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Link03' + +export default Icon diff --git a/app/components/base/icons/line/loading-02/data.json b/app/components/base/icons/line/loading-02/data.json new file mode 100644 index 0000000..20d0b3c --- /dev/null +++ b/app/components/base/icons/line/loading-02/data.json @@ -0,0 +1,64 @@ +{ + "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 diff --git a/app/components/base/icons/line/loading-02/index.tsx b/app/components/base/icons/line/loading-02/index.tsx new file mode 100644 index 0000000..123e0e3 --- /dev/null +++ b/app/components/base/icons/line/loading-02/index.tsx @@ -0,0 +1,13 @@ +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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Loading02' + +export default Icon diff --git a/app/components/base/icons/line/refresh-ccw-01/data.json b/app/components/base/icons/line/refresh-ccw-01/data.json new file mode 100644 index 0000000..c3b6858 --- /dev/null +++ b/app/components/base/icons/line/refresh-ccw-01/data.json @@ -0,0 +1,29 @@ +{ + "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 diff --git a/app/components/base/icons/line/refresh-ccw-01/index.tsx b/app/components/base/icons/line/refresh-ccw-01/index.tsx new file mode 100644 index 0000000..df74ab3 --- /dev/null +++ b/app/components/base/icons/line/refresh-ccw-01/index.tsx @@ -0,0 +1,13 @@ +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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'RefreshCcw01' + +export default Icon diff --git a/app/components/base/icons/line/upload-03/data.json b/app/components/base/icons/line/upload-03/data.json new file mode 100644 index 0000000..a2cfb6e --- /dev/null +++ b/app/components/base/icons/line/upload-03/data.json @@ -0,0 +1,66 @@ +{ + "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 diff --git a/app/components/base/icons/line/upload-03/index.tsx b/app/components/base/icons/line/upload-03/index.tsx new file mode 100644 index 0000000..080bc04 --- /dev/null +++ b/app/components/base/icons/line/upload-03/index.tsx @@ -0,0 +1,13 @@ +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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Upload03' + +export default Icon diff --git a/app/components/base/icons/line/x-close/data.json b/app/components/base/icons/line/x-close/data.json new file mode 100644 index 0000000..a2565ec --- /dev/null +++ b/app/components/base/icons/line/x-close/data.json @@ -0,0 +1,39 @@ +{ + "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 diff --git a/app/components/base/icons/line/x-close/index.tsx b/app/components/base/icons/line/x-close/index.tsx new file mode 100644 index 0000000..83d0f1c --- /dev/null +++ b/app/components/base/icons/line/x-close/index.tsx @@ -0,0 +1,13 @@ +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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'XClose' + +export default Icon diff --git a/app/components/base/icons/solid/alert-triangle/data.json b/app/components/base/icons/solid/alert-triangle/data.json new file mode 100644 index 0000000..ea5f80a --- /dev/null +++ b/app/components/base/icons/solid/alert-triangle/data.json @@ -0,0 +1,38 @@ +{ + "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 diff --git a/app/components/base/icons/solid/alert-triangle/index.tsx b/app/components/base/icons/solid/alert-triangle/index.tsx new file mode 100644 index 0000000..6455c1c --- /dev/null +++ b/app/components/base/icons/solid/alert-triangle/index.tsx @@ -0,0 +1,13 @@ +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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'AlertTriangle' + +export default Icon diff --git a/app/components/base/icons/utils.tsx b/app/components/base/icons/utils.tsx new file mode 100644 index 0000000..90d075f --- /dev/null +++ b/app/components/base/icons/utils.tsx @@ -0,0 +1,66 @@ +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}`)), + ) +} diff --git a/app/components/base/image-gallery/index.tsx b/app/components/base/image-gallery/index.tsx new file mode 100644 index 0000000..bd46c34 --- /dev/null +++ b/app/components/base/image-gallery/index.tsx @@ -0,0 +1,83 @@ +'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 = ({ + srcs, +}) => { + const [imagePreviewUrl, setImagePreviewUrl] = useState('') + + const imgNum = srcs.length + const imgStyle = getWidthStyle(imgNum) + return ( +
+ {/* TODO: support preview */} + {srcs.map((src, index) => ( + setImagePreviewUrl(src)} + /> + ))} + { + imagePreviewUrl && ( + setImagePreviewUrl('')} + /> + ) + } +
+ ) +} + +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 ( +
+ {imgGallerySrcs.map((_, index) => ( +
+ +
+ ))} +
+ ) +} diff --git a/app/components/base/image-gallery/style.module.css b/app/components/base/image-gallery/style.module.css new file mode 100644 index 0000000..116d602 --- /dev/null +++ b/app/components/base/image-gallery/style.module.css @@ -0,0 +1,22 @@ +.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 diff --git a/app/components/base/image-uploader/chat-image-uploader.tsx b/app/components/base/image-uploader/chat-image-uploader.tsx new file mode 100644 index 0000000..863b79d --- /dev/null +++ b/app/components/base/image-uploader/chat-image-uploader.tsx @@ -0,0 +1,150 @@ +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 = ({ + onUpload, + disabled, + limit, +}) => { + return ( + + { + hovering => ( +
+ +
+ ) + } +
+ ) +} + +type UploaderButtonProps = { + methods: VisionSettings['transfer_methods'] + onUpload: (imageFile: ImageFile) => void + disabled?: boolean + limit?: number +} +const UploaderButton: FC = ({ + 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 ( + + +
+ +
+
+ +
+ + { + hasUploadFromLocal && ( + <> +
+
+ OR +
+
+ + { + hovering => ( +
+ + {t('common.imageUploader.uploadFromComputer')} +
+ ) + } +
+ + ) + } +
+ + + ) +} + +type ChatImageUploaderProps = { + settings: VisionSettings + onUpload: (imageFile: ImageFile) => void + disabled?: boolean +} +const ChatImageUploader: FC = ({ + settings, + onUpload, + disabled, +}) => { + const onlyUploadLocal = settings.transfer_methods.length === 1 && settings.transfer_methods[0] === TransferMethod.local_file + + if (onlyUploadLocal) { + return ( + + ) + } + + return ( + + ) +} + +export default ChatImageUploader diff --git a/app/components/base/image-uploader/hooks.ts b/app/components/base/image-uploader/hooks.ts new file mode 100644 index 0000000..095a45b --- /dev/null +++ b/app/components/base/image-uploader/hooks.ts @@ -0,0 +1,108 @@ +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([]) + const filesRef = useRef([]) + + 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, + } +} diff --git a/app/components/base/image-uploader/image-link-input.tsx b/app/components/base/image-uploader/image-link-input.tsx new file mode 100644 index 0000000..6c1435d --- /dev/null +++ b/app/components/base/image-uploader/image-link-input.tsx @@ -0,0 +1,50 @@ +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 = ({ + 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 ( +
+ setImageLink(e.target.value)} + placeholder={t('common.imageUploader.pasteImageLinkInputPlaceholder') || ''} + /> + +
+ ) +} + +export default ImageLinkInput diff --git a/app/components/base/image-uploader/image-list.tsx b/app/components/base/image-uploader/image-list.tsx new file mode 100644 index 0000000..2c0f35e --- /dev/null +++ b/app/components/base/image-uploader/image-list.tsx @@ -0,0 +1,130 @@ +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 = ({ + 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 ( +
+ { + list.map(item => ( +
+ { + item.type === TransferMethod.local_file && item.progress !== 100 && ( + <> +
-1 ? `${item.progress}%` : 0 }} + > + { + item.progress === -1 && ( + onReUpload && onReUpload(item._id)} /> + ) + } +
+ { + item.progress > -1 && ( + {item.progress}% + ) + } + + ) + } + { + item.type === TransferMethod.remote_url && item.progress !== 100 && ( +
+ { + item.progress > -1 && ( + + ) + } + { + item.progress === -1 && ( + + + + ) + } +
+ ) + } + 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 && ( +
onRemove && onRemove(item._id)} + > + +
+ ) + } +
+ )) + } + { + imagePreviewUrl && ( + setImagePreviewUrl('')} + /> + ) + } +
+ ) +} + +export default ImageList diff --git a/app/components/base/image-uploader/image-preview.tsx b/app/components/base/image-uploader/image-preview.tsx new file mode 100644 index 0000000..c188ddd --- /dev/null +++ b/app/components/base/image-uploader/image-preview.tsx @@ -0,0 +1,31 @@ +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 = ({ + url, + onCancel, +}) => { + return createPortal( +
e.stopPropagation()}> + preview image +
+ +
+
, + document.body, + ) +} + +export default ImagePreview diff --git a/app/components/base/image-uploader/uploader.tsx b/app/components/base/image-uploader/uploader.tsx new file mode 100644 index 0000000..fe5d829 --- /dev/null +++ b/app/components/base/image-uploader/uploader.tsx @@ -0,0 +1,101 @@ +'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 = ({ + children, + onUpload, + limit, + disabled, +}) => { + const [hovering, setHovering] = useState(false) + const { notify } = Toast + const { t } = useTranslation() + + const handleChange = (e: ChangeEvent) => { + 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 ( +
setHovering(true)} + onMouseLeave={() => setHovering(false)} + > + {children(hovering)} + (e.target as HTMLInputElement).value = ''} + type='file' + accept='.png, .jpg, .jpeg, .webp, .gif' + onChange={handleChange} + disabled={disabled} + /> +
+ ) +} + +export default Uploader diff --git a/app/components/base/image-uploader/utils.ts b/app/components/base/image-uploader/utils.ts new file mode 100644 index 0000000..ff3fa92 --- /dev/null +++ b/app/components/base/image-uploader/utils.ts @@ -0,0 +1,38 @@ +'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() + }) +} diff --git a/app/components/base/portal-to-follow-elem/index.tsx b/app/components/base/portal-to-follow-elem/index.tsx new file mode 100644 index 0000000..c117441 --- /dev/null +++ b/app/components/base/portal-to-follow-elem/index.tsx @@ -0,0 +1,167 @@ +'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 | null + +const PortalToFollowElemContext = React.createContext(null) + +export function usePortalToFollowElemContext() { + const context = React.useContext(PortalToFollowElemContext) + + if (context == null) + throw new Error('PortalToFollowElem components must be wrapped in ') + + 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 ( + + {children} + + ) +} + +export const PortalToFollowElemTrigger = React.forwardRef< +HTMLElement, +React.HTMLProps & { 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 ( +
+ {children} +
+ ) +}) +PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger' + +export const PortalToFollowElemContent = React.forwardRef< +HTMLDivElement, +React.HTMLProps +>(({ style, ...props }, propRef) => { + const context = usePortalToFollowElemContext() + const ref = useMergeRefs([context.refs.setFloating, propRef]) + + if (!context.open) + return null + + return ( + +
+ + ) +}) + +PortalToFollowElemContent.displayName = 'PortalToFollowElemContent' diff --git a/app/components/base/toast/index.tsx b/app/components/base/toast/index.tsx index 328f9b4..8032318 100644 --- a/app/components/base/toast/index.tsx +++ b/app/components/base/toast/index.tsx @@ -9,7 +9,7 @@ import { InformationCircleIcon, XCircleIcon, } from '@heroicons/react/20/solid' -import { createContext } from 'use-context-selector' +import { createContext, useContext } from 'use-context-selector' export type IToastProps = { type?: 'success' | 'error' | 'warning' | 'info' @@ -24,6 +24,8 @@ type IToastContext = { const defaultDuring = 3000 export const ToastContext = createContext({} as IToastContext) +export const useToastContext = () => useContext(ToastContext) + const Toast = ({ type = 'info', duration, diff --git a/app/components/base/tooltip-plus/index.tsx b/app/components/base/tooltip-plus/index.tsx new file mode 100644 index 0000000..6fc7915 --- /dev/null +++ b/app/components/base/tooltip-plus/index.tsx @@ -0,0 +1,50 @@ +'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 = ( + +) + +const Tooltip: FC< TooltipProps> = ({ + position = 'top', + triggerMethod = 'hover', + popupContent, + children, +}) => { + const [open, setOpen] = useState(false) + + return ( + + triggerMethod === 'click' && setOpen(v => !v)} + onMouseEnter={() => triggerMethod === 'hover' && setOpen(true)} + onMouseLeave={() => triggerMethod === 'hover' && setOpen(false)} + > + {children} + + +
+ {popupContent} + {arrow} +
+
+
+ ) +} + +export default React.memo(Tooltip) diff --git a/app/components/chat/index.tsx b/app/components/chat/index.tsx index d4dde44..da57740 100644 --- a/app/components/chat/index.tsx +++ b/app/components/chat/index.tsx @@ -4,14 +4,19 @@ import React, { useEffect, useRef } from 'react' import cn from 'classnames' import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' +import Textarea from 'rc-textarea' import s from './style.module.css' import LoadingAnim from './loading-anim' 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 Toast from '@/app/components/base/toast' -import AutoHeightTextarea from '@/app/components/base/auto-height-textarea' 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 @@ -27,11 +32,11 @@ export type IChatProps = { isHideSendInput?: boolean onFeedback?: FeedbackFunc checkCanSend?: () => boolean - onSend?: (message: string) => void + onSend?: (message: string, files: VisionFile[]) => void useCurrentUserAvatar?: boolean isResponsing?: boolean controlClearQuery?: number - controlFocus?: number + visionConfig?: VisionSettings } export type IChatItem = { @@ -52,6 +57,7 @@ export type IChatItem = { isIntroduction?: boolean useCurrentUserAvatar?: boolean isOpeningStatement?: boolean + message_files?: VisionFile[] } const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => ( @@ -205,9 +211,11 @@ const Answer: FC = ({ item, feedbackDisabled = false, onFeedback, ) } -type IQuestionProps = Pick +type IQuestionProps = Pick & { + imgSrcs?: string[] +} -const Question: FC = ({ id, content, useCurrentUserAvatar }) => { +const Question: FC = ({ id, content, useCurrentUserAvatar, imgSrcs }) => { const userName = '' return (
@@ -216,6 +224,9 @@ const Question: FC = ({ id, content, useCurrentUserAvatar }) =>
+ {imgSrcs && imgSrcs.length > 0 && ( + + )}
@@ -243,7 +254,7 @@ const Chat: FC = ({ useCurrentUserAvatar, isResponsing, controlClearQuery, - controlFocus, + visionConfig, }) => { const { t } = useTranslation() const { notify } = Toast @@ -271,13 +282,31 @@ const Chat: FC = ({ if (controlClearQuery) setQuery('') }, [controlClearQuery]) + const { + files, + onUpload, + onRemove, + onReUpload, + onImageLinkLoadError, + onImageLinkLoadSuccess, + onClear, + } = useImageFiles() const handleSend = () => { if (!valid() || (checkCanSend && !checkCanSend())) return - onSend(query) - if (!isResponsing) - setQuery('') + onSend(query, files.filter(file => file.progress !== -1).map(fileItem => ({ + type: 'image', + 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) => { @@ -289,7 +318,7 @@ const Chat: FC = ({ } } - const haneleKeyDown = (e: any) => { + const handleKeyDown = (e: any) => { isUseInputMethod.current = e.nativeEvent.isComposing if (e.code === 'Enter' && !e.shiftKey) { setQuery(query.replace(/\n$/, '')) @@ -312,24 +341,56 @@ const Chat: FC = ({ isResponsing={isResponsing && isLast} /> } - return + return ( + 0) ? item.message_files.map(item => item.url) : []} + /> + ) })}
{ !isHideSendInput && (
-
- + { + visionConfig?.enabled && ( + <> +
+ = visionConfig.number_limits} + /> +
+
+
+ +
+ + ) + } +