mirror of
https://github.com/langgenius/webapp-conversation.git
synced 2025-12-08 17:32:27 +08:00
Merge branch 'main' into chore/update-nextjs
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
# Conversion Web App Template
|
||||
# Conversation Web App Template
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Config App
|
||||
@ -8,6 +8,8 @@ Create a file named `.env.local` in the current directory and copy the contents
|
||||
NEXT_PUBLIC_APP_ID=
|
||||
# APP API key
|
||||
NEXT_PUBLIC_APP_KEY=
|
||||
# APP URL
|
||||
NEXT_PUBLIC_API_URL=
|
||||
```
|
||||
|
||||
Config more in `config/index.ts` file:
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
19
app/api/conversations/[conversationId]/name/route.ts
Normal file
19
app/api/conversations/[conversationId]/name/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function POST(request: NextRequest, { params }: {
|
||||
params: { conversationId: string }
|
||||
}) {
|
||||
const body = await request.json()
|
||||
const {
|
||||
auto_generate,
|
||||
name,
|
||||
} = body
|
||||
const { conversationId } = params
|
||||
const { user } = getInfo(request)
|
||||
|
||||
// auto generate name
|
||||
const { data } = await client.renameConversation(conversationId, name, user, auto_generate)
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
15
app/api/file-upload/route.ts
Normal file
15
app/api/file-upload/route.ts
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,8 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(data as object, {
|
||||
headers: setSession(sessionId),
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
catch (error) {
|
||||
return NextResponse.json([])
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import classNames from 'classnames'
|
||||
import style from './style.module.css'
|
||||
|
||||
export type AppIconProps = {
|
||||
size?: 'tiny' | 'small' | 'medium' | 'large'
|
||||
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large'
|
||||
rounded?: boolean
|
||||
icon?: string
|
||||
background?: string
|
||||
|
||||
@ -1,15 +1,23 @@
|
||||
.appIcon {
|
||||
@apply flex items-center justify-center relative w-9 h-9 text-lg bg-teal-100 rounded-lg grow-0 shrink-0;
|
||||
}
|
||||
|
||||
.appIcon.large {
|
||||
@apply w-10 h-10;
|
||||
}
|
||||
|
||||
.appIcon.small {
|
||||
@apply w-8 h-8;
|
||||
}
|
||||
|
||||
.appIcon.xs {
|
||||
@apply w-3 h-3 text-base;
|
||||
}
|
||||
|
||||
.appIcon.tiny {
|
||||
@apply w-6 h-6 text-base;
|
||||
}
|
||||
|
||||
.appIcon.rounded {
|
||||
@apply rounded-full;
|
||||
}
|
||||
}
|
||||
31
app/components/base/icons/IconBase.tsx
Normal file
31
app/components/base/icons/IconBase.tsx
Normal file
@ -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<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
|
||||
39
app/components/base/icons/line/arrows/chevron-down/data.json
Normal file
39
app/components/base/icons/line/arrows/chevron-down/data.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"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": "chevron-down"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "Icon",
|
||||
"d": "M3 4.5L6 7.5L9 4.5",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "ChevronDown"
|
||||
}
|
||||
16
app/components/base/icons/line/arrows/chevron-down/index.tsx
Normal file
16
app/components/base/icons/line/arrows/chevron-down/index.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
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 = 'ChevronDown'
|
||||
|
||||
export default Icon
|
||||
39
app/components/base/icons/line/image-plus/data.json
Normal file
39
app/components/base/icons/line/image-plus/data.json
Normal file
@ -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"
|
||||
}
|
||||
13
app/components/base/icons/line/image-plus/index.tsx
Normal file
13
app/components/base/icons/line/image-plus/index.tsx
Normal file
@ -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<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
|
||||
props,
|
||||
ref,
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />)
|
||||
|
||||
Icon.displayName = 'ImagePlus'
|
||||
|
||||
export default Icon
|
||||
57
app/components/base/icons/line/link-03/data.json
Normal file
57
app/components/base/icons/line/link-03/data.json
Normal file
@ -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"
|
||||
}
|
||||
13
app/components/base/icons/line/link-03/index.tsx
Normal file
13
app/components/base/icons/line/link-03/index.tsx
Normal file
@ -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<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
|
||||
props,
|
||||
ref,
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />)
|
||||
|
||||
Icon.displayName = 'Link03'
|
||||
|
||||
export default Icon
|
||||
64
app/components/base/icons/line/loading-02/data.json
Normal file
64
app/components/base/icons/line/loading-02/data.json
Normal file
@ -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"
|
||||
}
|
||||
13
app/components/base/icons/line/loading-02/index.tsx
Normal file
13
app/components/base/icons/line/loading-02/index.tsx
Normal file
@ -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<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
|
||||
props,
|
||||
ref,
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />)
|
||||
|
||||
Icon.displayName = 'Loading02'
|
||||
|
||||
export default Icon
|
||||
29
app/components/base/icons/line/refresh-ccw-01/data.json
Normal file
29
app/components/base/icons/line/refresh-ccw-01/data.json
Normal file
@ -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"
|
||||
}
|
||||
13
app/components/base/icons/line/refresh-ccw-01/index.tsx
Normal file
13
app/components/base/icons/line/refresh-ccw-01/index.tsx
Normal file
@ -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<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
|
||||
props,
|
||||
ref,
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />)
|
||||
|
||||
Icon.displayName = 'RefreshCcw01'
|
||||
|
||||
export default Icon
|
||||
66
app/components/base/icons/line/upload-03/data.json
Normal file
66
app/components/base/icons/line/upload-03/data.json
Normal file
@ -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"
|
||||
}
|
||||
13
app/components/base/icons/line/upload-03/index.tsx
Normal file
13
app/components/base/icons/line/upload-03/index.tsx
Normal file
@ -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<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
|
||||
props,
|
||||
ref,
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />)
|
||||
|
||||
Icon.displayName = 'Upload03'
|
||||
|
||||
export default Icon
|
||||
39
app/components/base/icons/line/x-close/data.json
Normal file
39
app/components/base/icons/line/x-close/data.json
Normal file
@ -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"
|
||||
}
|
||||
13
app/components/base/icons/line/x-close/index.tsx
Normal file
13
app/components/base/icons/line/x-close/index.tsx
Normal file
@ -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<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
|
||||
props,
|
||||
ref,
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />)
|
||||
|
||||
Icon.displayName = 'XClose'
|
||||
|
||||
export default Icon
|
||||
64
app/components/base/icons/public/data-set/data.json
Normal file
64
app/components/base/icons/public/data-set/data.json
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"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": {
|
||||
"clip-path": "url(#clip0_7847_32895)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M10.5 2.5C10.5 3.32843 8.48528 4 6 4C3.51472 4 1.5 3.32843 1.5 2.5M10.5 2.5C10.5 1.67157 8.48528 1 6 1C3.51472 1 1.5 1.67157 1.5 2.5M10.5 2.5V9.5C10.5 10.33 8.5 11 6 11C3.5 11 1.5 10.33 1.5 9.5V2.5M10.5 6C10.5 6.83 8.5 7.5 6 7.5C3.5 7.5 1.5 6.83 1.5 6",
|
||||
"stroke": "#667085",
|
||||
"stroke-width": "1.25",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_7847_32895"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "12",
|
||||
"height": "12",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "DataSet"
|
||||
}
|
||||
16
app/components/base/icons/public/data-set/index.tsx
Normal file
16
app/components/base/icons/public/data-set/index.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
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 = 'DataSet'
|
||||
|
||||
export default Icon
|
||||
38
app/components/base/icons/solid/alert-triangle/data.json
Normal file
38
app/components/base/icons/solid/alert-triangle/data.json
Normal file
@ -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"
|
||||
}
|
||||
13
app/components/base/icons/solid/alert-triangle/index.tsx
Normal file
13
app/components/base/icons/solid/alert-triangle/index.tsx
Normal file
@ -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<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
|
||||
props,
|
||||
ref,
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />)
|
||||
|
||||
Icon.displayName = 'AlertTriangle'
|
||||
|
||||
export default Icon
|
||||
@ -0,0 +1,38 @@
|
||||
{
|
||||
"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": "check-circle"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "Solid",
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M8 0.666626C3.94992 0.666626 0.666672 3.94987 0.666672 7.99996C0.666672 12.05 3.94992 15.3333 8 15.3333C12.0501 15.3333 15.3333 12.05 15.3333 7.99996C15.3333 3.94987 12.0501 0.666626 8 0.666626ZM11.4714 6.47136C11.7318 6.21101 11.7318 5.7889 11.4714 5.52855C11.2111 5.26821 10.7889 5.26821 10.5286 5.52855L7 9.05715L5.47141 7.52855C5.21106 7.2682 4.78895 7.2682 4.5286 7.52855C4.26825 7.7889 4.26825 8.21101 4.5286 8.47136L6.5286 10.4714C6.78895 10.7317 7.21106 10.7317 7.47141 10.4714L11.4714 6.47136Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "CheckCircle"
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
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 = 'CheckCircle'
|
||||
|
||||
export default Icon
|
||||
66
app/components/base/icons/utils.tsx
Normal file
66
app/components/base/icons/utils.tsx
Normal file
@ -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}`)),
|
||||
)
|
||||
}
|
||||
83
app/components/base/image-gallery/index.tsx
Normal file
83
app/components/base/image-gallery/index.tsx
Normal file
@ -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<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>
|
||||
)
|
||||
}
|
||||
22
app/components/base/image-gallery/style.module.css
Normal file
22
app/components/base/image-gallery/style.module.css
Normal file
@ -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;
|
||||
}
|
||||
150
app/components/base/image-uploader/chat-image-uploader.tsx
Normal file
150
app/components/base/image-uploader/chat-image-uploader.tsx
Normal file
@ -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<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
|
||||
108
app/components/base/image-uploader/hooks.ts
Normal file
108
app/components/base/image-uploader/hooks.ts
Normal file
@ -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<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,
|
||||
}
|
||||
}
|
||||
50
app/components/base/image-uploader/image-link-input.tsx
Normal file
50
app/components/base/image-uploader/image-link-input.tsx
Normal file
@ -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<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
|
||||
130
app/components/base/image-uploader/image-list.tsx
Normal file
130
app/components/base/image-uploader/image-list.tsx
Normal file
@ -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<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
|
||||
31
app/components/base/image-uploader/image-preview.tsx
Normal file
31
app/components/base/image-uploader/image-preview.tsx
Normal file
@ -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<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
|
||||
101
app/components/base/image-uploader/uploader.tsx
Normal file
101
app/components/base/image-uploader/uploader.tsx
Normal file
@ -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<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
|
||||
38
app/components/base/image-uploader/utils.ts
Normal file
38
app/components/base/image-uploader/utils.ts
Normal file
@ -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()
|
||||
})
|
||||
}
|
||||
167
app/components/base/portal-to-follow-elem/index.tsx
Normal file
167
app/components/base/portal-to-follow-elem/index.tsx
Normal file
@ -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<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 {
|
||||
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<IToastContext>({} as IToastContext)
|
||||
export const useToastContext = () => useContext(ToastContext)
|
||||
|
||||
const Toast = ({
|
||||
type = 'info',
|
||||
duration,
|
||||
|
||||
50
app/components/base/tooltip-plus/index.tsx
Normal file
50
app/components/base/tooltip-plus/index.tsx
Normal file
@ -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 = (
|
||||
<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)
|
||||
203
app/components/chat/answer/index.tsx
Normal file
203
app/components/chat/answer/index.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LoadingAnim from '../loading-anim'
|
||||
import type { FeedbackFunc, IChatItem } from '../type'
|
||||
import s from '../style.module.css'
|
||||
import ImageGallery from '../../base/image-gallery'
|
||||
import Thought from '../thought'
|
||||
import { randomString } from '@/utils/string'
|
||||
import type { MessageRating, VisionFile } from '@/types/app'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import type { Emoji } from '@/types/tools'
|
||||
|
||||
const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => (
|
||||
<div
|
||||
className={`relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800 ${className ?? ''}`}
|
||||
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={onClick && onClick}
|
||||
>
|
||||
{innerContent}
|
||||
</div>
|
||||
)
|
||||
|
||||
const OpeningStatementIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M6.25002 1C3.62667 1 1.50002 3.12665 1.50002 5.75C1.50002 6.28 1.58702 6.79071 1.7479 7.26801C1.7762 7.35196 1.79285 7.40164 1.80368 7.43828L1.80722 7.45061L1.80535 7.45452C1.79249 7.48102 1.77339 7.51661 1.73766 7.58274L0.911727 9.11152C0.860537 9.20622 0.807123 9.30503 0.770392 9.39095C0.733879 9.47635 0.674738 9.63304 0.703838 9.81878C0.737949 10.0365 0.866092 10.2282 1.05423 10.343C1.21474 10.4409 1.38213 10.4461 1.475 10.4451C1.56844 10.444 1.68015 10.4324 1.78723 10.4213L4.36472 10.1549C4.406 10.1506 4.42758 10.1484 4.44339 10.1472L4.44542 10.147L4.45161 10.1492C4.47103 10.1562 4.49738 10.1663 4.54285 10.1838C5.07332 10.3882 5.64921 10.5 6.25002 10.5C8.87338 10.5 11 8.37335 11 5.75C11 3.12665 8.87338 1 6.25002 1ZM4.48481 4.29111C5.04844 3.81548 5.7986 3.9552 6.24846 4.47463C6.69831 3.9552 7.43879 3.82048 8.01211 4.29111C8.58544 4.76175 8.6551 5.562 8.21247 6.12453C7.93825 6.47305 7.24997 7.10957 6.76594 7.54348C6.58814 7.70286 6.49924 7.78255 6.39255 7.81466C6.30103 7.84221 6.19589 7.84221 6.10436 7.81466C5.99767 7.78255 5.90878 7.70286 5.73098 7.54348C5.24694 7.10957 4.55867 6.47305 4.28444 6.12453C3.84182 5.562 3.92117 4.76675 4.48481 4.29111Z" fill="#667085" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const RatingIcon: FC<{ isLike: boolean }> = ({ isLike }) => {
|
||||
return isLike ? <HandThumbUpIcon className='w-4 h-4' /> : <HandThumbDownIcon className='w-4 h-4' />
|
||||
}
|
||||
|
||||
const EditIcon: FC<{ className?: string }> = ({ className }) => {
|
||||
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path d="M14 11.9998L13.3332 12.7292C12.9796 13.1159 12.5001 13.3332 12.0001 13.3332C11.5001 13.3332 11.0205 13.1159 10.6669 12.7292C10.3128 12.3432 9.83332 12.1265 9.33345 12.1265C8.83359 12.1265 8.35409 12.3432 7.99998 12.7292M2 13.3332H3.11636C3.44248 13.3332 3.60554 13.3332 3.75899 13.2963C3.89504 13.2637 4.0251 13.2098 4.1444 13.1367C4.27895 13.0542 4.39425 12.9389 4.62486 12.7083L13 4.33316C13.5523 3.78087 13.5523 2.88544 13 2.33316C12.4477 1.78087 11.5523 1.78087 11 2.33316L2.62484 10.7083C2.39424 10.9389 2.27894 11.0542 2.19648 11.1888C2.12338 11.3081 2.0695 11.4381 2.03684 11.5742C2 11.7276 2 11.8907 2 12.2168V13.3332Z" stroke="#6B7280" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const EditIconSolid: FC<{ className?: string }> = ({ className }) => {
|
||||
return <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path fillRule="evenodd" clip-rule="evenodd" d="M10.8374 8.63108C11.0412 8.81739 11.0554 9.13366 10.8691 9.33747L10.369 9.88449C10.0142 10.2725 9.52293 10.5001 9.00011 10.5001C8.47746 10.5001 7.98634 10.2727 7.63157 9.8849C7.45561 9.69325 7.22747 9.59515 7.00014 9.59515C6.77271 9.59515 6.54446 9.69335 6.36846 9.88517C6.18177 10.0886 5.86548 10.1023 5.66201 9.91556C5.45853 9.72888 5.44493 9.41259 5.63161 9.20911C5.98678 8.82201 6.47777 8.59515 7.00014 8.59515C7.52251 8.59515 8.0135 8.82201 8.36867 9.20911L8.36924 9.20974C8.54486 9.4018 8.77291 9.50012 9.00011 9.50012C9.2273 9.50012 9.45533 9.40182 9.63095 9.20979L10.131 8.66276C10.3173 8.45895 10.6336 8.44476 10.8374 8.63108Z" fill="#6B7280" />
|
||||
<path fillRule="evenodd" clip-rule="evenodd" d="M7.89651 1.39656C8.50599 0.787085 9.49414 0.787084 10.1036 1.39656C10.7131 2.00604 10.7131 2.99419 10.1036 3.60367L3.82225 9.88504C3.81235 9.89494 3.80254 9.90476 3.79281 9.91451C3.64909 10.0585 3.52237 10.1855 3.3696 10.2791C3.23539 10.3613 3.08907 10.4219 2.93602 10.4587C2.7618 10.5005 2.58242 10.5003 2.37897 10.5001C2.3652 10.5001 2.35132 10.5001 2.33732 10.5001H1.50005C1.22391 10.5001 1.00005 10.2763 1.00005 10.0001V9.16286C1.00005 9.14886 1.00004 9.13497 1.00003 9.1212C0.999836 8.91776 0.999669 8.73838 1.0415 8.56416C1.07824 8.4111 1.13885 8.26479 1.22109 8.13058C1.31471 7.97781 1.44166 7.85109 1.58566 7.70736C1.5954 7.69764 1.60523 7.68783 1.61513 7.67793L7.89651 1.39656Z" fill="#6B7280" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
const IconWrapper: FC<{ children: React.ReactNode | string }> = ({ children }) => {
|
||||
return <div className={'rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100'}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
type IAnswerProps = {
|
||||
item: IChatItem
|
||||
feedbackDisabled: boolean
|
||||
onFeedback?: FeedbackFunc
|
||||
isResponsing?: boolean
|
||||
allToolIcons?: Record<string, string | Emoji>
|
||||
}
|
||||
|
||||
// The component needs to maintain its own state to control whether to display input component
|
||||
const Answer: FC<IAnswerProps> = ({
|
||||
item,
|
||||
feedbackDisabled = false,
|
||||
onFeedback,
|
||||
isResponsing,
|
||||
allToolIcons,
|
||||
}) => {
|
||||
const { id, content, feedback, agent_thoughts } = item
|
||||
const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
/**
|
||||
* Render feedback results (distinguish between users and administrators)
|
||||
* User reviews cannot be cancelled in Console
|
||||
* @param rating feedback result
|
||||
* @param isUserFeedback Whether it is user's feedback
|
||||
* @returns comp
|
||||
*/
|
||||
const renderFeedbackRating = (rating: MessageRating | undefined) => {
|
||||
if (!rating)
|
||||
return null
|
||||
|
||||
const isLike = rating === 'like'
|
||||
const ratingIconClassname = isLike ? 'text-primary-600 bg-primary-100 hover:bg-primary-200' : 'text-red-600 bg-red-100 hover:bg-red-200'
|
||||
// The tooltip is always displayed, but the content is different for different scenarios.
|
||||
return (
|
||||
<Tooltip
|
||||
selector={`user-feedback-${randomString(16)}`}
|
||||
content={isLike ? '取消赞同' : '取消反对'}
|
||||
>
|
||||
<div
|
||||
className={'relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800'}
|
||||
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={async () => {
|
||||
await onFeedback?.(id, { rating: null })
|
||||
}}
|
||||
>
|
||||
<div className={`${ratingIconClassname} rounded-lg h-6 w-6 flex items-center justify-center`}>
|
||||
<RatingIcon isLike={isLike} />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Different scenarios have different operation items.
|
||||
* @returns comp
|
||||
*/
|
||||
const renderItemOperation = () => {
|
||||
const userOperation = () => {
|
||||
return feedback?.rating
|
||||
? null
|
||||
: <div className='flex gap-1'>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.like') as string}>
|
||||
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
|
||||
</Tooltip>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.dislike') as string}>
|
||||
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${s.itemOperation} flex gap-2`}>
|
||||
{userOperation()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getImgs = (list?: VisionFile[]) => {
|
||||
if (!list)
|
||||
return []
|
||||
return list.filter(file => file.type === 'image' && file.belongs_to === 'assistant')
|
||||
}
|
||||
|
||||
const agentModeAnswer = (
|
||||
<div>
|
||||
{agent_thoughts?.map((item, index) => (
|
||||
<div key={index}>
|
||||
{item.thought && (
|
||||
<Markdown content={item.thought} />
|
||||
)}
|
||||
{/* {item.tool} */}
|
||||
{/* perhaps not use tool */}
|
||||
{!!item.tool && (
|
||||
<Thought
|
||||
thought={item}
|
||||
allToolIcons={allToolIcons || {}}
|
||||
isFinished={!!item.observation || !isResponsing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{getImgs(item.message_files).length > 0 && (
|
||||
<ImageGallery srcs={getImgs(item.message_files).map(item => item.url)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={id}>
|
||||
<div className='flex items-start'>
|
||||
<div className={`${s.answerIcon} w-10 h-10 shrink-0`}>
|
||||
{isResponsing
|
||||
&& <div className={s.typeingIcon}>
|
||||
<LoadingAnim type='avatar' />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={`${s.answerWrap}`}>
|
||||
<div className={`${s.answer} relative text-sm text-gray-900`}>
|
||||
<div className={'ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl'}>
|
||||
{(isResponsing && (isAgentMode ? (!content && (agent_thoughts || []).filter(item => !!item.thought || !!item.tool).length === 0) : !content))
|
||||
? (
|
||||
<div className='flex items-center justify-center w-6 h-5'>
|
||||
<LoadingAnim type='text' />
|
||||
</div>
|
||||
)
|
||||
: (isAgentMode
|
||||
? agentModeAnswer
|
||||
: (
|
||||
<Markdown content={content} />
|
||||
))}
|
||||
</div>
|
||||
<div className='absolute top-[-14px] right-[-14px] flex flex-row justify-end gap-1'>
|
||||
{!feedbackDisabled && !item.feedbackDisabled && renderItemOperation()}
|
||||
{/* User feedback must be displayed */}
|
||||
{!feedbackDisabled && renderFeedbackRating(feedback?.rating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Answer)
|
||||
@ -2,18 +2,19 @@
|
||||
import type { FC } from 'react'
|
||||
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 Answer from './answer'
|
||||
import Question from './question'
|
||||
import type { FeedbackFunc, Feedbacktype } from './type'
|
||||
import type { 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'
|
||||
|
||||
export type FeedbackFunc = (messageId: string, feedback: Feedbacktype) => Promise<any>
|
||||
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'
|
||||
|
||||
export type IChatProps = {
|
||||
chatList: IChatItem[]
|
||||
@ -27,11 +28,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,185 +53,7 @@ export type IChatItem = {
|
||||
isIntroduction?: boolean
|
||||
useCurrentUserAvatar?: boolean
|
||||
isOpeningStatement?: boolean
|
||||
}
|
||||
|
||||
const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => (
|
||||
<div
|
||||
className={`relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800 ${className ?? ''}`}
|
||||
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={onClick && onClick}
|
||||
>
|
||||
{innerContent}
|
||||
</div>
|
||||
)
|
||||
|
||||
const OpeningStatementIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M6.25002 1C3.62667 1 1.50002 3.12665 1.50002 5.75C1.50002 6.28 1.58702 6.79071 1.7479 7.26801C1.7762 7.35196 1.79285 7.40164 1.80368 7.43828L1.80722 7.45061L1.80535 7.45452C1.79249 7.48102 1.77339 7.51661 1.73766 7.58274L0.911727 9.11152C0.860537 9.20622 0.807123 9.30503 0.770392 9.39095C0.733879 9.47635 0.674738 9.63304 0.703838 9.81878C0.737949 10.0365 0.866092 10.2282 1.05423 10.343C1.21474 10.4409 1.38213 10.4461 1.475 10.4451C1.56844 10.444 1.68015 10.4324 1.78723 10.4213L4.36472 10.1549C4.406 10.1506 4.42758 10.1484 4.44339 10.1472L4.44542 10.147L4.45161 10.1492C4.47103 10.1562 4.49738 10.1663 4.54285 10.1838C5.07332 10.3882 5.64921 10.5 6.25002 10.5C8.87338 10.5 11 8.37335 11 5.75C11 3.12665 8.87338 1 6.25002 1ZM4.48481 4.29111C5.04844 3.81548 5.7986 3.9552 6.24846 4.47463C6.69831 3.9552 7.43879 3.82048 8.01211 4.29111C8.58544 4.76175 8.6551 5.562 8.21247 6.12453C7.93825 6.47305 7.24997 7.10957 6.76594 7.54348C6.58814 7.70286 6.49924 7.78255 6.39255 7.81466C6.30103 7.84221 6.19589 7.84221 6.10436 7.81466C5.99767 7.78255 5.90878 7.70286 5.73098 7.54348C5.24694 7.10957 4.55867 6.47305 4.28444 6.12453C3.84182 5.562 3.92117 4.76675 4.48481 4.29111Z" fill="#667085" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const RatingIcon: FC<{ isLike: boolean }> = ({ isLike }) => {
|
||||
return isLike ? <HandThumbUpIcon className='w-4 h-4' /> : <HandThumbDownIcon className='w-4 h-4' />
|
||||
}
|
||||
|
||||
const EditIcon: FC<{ className?: string }> = ({ className }) => {
|
||||
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path d="M14 11.9998L13.3332 12.7292C12.9796 13.1159 12.5001 13.3332 12.0001 13.3332C11.5001 13.3332 11.0205 13.1159 10.6669 12.7292C10.3128 12.3432 9.83332 12.1265 9.33345 12.1265C8.83359 12.1265 8.35409 12.3432 7.99998 12.7292M2 13.3332H3.11636C3.44248 13.3332 3.60554 13.3332 3.75899 13.2963C3.89504 13.2637 4.0251 13.2098 4.1444 13.1367C4.27895 13.0542 4.39425 12.9389 4.62486 12.7083L13 4.33316C13.5523 3.78087 13.5523 2.88544 13 2.33316C12.4477 1.78087 11.5523 1.78087 11 2.33316L2.62484 10.7083C2.39424 10.9389 2.27894 11.0542 2.19648 11.1888C2.12338 11.3081 2.0695 11.4381 2.03684 11.5742C2 11.7276 2 11.8907 2 12.2168V13.3332Z" stroke="#6B7280" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const EditIconSolid: FC<{ className?: string }> = ({ className }) => {
|
||||
return <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path fillRule="evenodd" clip-rule="evenodd" d="M10.8374 8.63108C11.0412 8.81739 11.0554 9.13366 10.8691 9.33747L10.369 9.88449C10.0142 10.2725 9.52293 10.5001 9.00011 10.5001C8.47746 10.5001 7.98634 10.2727 7.63157 9.8849C7.45561 9.69325 7.22747 9.59515 7.00014 9.59515C6.77271 9.59515 6.54446 9.69335 6.36846 9.88517C6.18177 10.0886 5.86548 10.1023 5.66201 9.91556C5.45853 9.72888 5.44493 9.41259 5.63161 9.20911C5.98678 8.82201 6.47777 8.59515 7.00014 8.59515C7.52251 8.59515 8.0135 8.82201 8.36867 9.20911L8.36924 9.20974C8.54486 9.4018 8.77291 9.50012 9.00011 9.50012C9.2273 9.50012 9.45533 9.40182 9.63095 9.20979L10.131 8.66276C10.3173 8.45895 10.6336 8.44476 10.8374 8.63108Z" fill="#6B7280" />
|
||||
<path fillRule="evenodd" clip-rule="evenodd" d="M7.89651 1.39656C8.50599 0.787085 9.49414 0.787084 10.1036 1.39656C10.7131 2.00604 10.7131 2.99419 10.1036 3.60367L3.82225 9.88504C3.81235 9.89494 3.80254 9.90476 3.79281 9.91451C3.64909 10.0585 3.52237 10.1855 3.3696 10.2791C3.23539 10.3613 3.08907 10.4219 2.93602 10.4587C2.7618 10.5005 2.58242 10.5003 2.37897 10.5001C2.3652 10.5001 2.35132 10.5001 2.33732 10.5001H1.50005C1.22391 10.5001 1.00005 10.2763 1.00005 10.0001V9.16286C1.00005 9.14886 1.00004 9.13497 1.00003 9.1212C0.999836 8.91776 0.999669 8.73838 1.0415 8.56416C1.07824 8.4111 1.13885 8.26479 1.22109 8.13058C1.31471 7.97781 1.44166 7.85109 1.58566 7.70736C1.5954 7.69764 1.60523 7.68783 1.61513 7.67793L7.89651 1.39656Z" fill="#6B7280" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
const IconWrapper: FC<{ children: React.ReactNode | string }> = ({ children }) => {
|
||||
return <div className={'rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100'}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
type IAnswerProps = {
|
||||
item: IChatItem
|
||||
feedbackDisabled: boolean
|
||||
onFeedback?: FeedbackFunc
|
||||
isResponsing?: boolean
|
||||
}
|
||||
|
||||
// The component needs to maintain its own state to control whether to display input component
|
||||
const Answer: FC<IAnswerProps> = ({ item, feedbackDisabled = false, onFeedback, isResponsing }) => {
|
||||
const { id, content, feedback } = item
|
||||
const { t } = useTranslation()
|
||||
|
||||
/**
|
||||
* Render feedback results (distinguish between users and administrators)
|
||||
* User reviews cannot be cancelled in Console
|
||||
* @param rating feedback result
|
||||
* @param isUserFeedback Whether it is user's feedback
|
||||
* @returns comp
|
||||
*/
|
||||
const renderFeedbackRating = (rating: MessageRating | undefined) => {
|
||||
if (!rating)
|
||||
return null
|
||||
|
||||
const isLike = rating === 'like'
|
||||
const ratingIconClassname = isLike ? 'text-primary-600 bg-primary-100 hover:bg-primary-200' : 'text-red-600 bg-red-100 hover:bg-red-200'
|
||||
// The tooltip is always displayed, but the content is different for different scenarios.
|
||||
return (
|
||||
<Tooltip
|
||||
selector={`user-feedback-${randomString(16)}`}
|
||||
content={isLike ? '取消赞同' : '取消反对'}
|
||||
>
|
||||
<div
|
||||
className={'relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800'}
|
||||
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={async () => {
|
||||
await onFeedback?.(id, { rating: null })
|
||||
}}
|
||||
>
|
||||
<div className={`${ratingIconClassname} rounded-lg h-6 w-6 flex items-center justify-center`}>
|
||||
<RatingIcon isLike={isLike} />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Different scenarios have different operation items.
|
||||
* @returns comp
|
||||
*/
|
||||
const renderItemOperation = () => {
|
||||
const userOperation = () => {
|
||||
return feedback?.rating
|
||||
? null
|
||||
: <div className='flex gap-1'>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.like') as string}>
|
||||
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
|
||||
</Tooltip>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.dislike') as string}>
|
||||
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${s.itemOperation} flex gap-2`}>
|
||||
{userOperation()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={id}>
|
||||
<div className='flex items-start'>
|
||||
<div className={`${s.answerIcon} w-10 h-10 shrink-0`}>
|
||||
{isResponsing
|
||||
&& <div className={s.typeingIcon}>
|
||||
<LoadingAnim type='avatar' />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={`${s.answerWrap}`}>
|
||||
<div className={`${s.answer} relative text-sm text-gray-900`}>
|
||||
<div className={'ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl'}>
|
||||
{item.isOpeningStatement && (
|
||||
<div className='flex items-center mb-1 gap-1'>
|
||||
<OpeningStatementIcon />
|
||||
<div className='text-xs text-gray-500'>{t('app.chat.openingStatementTitle')}</div>
|
||||
</div>
|
||||
)}
|
||||
{(isResponsing && !content)
|
||||
? (
|
||||
<div className='flex items-center justify-center w-6 h-5'>
|
||||
<LoadingAnim type='text' />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Markdown content={content} />
|
||||
)}
|
||||
</div>
|
||||
<div className='absolute top-[-14px] right-[-14px] flex flex-row justify-end gap-1'>
|
||||
{!feedbackDisabled && !item.feedbackDisabled && renderItemOperation()}
|
||||
{/* User feedback must be displayed */}
|
||||
{!feedbackDisabled && renderFeedbackRating(feedback?.rating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'useCurrentUserAvatar'>
|
||||
|
||||
const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar }) => {
|
||||
const userName = ''
|
||||
return (
|
||||
<div className='flex items-start justify-end' key={id}>
|
||||
<div>
|
||||
<div className={`${s.question} relative text-sm text-gray-900`}>
|
||||
<div
|
||||
className={'mr-2 py-3 px-4 bg-blue-500 rounded-tl-2xl rounded-b-2xl'}
|
||||
>
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{useCurrentUserAvatar
|
||||
? (
|
||||
<div className='w-10 h-10 shrink-0 leading-10 text-center mr-2 rounded-full bg-primary-600 text-white'>
|
||||
{userName?.[0].toLocaleUpperCase()}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={`${s.questionIcon} w-10 h-10 shrink-0 `}></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
message_files?: VisionFile[]
|
||||
}
|
||||
|
||||
const Chat: FC<IChatProps> = ({
|
||||
@ -243,7 +66,7 @@ const Chat: FC<IChatProps> = ({
|
||||
useCurrentUserAvatar,
|
||||
isResponsing,
|
||||
controlClearQuery,
|
||||
controlFocus,
|
||||
visionConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = Toast
|
||||
@ -271,13 +94,31 @@ const Chat: FC<IChatProps> = ({
|
||||
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 +130,7 @@ const Chat: FC<IChatProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
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 +153,56 @@ const Chat: FC<IChatProps> = ({
|
||||
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>
|
||||
{
|
||||
!isHideSendInput && (
|
||||
<div className={cn(!feedbackDisabled && '!left-3.5 !right-3.5', 'absolute z-10 bottom-0 left-0 right-0')}>
|
||||
<div className="positive">
|
||||
<AutoHeightTextarea
|
||||
<div className='p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto'>
|
||||
{
|
||||
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}
|
||||
onChange={handleContentChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={haneleKeyDown}
|
||||
minHeight={48}
|
||||
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`}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoSize
|
||||
/>
|
||||
<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>
|
||||
<Tooltip
|
||||
selector='send-tip'
|
||||
|
||||
43
app/components/chat/question/index.tsx
Normal file
43
app/components/chat/question/index.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { IChatItem } from '../type'
|
||||
import s from '../style.module.css'
|
||||
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
|
||||
type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'useCurrentUserAvatar'> & {
|
||||
imgSrcs?: string[]
|
||||
}
|
||||
|
||||
const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar, imgSrcs }) => {
|
||||
const userName = ''
|
||||
return (
|
||||
<div className='flex items-start justify-end' key={id}>
|
||||
<div>
|
||||
<div className={`${s.question} relative text-sm text-gray-900`}>
|
||||
<div
|
||||
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} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{useCurrentUserAvatar
|
||||
? (
|
||||
<div className='w-10 h-10 shrink-0 leading-10 text-center mr-2 rounded-full bg-primary-600 text-white'>
|
||||
{userName?.[0].toLocaleUpperCase()}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={`${s.questionIcon} w-10 h-10 shrink-0 `}></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Question)
|
||||
61
app/components/chat/thought/index.tsx
Normal file
61
app/components/chat/thought/index.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { ThoughtItem, ToolInfoInThought } from '../type'
|
||||
import Tool from './tool'
|
||||
import type { Emoji } from '@/types/tools'
|
||||
|
||||
export type IThoughtProps = {
|
||||
thought: ThoughtItem
|
||||
allToolIcons: Record<string, string | Emoji>
|
||||
isFinished: boolean
|
||||
}
|
||||
|
||||
function getValue(value: string, isValueArray: boolean, index: number) {
|
||||
if (isValueArray) {
|
||||
try {
|
||||
return JSON.parse(value)[index]
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const Thought: FC<IThoughtProps> = ({
|
||||
thought,
|
||||
allToolIcons,
|
||||
isFinished,
|
||||
}) => {
|
||||
const [toolNames, isValueArray]: [string[], boolean] = (() => {
|
||||
try {
|
||||
if (Array.isArray(JSON.parse(thought.tool)))
|
||||
return [JSON.parse(thought.tool), true]
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
return [[thought.tool], false]
|
||||
})()
|
||||
|
||||
const toolThoughtList = toolNames.map((toolName, index) => {
|
||||
return {
|
||||
name: toolName,
|
||||
input: getValue(thought.tool_input, isValueArray, index),
|
||||
output: getValue(thought.observation, isValueArray, index),
|
||||
isFinished,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='my-2 space-y-2'>
|
||||
{toolThoughtList.map((item: ToolInfoInThought, index) => (
|
||||
<Tool
|
||||
key={index}
|
||||
payload={item}
|
||||
allToolIcons={allToolIcons}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Thought)
|
||||
28
app/components/chat/thought/panel.tsx
Normal file
28
app/components/chat/thought/panel.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
isRequest: boolean
|
||||
toolName: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const Panel: FC<Props> = ({
|
||||
isRequest,
|
||||
toolName,
|
||||
content,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='rounded-md bg-gray-100 overflow-hidden border border-black/5'>
|
||||
<div className='flex items-center px-2 py-1 leading-[18px] bg-gray-50 uppercase text-xs font-medium text-gray-500'>
|
||||
{t(`tools.thought.${isRequest ? 'requestTitle' : 'responseTitle'}`)} {toolName}
|
||||
</div>
|
||||
<div className='p-2 border-t border-black/5 leading-4 text-xs text-gray-700'>{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Panel)
|
||||
7
app/components/chat/thought/style.module.css
Normal file
7
app/components/chat/thought/style.module.css
Normal file
@ -0,0 +1,7 @@
|
||||
.wrap {
|
||||
background-color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.wrapHoverEffect:hover{
|
||||
box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.06), 0px 1px 3px 0px rgba(16, 24, 40, 0.1);
|
||||
}
|
||||
103
app/components/chat/thought/tool.tsx
Normal file
103
app/components/chat/thought/tool.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import cn from 'classnames'
|
||||
import type { ToolInfoInThought } from '../type'
|
||||
import Panel from './panel'
|
||||
import Loading02 from '@/app/components/base/icons/line/loading-02'
|
||||
import ChevronDown from '@/app/components/base/icons/line/arrows/chevron-down'
|
||||
import CheckCircle from '@/app/components/base/icons/solid/general/check-circle'
|
||||
import DataSetIcon from '@/app/components/base/icons/public/data-set'
|
||||
import type { Emoji } from '@/types/tools'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
|
||||
type Props = {
|
||||
payload: ToolInfoInThought
|
||||
allToolIcons?: Record<string, string | Emoji>
|
||||
}
|
||||
|
||||
const getIcon = (toolName: string, allToolIcons: Record<string, string | Emoji>) => {
|
||||
if (toolName.startsWith('dataset-'))
|
||||
return <DataSetIcon className='shrink-0'></DataSetIcon>
|
||||
const icon = allToolIcons[toolName]
|
||||
if (!icon)
|
||||
return null
|
||||
return (
|
||||
typeof icon === 'string'
|
||||
? (
|
||||
<div
|
||||
className='w-3 h-3 bg-cover bg-center rounded-[3px] shrink-0'
|
||||
style={{
|
||||
backgroundImage: `url(${icon})`,
|
||||
}}
|
||||
></div>
|
||||
)
|
||||
: (
|
||||
<AppIcon
|
||||
className='rounded-[3px] shrink-0'
|
||||
size='xs'
|
||||
icon={icon?.content}
|
||||
background={icon?.background}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const Tool: FC<Props> = ({
|
||||
payload,
|
||||
allToolIcons = {},
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { name, input, isFinished, output } = payload
|
||||
const toolName = name.startsWith('dataset-') ? t('dataset.knowledge') : name
|
||||
const [isShowDetail, setIsShowDetail] = useState(false)
|
||||
const icon = getIcon(toolName, allToolIcons) as any
|
||||
return (
|
||||
<div>
|
||||
<div className={cn(!isShowDetail && 'shadow-sm', !isShowDetail && 'inline-block', 'max-w-full overflow-x-auto bg-white rounded-md')}>
|
||||
<div
|
||||
className={cn('flex items-center h-7 px-2 cursor-pointer')}
|
||||
onClick={() => setIsShowDetail(!isShowDetail)}
|
||||
>
|
||||
{!isFinished && (
|
||||
<Loading02 className='w-3 h-3 text-gray-500 animate-spin shrink-0' />
|
||||
)}
|
||||
{isFinished && !isShowDetail && (
|
||||
<CheckCircle className='w-3 h-3 text-[#12B76A] shrink-0' />
|
||||
)}
|
||||
{isFinished && isShowDetail && (
|
||||
icon
|
||||
)}
|
||||
<span className='mx-1 text-xs font-medium text-gray-500 shrink-0'>
|
||||
{t(`tools.thought.${isFinished ? 'used' : 'using'}`)}
|
||||
</span>
|
||||
<span
|
||||
className='text-xs font-medium text-gray-700 truncate'
|
||||
title={toolName}
|
||||
>
|
||||
{toolName}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(isShowDetail && 'rotate-180', 'ml-1 w-3 h-3 text-gray-500 select-none cursor-pointer shrink-0')}
|
||||
/>
|
||||
</div>
|
||||
{isShowDetail && (
|
||||
<div className='border-t border-black/5 p-2 space-y-2 '>
|
||||
<Panel
|
||||
isRequest={true}
|
||||
toolName={toolName}
|
||||
content={input} />
|
||||
{output && (
|
||||
<Panel
|
||||
isRequest={false}
|
||||
toolName={toolName}
|
||||
content={output as string} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Tool)
|
||||
134
app/components/chat/type.ts
Normal file
134
app/components/chat/type.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import type { VisionFile } from '@/types/app'
|
||||
|
||||
export type LogAnnotation = {
|
||||
content: string
|
||||
account: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type Annotation = {
|
||||
id: string
|
||||
authorName: string
|
||||
logAnnotation?: LogAnnotation
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export const MessageRatings = ['like', 'dislike', null] as const
|
||||
export type MessageRating = typeof MessageRatings[number]
|
||||
|
||||
export type MessageMore = {
|
||||
time: string
|
||||
tokens: number
|
||||
latency: number | string
|
||||
}
|
||||
|
||||
export type Feedbacktype = {
|
||||
rating: MessageRating
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
export type FeedbackFunc = (messageId: string, feedback: Feedbacktype) => Promise<any>
|
||||
export type SubmitAnnotationFunc = (messageId: string, content: string) => Promise<any>
|
||||
|
||||
export type DisplayScene = 'web' | 'console'
|
||||
|
||||
export type ToolInfoInThought = {
|
||||
name: string
|
||||
input: string
|
||||
output: string
|
||||
isFinished: boolean
|
||||
}
|
||||
|
||||
export type ThoughtItem = {
|
||||
id: string
|
||||
tool: string // plugin or dataset. May has multi.
|
||||
thought: string
|
||||
tool_input: string
|
||||
message_id: string
|
||||
observation: string
|
||||
position: number
|
||||
files?: string[]
|
||||
message_files?: VisionFile[]
|
||||
}
|
||||
|
||||
export type CitationItem = {
|
||||
content: string
|
||||
data_source_type: string
|
||||
dataset_name: string
|
||||
dataset_id: string
|
||||
document_id: string
|
||||
document_name: string
|
||||
hit_count: number
|
||||
index_node_hash: string
|
||||
segment_id: string
|
||||
segment_position: number
|
||||
score: number
|
||||
word_count: number
|
||||
}
|
||||
|
||||
export type IChatItem = {
|
||||
id: string
|
||||
content: string
|
||||
citation?: CitationItem[]
|
||||
/**
|
||||
* Specific message type
|
||||
*/
|
||||
isAnswer: boolean
|
||||
/**
|
||||
* The user feedback result of this message
|
||||
*/
|
||||
feedback?: Feedbacktype
|
||||
/**
|
||||
* The admin feedback result of this message
|
||||
*/
|
||||
adminFeedback?: Feedbacktype
|
||||
/**
|
||||
* Whether to hide the feedback area
|
||||
*/
|
||||
feedbackDisabled?: boolean
|
||||
/**
|
||||
* More information about this message
|
||||
*/
|
||||
more?: MessageMore
|
||||
annotation?: Annotation
|
||||
useCurrentUserAvatar?: boolean
|
||||
isOpeningStatement?: boolean
|
||||
suggestedQuestions?: string[]
|
||||
log?: { role: string; text: string }[]
|
||||
agent_thoughts?: ThoughtItem[]
|
||||
message_files?: VisionFile[]
|
||||
}
|
||||
|
||||
export type MessageEnd = {
|
||||
id: string
|
||||
metadata: {
|
||||
retriever_resources?: CitationItem[]
|
||||
annotation_reply: {
|
||||
id: string
|
||||
account: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type MessageReplace = {
|
||||
id: string
|
||||
task_id: string
|
||||
answer: string
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
export type AnnotationReply = {
|
||||
id: string
|
||||
task_id: string
|
||||
answer: string
|
||||
conversation_id: string
|
||||
annotation_id: string
|
||||
annotation_author_name: string
|
||||
}
|
||||
@ -3,15 +3,16 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import produce, { setAutoFreeze } from 'immer'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import useConversation from '@/hooks/use-conversation'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Sidebar from '@/app/components/sidebar'
|
||||
import ConfigSence from '@/app/components/config-scence'
|
||||
import Header from '@/app/components/header'
|
||||
import { fetchAppParams, fetchChatList, fetchConversations, sendChatMessage, updateFeedback } from '@/service'
|
||||
import type { ConversationItem, Feedbacktype, IChatItem, PromptConfig } from '@/types/app'
|
||||
import { fetchAppParams, fetchChatList, fetchConversations, generationConversationName, sendChatMessage, updateFeedback } from '@/service'
|
||||
import type { ConversationItem, Feedbacktype, IChatItem, PromptConfig, VisionFile, VisionSettings } from '@/types/app'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import Chat from '@/app/components/chat'
|
||||
import { setLocaleOnClient } from '@/i18n/client'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
@ -19,6 +20,8 @@ import Loading from '@/app/components/base/loading'
|
||||
import { replaceVarWithValues, userInputsFormToPromptVariables } from '@/utils/prompt'
|
||||
import AppUnavailable from '@/app/components/app-unavailable'
|
||||
import { API_KEY, APP_ID, APP_INFO, isShowPrompt, promptTemplate } from '@/config'
|
||||
import type { Annotation as AnnotationType } from '@/types/log'
|
||||
import { addFileInfos, sortAgentSorts } from '@/utils/tools'
|
||||
|
||||
const Main: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
@ -35,12 +38,26 @@ const Main: FC = () => {
|
||||
const [inited, setInited] = useState<boolean>(false)
|
||||
// in mobile, show sidebar by click button
|
||||
const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false)
|
||||
const [visionConfig, setVisionConfig] = useState<VisionSettings | undefined>({
|
||||
enabled: false,
|
||||
number_limits: 2,
|
||||
detail: Resolution.low,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (APP_INFO?.title)
|
||||
document.title = `${APP_INFO.title} - Powered by Dify`
|
||||
}, [APP_INFO?.title])
|
||||
|
||||
// onData change thought (the produce obj). https://github.com/immerjs/immer/issues/576
|
||||
useEffect(() => {
|
||||
setAutoFreeze(false)
|
||||
return () => {
|
||||
setAutoFreeze(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/*
|
||||
* conversation info
|
||||
*/
|
||||
@ -48,6 +65,7 @@ const Main: FC = () => {
|
||||
conversationList,
|
||||
setConversationList,
|
||||
currConversationId,
|
||||
getCurrConversationId,
|
||||
setCurrConversationId,
|
||||
getConversationIdFromStorage,
|
||||
isNewConversation,
|
||||
@ -113,12 +131,16 @@ const Main: FC = () => {
|
||||
id: `question-${item.id}`,
|
||||
content: item.query,
|
||||
isAnswer: false,
|
||||
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
|
||||
|
||||
})
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
|
||||
feedback: item.feedback,
|
||||
isAnswer: true,
|
||||
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
})
|
||||
})
|
||||
setChatList(newChatList)
|
||||
@ -127,8 +149,6 @@ const Main: FC = () => {
|
||||
|
||||
if (isNewConversation && isChatStarted)
|
||||
setChatList(generateNewChatListWithOpenstatement())
|
||||
|
||||
setControlFocus(Date.now())
|
||||
}
|
||||
useEffect(handleConversationSwitch, [currConversationId, inited])
|
||||
|
||||
@ -208,7 +228,7 @@ const Main: FC = () => {
|
||||
const isNotNewConversation = conversations.some(item => item.id === _conversationId)
|
||||
|
||||
// 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)
|
||||
setNewConversationInfo({
|
||||
name: t('app.chat.newChatDefaultName'),
|
||||
@ -219,7 +239,10 @@ const Main: FC = () => {
|
||||
prompt_template: promptTemplate,
|
||||
prompt_variables,
|
||||
} as PromptConfig)
|
||||
|
||||
setVisionConfig({
|
||||
...file_upload?.image,
|
||||
image_file_size_limit: system_parameters?.system_parameters || 0,
|
||||
})
|
||||
setConversationList(conversations as ConversationItem[])
|
||||
|
||||
if (isNotNewConversation)
|
||||
@ -240,6 +263,7 @@ const Main: FC = () => {
|
||||
}, [])
|
||||
|
||||
const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
const { notify } = Toast
|
||||
const logError = (message: string) => {
|
||||
notify({ type: 'error', message })
|
||||
@ -264,23 +288,65 @@ const Main: FC = () => {
|
||||
}
|
||||
|
||||
const [controlFocus, setControlFocus] = useState(0)
|
||||
const handleSend = async (message: string) => {
|
||||
const [openingSuggestedQuestions, setOpeningSuggestedQuestions] = useState<string[]>([])
|
||||
const [messageTaskId, setMessageTaskId] = useState('')
|
||||
const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false)
|
||||
const [isResponsingConIsCurrCon, setIsResponsingConCurrCon, getIsResponsingConIsCurrCon] = useGetState(true)
|
||||
const [userQuery, setUserQuery] = useState('')
|
||||
|
||||
const updateCurrentQA = ({
|
||||
responseItem,
|
||||
questionId,
|
||||
placeholderAnswerId,
|
||||
questionItem,
|
||||
}: {
|
||||
responseItem: IChatItem
|
||||
questionId: string
|
||||
placeholderAnswerId: string
|
||||
questionItem: IChatItem
|
||||
}) => {
|
||||
// closesure new list is outdated.
|
||||
const newListWithAnswer = produce(
|
||||
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
|
||||
(draft) => {
|
||||
if (!draft.find(item => item.id === questionId))
|
||||
draft.push({ ...questionItem })
|
||||
|
||||
draft.push({ ...responseItem })
|
||||
})
|
||||
setChatList(newListWithAnswer)
|
||||
}
|
||||
|
||||
const handleSend = async (message: string, files?: VisionFile[]) => {
|
||||
if (isResponsing) {
|
||||
notify({ type: 'info', message: t('app.errorMessage.waitForResponse') })
|
||||
return
|
||||
}
|
||||
const data = {
|
||||
const data: Record<string, any> = {
|
||||
inputs: currInputs,
|
||||
query: message,
|
||||
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
|
||||
const questionId = `question-${Date.now()}`
|
||||
const questionItem = {
|
||||
id: questionId,
|
||||
content: message,
|
||||
isAnswer: false,
|
||||
message_files: files,
|
||||
}
|
||||
|
||||
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
|
||||
@ -293,23 +359,145 @@ const Main: FC = () => {
|
||||
const newList = [...getChatList(), questionItem, placeholderAnswerItem]
|
||||
setChatList(newList)
|
||||
|
||||
let isAgentMode = false
|
||||
|
||||
// answer
|
||||
const responseItem = {
|
||||
const responseItem: IChatItem = {
|
||||
id: `${Date.now()}`,
|
||||
content: '',
|
||||
agent_thoughts: [],
|
||||
message_files: [],
|
||||
isAnswer: true,
|
||||
}
|
||||
let hasSetResponseId = false
|
||||
|
||||
const prevTempNewConversationId = getCurrConversationId() || '-1'
|
||||
let tempNewConversationId = ''
|
||||
|
||||
setResponsingTrue()
|
||||
sendChatMessage(data, {
|
||||
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId }: any) => {
|
||||
responseItem.content = responseItem.content + message
|
||||
responseItem.id = messageId
|
||||
getAbortController: (abortController) => {
|
||||
setAbortController(abortController)
|
||||
},
|
||||
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
|
||||
if (!isAgentMode) {
|
||||
responseItem.content = responseItem.content + message
|
||||
}
|
||||
else {
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
if (lastThought)
|
||||
lastThought.thought = lastThought.thought + message // need immer setAutoFreeze
|
||||
}
|
||||
if (messageId && !hasSetResponseId) {
|
||||
responseItem.id = messageId
|
||||
hasSetResponseId = true
|
||||
}
|
||||
|
||||
if (isFirstMessage && newConversationId)
|
||||
tempNewConversationId = newConversationId
|
||||
|
||||
// closesure new list is outdated.
|
||||
setMessageTaskId(taskId)
|
||||
// has switched to other conversation
|
||||
if (prevTempNewConversationId !== getCurrConversationId()) {
|
||||
setIsResponsingConCurrCon(false)
|
||||
return
|
||||
}
|
||||
updateCurrentQA({
|
||||
responseItem,
|
||||
questionId,
|
||||
placeholderAnswerId,
|
||||
questionItem,
|
||||
})
|
||||
},
|
||||
async onCompleted(hasError?: boolean) {
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (getConversationIdChangeBecauseOfNew()) {
|
||||
const { data: allConversations }: any = await fetchConversations()
|
||||
const newItem: any = await generationConversationName(allConversations[0].id)
|
||||
|
||||
const newAllConversations = produce(allConversations, (draft: any) => {
|
||||
draft[0].name = newItem.name
|
||||
})
|
||||
setConversationList(newAllConversations as any)
|
||||
}
|
||||
setConversationIdChangeBecauseOfNew(false)
|
||||
resetNewConversationInputs()
|
||||
setChatNotStarted()
|
||||
setCurrConversationId(tempNewConversationId, APP_ID, true)
|
||||
setResponsingFalse()
|
||||
},
|
||||
onFile(file) {
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
if (lastThought)
|
||||
lastThought.message_files = [...(lastThought as any).message_files, { ...file }]
|
||||
|
||||
updateCurrentQA({
|
||||
responseItem,
|
||||
questionId,
|
||||
placeholderAnswerId,
|
||||
questionItem,
|
||||
})
|
||||
},
|
||||
onThought(thought) {
|
||||
isAgentMode = true
|
||||
const response = responseItem as any
|
||||
if (thought.message_id && !hasSetResponseId) {
|
||||
response.id = thought.message_id
|
||||
hasSetResponseId = true
|
||||
}
|
||||
// responseItem.id = thought.message_id;
|
||||
if (response.agent_thoughts.length === 0) {
|
||||
response.agent_thoughts.push(thought)
|
||||
}
|
||||
else {
|
||||
const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]
|
||||
// thought changed but still the same thought, so update.
|
||||
if (lastThought.id === thought.id) {
|
||||
thought.thought = lastThought.thought
|
||||
thought.message_files = lastThought.message_files
|
||||
responseItem.agent_thoughts![response.agent_thoughts.length - 1] = thought
|
||||
}
|
||||
else {
|
||||
responseItem.agent_thoughts!.push(thought)
|
||||
}
|
||||
}
|
||||
// has switched to other conversation
|
||||
if (prevTempNewConversationId !== getCurrConversationId()) {
|
||||
setIsResponsingConCurrCon(false)
|
||||
return false
|
||||
}
|
||||
|
||||
updateCurrentQA({
|
||||
responseItem,
|
||||
questionId,
|
||||
placeholderAnswerId,
|
||||
questionItem,
|
||||
})
|
||||
},
|
||||
onMessageEnd: (messageEnd) => {
|
||||
if (messageEnd.metadata?.annotation_reply) {
|
||||
responseItem.id = messageEnd.id
|
||||
responseItem.annotation = ({
|
||||
id: messageEnd.metadata.annotation_reply.id,
|
||||
authorName: messageEnd.metadata.annotation_reply.account.name,
|
||||
} as AnnotationType)
|
||||
const newListWithAnswer = produce(
|
||||
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
|
||||
(draft) => {
|
||||
if (!draft.find(item => item.id === questionId))
|
||||
draft.push({ ...questionItem })
|
||||
|
||||
draft.push({
|
||||
...responseItem,
|
||||
})
|
||||
})
|
||||
setChatList(newListWithAnswer)
|
||||
return
|
||||
}
|
||||
// not support show citation
|
||||
// responseItem.citation = messageEnd.retriever_resources
|
||||
const newListWithAnswer = produce(
|
||||
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
|
||||
(draft) => {
|
||||
@ -320,19 +508,16 @@ const Main: FC = () => {
|
||||
})
|
||||
setChatList(newListWithAnswer)
|
||||
},
|
||||
async onCompleted() {
|
||||
setResponsingFalse()
|
||||
if (!tempNewConversationId)
|
||||
return
|
||||
onMessageReplace: (messageReplace) => {
|
||||
setChatList(produce(
|
||||
getChatList(),
|
||||
(draft) => {
|
||||
const current = draft.find(item => item.id === messageReplace.id)
|
||||
|
||||
if (getConversationIdChangeBecauseOfNew()) {
|
||||
const { data: conversations }: any = await fetchConversations()
|
||||
setConversationList(conversations as ConversationItem[])
|
||||
}
|
||||
setConversationIdChangeBecauseOfNew(false)
|
||||
resetNewConversationInputs()
|
||||
setChatNotStarted()
|
||||
setCurrConversationId(tempNewConversationId, APP_ID, true)
|
||||
if (current)
|
||||
current.content = messageReplace.answer
|
||||
},
|
||||
))
|
||||
},
|
||||
onError() {
|
||||
setResponsingFalse()
|
||||
@ -423,7 +608,7 @@ const Main: FC = () => {
|
||||
onFeedback={handleFeedback}
|
||||
isResponsing={isResponsing}
|
||||
checkCanSend={checkCanSend}
|
||||
controlFocus={controlFocus}
|
||||
visionConfig={visionConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import produce from 'immer'
|
||||
import { useGetState } from 'ahooks'
|
||||
import type { ConversationItem } from '@/types/app'
|
||||
|
||||
const storageConversationIdKey = 'conversationIdInfo'
|
||||
@ -7,7 +8,7 @@ const storageConversationIdKey = 'conversationIdInfo'
|
||||
type ConversationInfoType = Omit<ConversationItem, 'inputs' | 'id'>
|
||||
function useConversation() {
|
||||
const [conversationList, setConversationList] = useState<ConversationItem[]>([])
|
||||
const [currConversationId, doSetCurrConversationId] = useState<string>('-1')
|
||||
const [currConversationId, doSetCurrConversationId, getCurrConversationId] = useGetState<string>('-1')
|
||||
// when set conversation id, we do not have set appId
|
||||
const setCurrConversationId = (id: string, appId: string, isSetToLocalStroge = true, newConversationName = '') => {
|
||||
doSetCurrConversationId(id)
|
||||
@ -50,6 +51,7 @@ function useConversation() {
|
||||
conversationList,
|
||||
setConversationList,
|
||||
currConversationId,
|
||||
getCurrConversationId,
|
||||
setCurrConversationId,
|
||||
getConversationIdFromStorage,
|
||||
isNewConversation,
|
||||
|
||||
@ -5,6 +5,8 @@ import commonEn from './lang/common.en'
|
||||
import commonZh from './lang/common.zh'
|
||||
import appEn from './lang/app.en'
|
||||
import appZh from './lang/app.zh'
|
||||
import toolsEn from './lang/tools.en'
|
||||
import toolsZh from './lang/tools.zh'
|
||||
import type { Locale } from '.'
|
||||
|
||||
const resources = {
|
||||
@ -12,12 +14,16 @@ const resources = {
|
||||
translation: {
|
||||
common: commonEn,
|
||||
app: appEn,
|
||||
// tools
|
||||
tools: toolsEn,
|
||||
},
|
||||
},
|
||||
'zh-Hans': {
|
||||
translation: {
|
||||
common: commonZh,
|
||||
app: appZh,
|
||||
// tools
|
||||
tools: toolsZh,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -16,6 +16,17 @@ const translation = {
|
||||
lineBreak: 'Line break',
|
||||
like: 'like',
|
||||
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 = {
|
||||
lineBreak: '换行',
|
||||
like: '赞同',
|
||||
dislike: '反对',
|
||||
ok: '好的',
|
||||
},
|
||||
imageUploader: {
|
||||
uploadFromComputer: '从本地上传',
|
||||
uploadFromComputerReadError: '图片读取失败,请重新选择。',
|
||||
uploadFromComputerUploadError: '图片上传失败,请重新上传。',
|
||||
uploadFromComputerLimit: '上传图片不能超过 {{size}} MB',
|
||||
pasteImageLink: '粘贴图片链接',
|
||||
pasteImageLinkInputPlaceholder: '将图像链接粘贴到此处',
|
||||
pasteImageLinkInvalid: '图片链接无效',
|
||||
imageUpload: '图片上传',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
103
i18n/lang/tools.en.ts
Normal file
103
i18n/lang/tools.en.ts
Normal file
@ -0,0 +1,103 @@
|
||||
const translation = {
|
||||
title: 'Tools',
|
||||
createCustomTool: 'Create Custom Tool',
|
||||
type: {
|
||||
all: 'All',
|
||||
builtIn: 'Built-in',
|
||||
custom: 'Custom',
|
||||
},
|
||||
contribute: {
|
||||
line1: 'I\'m interested in ',
|
||||
line2: 'contributing tools to Dify.',
|
||||
viewGuide: 'View the guide',
|
||||
},
|
||||
author: 'By',
|
||||
auth: {
|
||||
unauthorized: 'To Authorize',
|
||||
authorized: 'Authorized',
|
||||
setup: 'Set up authorization to use',
|
||||
setupModalTitle: 'Set Up Authorization',
|
||||
setupModalTitleDescription: 'After configuring credentials, all members within the workspace can use this tool when orchestrating applications.',
|
||||
},
|
||||
includeToolNum: '{{num}} tools included',
|
||||
addTool: 'Add Tool',
|
||||
createTool: {
|
||||
title: 'Create Custom Tool',
|
||||
editAction: 'Configure',
|
||||
editTitle: 'Edit Custom Tool',
|
||||
name: 'Name',
|
||||
toolNamePlaceHolder: 'Enter the tool name',
|
||||
schema: 'Schema',
|
||||
schemaPlaceHolder: 'Enter your OpenAPI schema here',
|
||||
viewSchemaSpec: 'View the OpenAPI-Swagger Specification',
|
||||
importFromUrl: 'Import from URL',
|
||||
importFromUrlPlaceHolder: 'https://...',
|
||||
urlError: 'Please enter a valid URL',
|
||||
examples: 'Examples',
|
||||
exampleOptions: {
|
||||
json: 'Weather(JSON)',
|
||||
yaml: 'Pet Store(YAML)',
|
||||
blankTemplate: 'Blank Template',
|
||||
},
|
||||
availableTools: {
|
||||
title: 'Available Tools',
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
method: 'Method',
|
||||
path: 'Path',
|
||||
action: 'Actions',
|
||||
test: 'Test',
|
||||
},
|
||||
authMethod: {
|
||||
title: 'Authorization method',
|
||||
type: 'Authorization type',
|
||||
types: {
|
||||
none: 'None',
|
||||
api_key: 'API Key',
|
||||
},
|
||||
key: 'Key',
|
||||
value: 'Value',
|
||||
},
|
||||
privacyPolicy: 'Privacy policy',
|
||||
privacyPolicyPlaceholder: 'Please enter privacy policy',
|
||||
},
|
||||
test: {
|
||||
title: 'Test',
|
||||
parametersValue: 'Parameters & Value',
|
||||
parameters: 'Parameters',
|
||||
value: 'Value',
|
||||
testResult: 'Test Results',
|
||||
testResultPlaceholder: 'Test result will show here',
|
||||
},
|
||||
thought: {
|
||||
using: 'Using',
|
||||
used: 'Used',
|
||||
requestTitle: 'Request to',
|
||||
responseTitle: 'Response from',
|
||||
},
|
||||
setBuiltInTools: {
|
||||
info: 'Info',
|
||||
setting: 'Setting',
|
||||
toolDescription: 'Tool description',
|
||||
parameters: 'parameters',
|
||||
string: 'string',
|
||||
number: 'number',
|
||||
required: 'Required',
|
||||
infoAndSetting: 'Info & Settings',
|
||||
},
|
||||
noCustomTool: {
|
||||
title: 'No custom tools!',
|
||||
content: 'Add and manage your custom tools here for building AI apps.',
|
||||
createTool: 'Create Tool',
|
||||
},
|
||||
noSearchRes: {
|
||||
title: 'Sorry, no results!',
|
||||
content: 'We couldn\'t find any tools that match your search.',
|
||||
reset: 'Reset Search',
|
||||
},
|
||||
builtInPromptTitle: 'Prompt',
|
||||
toolRemoved: 'Tool removed',
|
||||
notAuthorized: 'Tool not authorized',
|
||||
}
|
||||
|
||||
export default translation
|
||||
95
i18n/lang/tools.zh.ts
Normal file
95
i18n/lang/tools.zh.ts
Normal file
@ -0,0 +1,95 @@
|
||||
const translation = {
|
||||
title: '工具',
|
||||
createCustomTool: '创建自定义工具',
|
||||
type: {
|
||||
all: '全部',
|
||||
builtIn: '内置',
|
||||
custom: '自定义',
|
||||
},
|
||||
contribute: {
|
||||
line1: '我有兴趣为 ',
|
||||
line2: 'Dify 贡献工具。',
|
||||
viewGuide: '查看指南',
|
||||
},
|
||||
author: '作者',
|
||||
auth: {
|
||||
unauthorized: '去授权',
|
||||
authorized: '已授权',
|
||||
setup: '要使用请先授权',
|
||||
setupModalTitle: '设置授权',
|
||||
setupModalTitleDescription: '配置凭据后,工作区中的所有成员都可以在编排应用程序时使用此工具。',
|
||||
},
|
||||
includeToolNum: '包含 {{num}} 个工具',
|
||||
addTool: '添加工具',
|
||||
createTool: {
|
||||
title: '创建自定义工具',
|
||||
editAction: '编辑',
|
||||
editTitle: '编辑自定义工具',
|
||||
name: '名称',
|
||||
toolNamePlaceHolder: '输入工具名称',
|
||||
schema: 'Schema',
|
||||
schemaPlaceHolder: '在此处输入您的 OpenAPI schema',
|
||||
viewSchemaSpec: '查看 OpenAPI-Swagger 规范',
|
||||
importFromUrl: '从 URL 中导入',
|
||||
importFromUrlPlaceHolder: 'https://...',
|
||||
urlError: '请输入有效的 URL',
|
||||
examples: '例子',
|
||||
exampleOptions: {
|
||||
json: '天气(JSON)',
|
||||
yaml: '宠物商店(YAML)',
|
||||
blankTemplate: '空白模版',
|
||||
},
|
||||
availableTools: {
|
||||
title: '可用工具',
|
||||
name: '名称',
|
||||
description: '描述',
|
||||
method: '方法',
|
||||
path: '路径',
|
||||
action: '操作',
|
||||
test: '测试',
|
||||
},
|
||||
authMethod: {
|
||||
title: '鉴权方法',
|
||||
type: '鉴权类型',
|
||||
types: {
|
||||
none: '无',
|
||||
api_key: 'API Key',
|
||||
},
|
||||
key: '键',
|
||||
value: '值',
|
||||
},
|
||||
privacyPolicy: '隐私协议',
|
||||
privacyPolicyPlaceholder: '请输入隐私协议',
|
||||
},
|
||||
thought: {
|
||||
using: '正在使用',
|
||||
used: '已使用',
|
||||
requestTitle: '请求来自',
|
||||
responseTitle: '响应来自',
|
||||
},
|
||||
setBuiltInTools: {
|
||||
info: '信息',
|
||||
setting: '设置',
|
||||
toolDescription: '工具描述',
|
||||
parameters: '参数',
|
||||
string: '字符串',
|
||||
number: '数字',
|
||||
required: '必填',
|
||||
infoAndSetting: '信息和设置',
|
||||
},
|
||||
noCustomTool: {
|
||||
title: '没有自定义工具!',
|
||||
content: '在此统一添加和管理你的自定义工具,方便构建应用时使用。',
|
||||
createTool: '创建工具',
|
||||
},
|
||||
noSearchRes: {
|
||||
title: '抱歉,没有结果!',
|
||||
content: '我们找不到任何与您的搜索相匹配的工具。',
|
||||
reset: '重置搜索',
|
||||
},
|
||||
builtInPromptTitle: '提示词',
|
||||
toolRemoved: '工具已被移除',
|
||||
notAuthorized: '工具未授权',
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -12,6 +12,7 @@
|
||||
"prepare": "husky install ./.husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.2",
|
||||
"@formatjs/intl-localematcher": "^0.2.32",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@heroicons/react": "^2.0.16",
|
||||
@ -26,7 +27,7 @@
|
||||
"axios": "^1.3.5",
|
||||
"classnames": "^2.3.2",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"dify-client": "2.0.0",
|
||||
"dify-client": "^2.2.0",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-next": "13.4.0",
|
||||
"eventsource-parser": "^1.0.0",
|
||||
@ -38,6 +39,7 @@
|
||||
"katex": "^0.16.7",
|
||||
"negotiator": "^0.6.3",
|
||||
"next": "^14.0.4",
|
||||
"rc-textarea": "^1.5.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.2",
|
||||
|
||||
137
service/base.ts
137
service/base.ts
@ -1,5 +1,7 @@
|
||||
import { API_PREFIX } from '@/config'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import type { AnnotationReply, MessageEnd, MessageReplace, ThoughtItem } from '@/app/components/chat/type'
|
||||
import type { VisionFile } from '@/types/app'
|
||||
|
||||
const TIME_OUT = 100000
|
||||
|
||||
@ -21,20 +23,35 @@ const baseOptions = {
|
||||
}
|
||||
|
||||
export type IOnDataMoreInfo = {
|
||||
conversationId: string | undefined
|
||||
conversationId?: string
|
||||
taskId?: string
|
||||
messageId: string
|
||||
errorMessage?: string
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
export type IOnData = (message: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => void
|
||||
export type IOnCompleted = () => void
|
||||
export type IOnError = (msg: string) => void
|
||||
export type IOnThought = (though: ThoughtItem) => void
|
||||
export type IOnFile = (file: VisionFile) => void
|
||||
export type IOnMessageEnd = (messageEnd: MessageEnd) => void
|
||||
export type IOnMessageReplace = (messageReplace: MessageReplace) => void
|
||||
export type IOnAnnotationReply = (messageReplace: AnnotationReply) => void
|
||||
export type IOnCompleted = (hasError?: boolean) => void
|
||||
export type IOnError = (msg: string, code?: string) => void
|
||||
|
||||
type IOtherOptions = {
|
||||
isPublicAPI?: boolean
|
||||
bodyStringify?: boolean
|
||||
needAllResponseContent?: boolean
|
||||
deleteContentType?: boolean
|
||||
onData?: IOnData // for stream
|
||||
onThought?: IOnThought
|
||||
onFile?: IOnFile
|
||||
onMessageEnd?: IOnMessageEnd
|
||||
onMessageReplace?: IOnMessageReplace
|
||||
onError?: IOnError
|
||||
onCompleted?: IOnCompleted // for stream
|
||||
getAbortController?: (abortController: AbortController) => void
|
||||
}
|
||||
|
||||
function unicodeToChar(text: string) {
|
||||
@ -43,17 +60,18 @@ function unicodeToChar(text: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const handleStream = (response: any, onData: IOnData, onCompleted?: IOnCompleted) => {
|
||||
const handleStream = (response: Response, onData: IOnData, onCompleted?: IOnCompleted, onThought?: IOnThought, onMessageEnd?: IOnMessageEnd, onMessageReplace?: IOnMessageReplace, onFile?: IOnFile) => {
|
||||
if (!response.ok)
|
||||
throw new Error('Network response was not ok')
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
let bufferObj: any
|
||||
let bufferObj: Record<string, any>
|
||||
let isFirstMessage = true
|
||||
function read() {
|
||||
reader.read().then((result: any) => {
|
||||
let hasError = false
|
||||
reader?.read().then((result: any) => {
|
||||
if (result.done) {
|
||||
onCompleted && onCompleted()
|
||||
return
|
||||
@ -62,26 +80,51 @@ const handleStream = (response: any, onData: IOnData, onCompleted?: IOnCompleted
|
||||
const lines = buffer.split('\n')
|
||||
try {
|
||||
lines.forEach((message) => {
|
||||
if (!message || !message.startsWith('data: '))
|
||||
return
|
||||
try {
|
||||
bufferObj = JSON.parse(message.substring(6)) // remove data: and parse as json
|
||||
} catch (e) {
|
||||
// mute handle message cut off
|
||||
onData('', isFirstMessage, {
|
||||
conversationId: bufferObj?.conversation_id,
|
||||
messageId: bufferObj?.id,
|
||||
})
|
||||
return
|
||||
if (message.startsWith('data: ')) { // check if it starts with data:
|
||||
try {
|
||||
bufferObj = JSON.parse(message.substring(6)) as Record<string, any>// remove data: and parse as json
|
||||
}
|
||||
catch (e) {
|
||||
// mute handle message cut off
|
||||
onData('', isFirstMessage, {
|
||||
conversationId: bufferObj?.conversation_id,
|
||||
messageId: bufferObj?.message_id,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (bufferObj.status === 400 || !bufferObj.event) {
|
||||
onData('', false, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
errorMessage: bufferObj?.message,
|
||||
errorCode: bufferObj?.code,
|
||||
})
|
||||
hasError = true
|
||||
onCompleted?.(true)
|
||||
return
|
||||
}
|
||||
if (bufferObj.event === 'message' || bufferObj.event === 'agent_message') {
|
||||
// can not use format here. Because message is splited.
|
||||
onData(unicodeToChar(bufferObj.answer), isFirstMessage, {
|
||||
conversationId: bufferObj.conversation_id,
|
||||
taskId: bufferObj.task_id,
|
||||
messageId: bufferObj.id,
|
||||
})
|
||||
isFirstMessage = false
|
||||
}
|
||||
else if (bufferObj.event === 'agent_thought') {
|
||||
onThought?.(bufferObj as ThoughtItem)
|
||||
}
|
||||
else if (bufferObj.event === 'message_file') {
|
||||
onFile?.(bufferObj as VisionFile)
|
||||
}
|
||||
else if (bufferObj.event === 'message_end') {
|
||||
onMessageEnd?.(bufferObj as MessageEnd)
|
||||
}
|
||||
else if (bufferObj.event === 'message_replace') {
|
||||
onMessageReplace?.(bufferObj as MessageReplace)
|
||||
}
|
||||
}
|
||||
if (bufferObj.event !== 'message')
|
||||
return
|
||||
|
||||
onData(unicodeToChar(bufferObj.answer), isFirstMessage, {
|
||||
conversationId: bufferObj.conversation_id,
|
||||
messageId: bufferObj.id,
|
||||
})
|
||||
isFirstMessage = false
|
||||
})
|
||||
buffer = lines[lines.length - 1]
|
||||
}
|
||||
@ -91,10 +134,12 @@ const handleStream = (response: any, onData: IOnData, onCompleted?: IOnCompleted
|
||||
messageId: '',
|
||||
errorMessage: `${e}`,
|
||||
})
|
||||
hasError = true
|
||||
onCompleted?.(true)
|
||||
return
|
||||
}
|
||||
|
||||
read()
|
||||
if (!hasError)
|
||||
read()
|
||||
})
|
||||
}
|
||||
read()
|
||||
@ -181,7 +226,39 @@ const baseFetch = (url: string, fetchOptions: any, { needAllResponseContent }: I
|
||||
])
|
||||
}
|
||||
|
||||
export const ssePost = (url: string, fetchOptions: any, { onData, onCompleted, onError }: IOtherOptions) => {
|
||||
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, onThought, onFile, onMessageEnd, onMessageReplace, onError }: IOtherOptions) => {
|
||||
const options = Object.assign({}, baseOptions, {
|
||||
method: 'POST',
|
||||
}, fetchOptions)
|
||||
@ -213,7 +290,7 @@ export const ssePost = (url: string, fetchOptions: any, { onData, onCompleted, o
|
||||
onData?.(str, isFirstMessage, moreInfo)
|
||||
}, () => {
|
||||
onCompleted?.()
|
||||
})
|
||||
}, onThought, onMessageEnd, onMessageReplace, onFile)
|
||||
}).catch((e) => {
|
||||
Toast.notify({ type: 'error', message: e })
|
||||
onError?.(e)
|
||||
|
||||
@ -1,22 +1,27 @@
|
||||
import type { IOnCompleted, IOnData, IOnError } from './base'
|
||||
import type { IOnCompleted, IOnData, IOnError, IOnFile, IOnMessageEnd, IOnMessageReplace, IOnThought } from './base'
|
||||
import { get, post, ssePost } from './base'
|
||||
import type { Feedbacktype } from '@/types/app'
|
||||
|
||||
export const sendChatMessage = async (body: Record<string, any>, { onData, onCompleted, onError }: {
|
||||
export const sendChatMessage = async (body: Record<string, any>, { onData, onCompleted, onThought, onFile, onError, getAbortController, onMessageEnd, onMessageReplace }: {
|
||||
onData: IOnData
|
||||
onCompleted: IOnCompleted
|
||||
onFile: IOnFile
|
||||
onThought: IOnThought
|
||||
onMessageEnd: IOnMessageEnd
|
||||
onMessageReplace: IOnMessageReplace
|
||||
onError: IOnError
|
||||
getAbortController?: (abortController: AbortController) => void
|
||||
}) => {
|
||||
return ssePost('chat-messages', {
|
||||
body: {
|
||||
...body,
|
||||
response_mode: 'streaming',
|
||||
},
|
||||
}, { onData, onCompleted, onError })
|
||||
}, { onData, onCompleted, onThought, onFile, onError, getAbortController, onMessageEnd, onMessageReplace })
|
||||
}
|
||||
|
||||
export const fetchConversations = async () => {
|
||||
return get('conversations', { params: { limit: 20, first_id: '' } })
|
||||
return get('conversations', { params: { limit: 100, first_id: '' } })
|
||||
}
|
||||
|
||||
export const fetchChatList = async (conversationId: string) => {
|
||||
@ -31,3 +36,7 @@ export const fetchAppParams = async () => {
|
||||
export const updateFeedback = async ({ url, body }: { url: string; body: Feedbacktype }) => {
|
||||
return post(url, { body })
|
||||
}
|
||||
|
||||
export const generationConversationName = async (id: string) => {
|
||||
return post(`conversations/${id}/name`, { body: { auto_generate: true } })
|
||||
}
|
||||
|
||||
47
types/app.ts
47
types/app.ts
@ -1,4 +1,6 @@
|
||||
import type { Annotation } from './log'
|
||||
import type { Locale } from '@/i18n'
|
||||
import type { ThoughtItem } from '@/app/components/chat/type'
|
||||
|
||||
export type PromptVariable = {
|
||||
key: string
|
||||
@ -74,9 +76,13 @@ export type IChatItem = {
|
||||
* More information about this message
|
||||
*/
|
||||
more?: MessageMore
|
||||
isIntroduction?: boolean
|
||||
annotation?: Annotation
|
||||
useCurrentUserAvatar?: boolean
|
||||
isOpeningStatement?: boolean
|
||||
suggestedQuestions?: string[]
|
||||
log?: { role: string; text: string }[]
|
||||
agent_thoughts?: ThoughtItem[]
|
||||
message_files?: VisionFile[]
|
||||
}
|
||||
|
||||
export type ResponseHolder = {}
|
||||
@ -95,3 +101,42 @@ export type AppInfo = {
|
||||
copyright?: 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
|
||||
belongs_to?: string
|
||||
}
|
||||
|
||||
5
types/base.ts
Normal file
5
types/base.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type TypeWithI18N<T = string> = {
|
||||
'en_US': T
|
||||
'zh_Hans': T
|
||||
[key: string]: T
|
||||
}
|
||||
16
types/log.ts
Normal file
16
types/log.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export type LogAnnotation = {
|
||||
content: string
|
||||
account: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type Annotation = {
|
||||
id: string
|
||||
authorName: string
|
||||
logAnnotation?: LogAnnotation
|
||||
created_at?: number
|
||||
}
|
||||
108
types/tools.ts
Normal file
108
types/tools.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import type { TypeWithI18N } from './base'
|
||||
export enum LOC {
|
||||
tools = 'tools',
|
||||
app = 'app',
|
||||
}
|
||||
|
||||
export enum AuthType {
|
||||
none = 'none',
|
||||
apiKey = 'api_key',
|
||||
}
|
||||
|
||||
export type Credential = {
|
||||
'auth_type': AuthType
|
||||
'api_key_header'?: string
|
||||
'api_key_value'?: string
|
||||
}
|
||||
|
||||
export enum CollectionType {
|
||||
all = 'all',
|
||||
builtIn = 'builtin',
|
||||
custom = 'api',
|
||||
}
|
||||
|
||||
export type Emoji = {
|
||||
background: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type Collection = {
|
||||
id: string
|
||||
name: string
|
||||
author: string
|
||||
description: TypeWithI18N
|
||||
icon: string | Emoji
|
||||
label: TypeWithI18N
|
||||
type: CollectionType
|
||||
team_credentials: Record<string, any>
|
||||
is_team_authorization: boolean
|
||||
allow_delete: boolean
|
||||
}
|
||||
|
||||
export type ToolParameter = {
|
||||
name: string
|
||||
label: TypeWithI18N
|
||||
human_description: TypeWithI18N
|
||||
type: string
|
||||
required: boolean
|
||||
default: string
|
||||
options?: {
|
||||
label: TypeWithI18N
|
||||
value: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type Tool = {
|
||||
name: string
|
||||
label: TypeWithI18N
|
||||
description: any
|
||||
parameters: ToolParameter[]
|
||||
}
|
||||
|
||||
export type ToolCredential = {
|
||||
name: string
|
||||
label: TypeWithI18N
|
||||
help: TypeWithI18N
|
||||
placeholder: TypeWithI18N
|
||||
type: string
|
||||
required: boolean
|
||||
default: string
|
||||
options?: {
|
||||
label: TypeWithI18N
|
||||
value: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type CustomCollectionBackend = {
|
||||
provider: string
|
||||
original_provider?: string
|
||||
credentials: Credential
|
||||
icon: Emoji
|
||||
schema_type: string
|
||||
schema: string
|
||||
privacy_policy: string
|
||||
tools?: ParamItem[]
|
||||
}
|
||||
|
||||
export type ParamItem = {
|
||||
name: string
|
||||
label: TypeWithI18N
|
||||
human_description: TypeWithI18N
|
||||
type: string
|
||||
required: boolean
|
||||
default: string
|
||||
min?: number
|
||||
max?: number
|
||||
options?: {
|
||||
label: TypeWithI18N
|
||||
value: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type CustomParamSchema = {
|
||||
operation_id: string // name
|
||||
summary: string
|
||||
server_url: string
|
||||
method: string
|
||||
parameters: ParamItem[]
|
||||
}
|
||||
26
utils/tools.ts
Normal file
26
utils/tools.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { ThoughtItem } from '@/app/components/chat/type'
|
||||
import type { VisionFile } from '@/types/app'
|
||||
|
||||
export const sortAgentSorts = (list: ThoughtItem[]) => {
|
||||
if (!list)
|
||||
return list
|
||||
if (list.some(item => item.position === undefined))
|
||||
return list
|
||||
const temp = [...list]
|
||||
temp.sort((a, b) => a.position - b.position)
|
||||
return temp
|
||||
}
|
||||
|
||||
export const addFileInfos = (list: ThoughtItem[], messageFiles: VisionFile[]) => {
|
||||
if (!list || !messageFiles)
|
||||
return list
|
||||
return list.map((item) => {
|
||||
if (item.files && item.files?.length > 0) {
|
||||
return {
|
||||
...item,
|
||||
message_files: item.files.map(fileId => messageFiles.find(file => file.id === fileId)) as VisionFile[],
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user