Compare commits

..

13 Commits

Author SHA1 Message Date
1df62f49f1 Merge branch 'main' into chore/update-nextjs 2024-04-19 10:36:28 +08:00
0ddaa92a12 Merge pull request #53 from Yefori-Go/patch-1
docs: fix typo in readme
2024-04-19 10:27:41 +08:00
65f90f56ac chore: update nextjs version 2024-04-19 09:05:39 +08:00
924a008de8 docs: fix typo in readme
Fix typo in README.md
`Conversion` -> `Conversation`
2024-04-03 13:41:33 +08:00
631a4f1f21 Merge pull request #42 from langgenius/feat/support-agent
feat: support agent
2024-01-29 16:05:51 +08:00
25ba4ac476 feat: show agent show 2024-01-29 16:03:50 +08:00
83695999ea feat: conversation support show thought 2024-01-29 13:33:15 +08:00
b62e34c6fa feat: support output agent info 2024-01-29 13:00:14 +08:00
2019d8f3e3 feat: change service code 2024-01-29 10:27:55 +08:00
ed839618a7 feat: move anwser related code 2024-01-26 18:06:04 +08:00
3de8abdd7a Merge pull request #40 from langgenius/chore/split-question-answer-comp
chore: split question and and answer to components
2024-01-26 16:44:33 +08:00
4a75e3774a chore: split question and and answer to components 2024-01-26 16:41:16 +08:00
c8f730208d Merge pull request #33 from langgenius/feat/support-upload-image
feat: support upload image
2023-11-22 17:18:22 +08:00
32 changed files with 1455 additions and 258 deletions

View File

@ -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

View 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)
}

View File

@ -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([])
}
}

View File

@ -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

View File

@ -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;
}
}

View 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"
}

View 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

View 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"
}

View 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

View File

@ -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"
}

View 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 = 'CheckCircle'
export default Icon

View 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)

View File

@ -2,23 +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, VisionFile, VisionSettings } 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 { Markdown } from '@/app/components/base/markdown'
import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
import ImageList from '@/app/components/base/image-uploader/image-list'
import { useImageFiles } from '@/app/components/base/image-uploader/hooks'
import ImageGallery from '@/app/components/base/image-gallery'
export type FeedbackFunc = (messageId: string, feedback: Feedbacktype) => Promise<any>
export type IChatProps = {
chatList: IChatItem[]
@ -60,190 +56,6 @@ export type IChatItem = {
message_files?: VisionFile[]
}
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'> & {
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>
)
}
const Chat: FC<IChatProps> = ({
chatList,
feedbackDisabled = false,

View 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)

View 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)

View 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)

View 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);
}

View 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
View 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
}

View File

@ -3,16 +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 { fetchAppParams, fetchChatList, fetchConversations, generationConversationName, sendChatMessage, updateFeedback } from '@/service'
import type { ConversationItem, Feedbacktype, IChatItem, PromptConfig, VisionFile, VisionSettings } from '@/types/app'
import { TransferMethod } 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'
@ -20,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()
@ -36,13 +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>(undefined)
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
*/
@ -50,6 +65,7 @@ const Main: FC = () => {
conversationList,
setConversationList,
currConversationId,
getCurrConversationId,
setCurrConversationId,
getConversationIdFromStorage,
isNewConversation,
@ -115,13 +131,16 @@ const Main: FC = () => {
id: `question-${item.id}`,
content: item.query,
isAnswer: false,
message_files: item.message_files,
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)
@ -244,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 })
@ -267,6 +287,36 @@ const Main: FC = () => {
return true
}
const [controlFocus, setControlFocus] = useState(0)
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') })
@ -309,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) => {
@ -336,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()

View File

@ -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,

View File

@ -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,
},
},
}

103
i18n/lang/tools.en.ts Normal file
View 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
View 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

View File

@ -27,7 +27,7 @@
"axios": "^1.3.5",
"classnames": "^2.3.2",
"copy-to-clipboard": "^3.3.3",
"dify-client": "^2.1.0",
"dify-client": "^2.2.0",
"eslint": "8.36.0",
"eslint-config-next": "13.4.0",
"eventsource-parser": "^1.0.0",
@ -38,7 +38,7 @@
"js-cookie": "^3.0.1",
"katex": "^0.16.7",
"negotiator": "^0.6.3",
"next": "13.4.0",
"next": "^14.0.4",
"rc-textarea": "^1.5.3",
"react": "18.2.0",
"react-dom": "18.2.0",

View File

@ -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,27 +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
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)
}
}
catch (e) {
// mute handle message cut off
onData('', isFirstMessage, {
conversationId: bufferObj?.conversation_id,
messageId: bufferObj?.id,
})
return
}
if (bufferObj.event !== 'message')
return
onData(unicodeToChar(bufferObj.answer), isFirstMessage, {
conversationId: bufferObj.conversation_id,
messageId: bufferObj.id,
})
isFirstMessage = false
})
buffer = lines[lines.length - 1]
}
@ -92,10 +134,12 @@ const handleStream = (response: any, onData: IOnData, onCompleted?: IOnCompleted
messageId: '',
errorMessage: `${e}`,
})
hasError = true
onCompleted?.(true)
return
}
read()
if (!hasError)
read()
})
}
read()
@ -214,7 +258,7 @@ export const upload = (fetchOptions: any): Promise<any> => {
})
}
export const ssePost = (url: string, fetchOptions: any, { onData, onCompleted, onError }: IOtherOptions) => {
export const ssePost = (url: string, fetchOptions: any, { onData, onCompleted, onThought, onFile, onMessageEnd, onMessageReplace, onError }: IOtherOptions) => {
const options = Object.assign({}, baseOptions, {
method: 'POST',
}, fetchOptions)
@ -246,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)

View File

@ -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 } })
}

View File

@ -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,12 @@ 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[]
}
@ -133,4 +138,5 @@ export type VisionFile = {
transfer_method: TransferMethod
url: string
upload_file_id: string
belongs_to?: string
}

5
types/base.ts Normal file
View 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
View 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
View 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
View 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
})
}