dorkai commited on
Commit
a9df3bb
·
1 Parent(s): 85ac151

Add app files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .editorconfig +22 -0
  2. .eslintrc.json +28 -0
  3. .gitignore +49 -0
  4. .vscode/launch.json +28 -0
  5. .vscode/settings.json +32 -0
  6. DEVELOPER.md +67 -0
  7. Dockerfile +12 -0
  8. LICENSE +21 -0
  9. README.md +4 -3
  10. app/api/chat-messages/route.ts +17 -0
  11. app/api/conversations/route.ts +11 -0
  12. app/api/messages/[messageId]/feedbacks/route.ts +16 -0
  13. app/api/messages/route.ts +13 -0
  14. app/api/parameters/route.ts +11 -0
  15. app/api/utils/common.ts +21 -0
  16. app/api/utils/stream.ts +25 -0
  17. app/components/app-unavailable.tsx +31 -0
  18. app/components/base/app-icon/index.tsx +36 -0
  19. app/components/base/app-icon/style.module.css +15 -0
  20. app/components/base/auto-height-textarea/index.tsx +73 -0
  21. app/components/base/button/index.tsx +44 -0
  22. app/components/base/loading/index.tsx +31 -0
  23. app/components/base/loading/style.css +41 -0
  24. app/components/base/markdown.tsx +45 -0
  25. app/components/base/select/index.tsx +216 -0
  26. app/components/base/spinner/index.tsx +24 -0
  27. app/components/base/toast/index.tsx +131 -0
  28. app/components/base/toast/style.module.css +43 -0
  29. app/components/base/tooltip/index.tsx +46 -0
  30. app/components/chat/icons/answer.svg +3 -0
  31. app/components/chat/icons/default-avatar.jpg +0 -0
  32. app/components/chat/icons/edit.svg +3 -0
  33. app/components/chat/icons/question.svg +3 -0
  34. app/components/chat/icons/robot.svg +10 -0
  35. app/components/chat/icons/send-active.svg +3 -0
  36. app/components/chat/icons/send.svg +3 -0
  37. app/components/chat/icons/typing.svg +19 -0
  38. app/components/chat/icons/user.svg +10 -0
  39. app/components/chat/index.tsx +353 -0
  40. app/components/chat/loading-anim/index.tsx +16 -0
  41. app/components/chat/loading-anim/style.module.css +82 -0
  42. app/components/chat/style.module.css +91 -0
  43. app/components/config-scence/index.tsx +13 -0
  44. app/components/header.tsx +48 -0
  45. app/components/index.tsx +433 -0
  46. app/components/sidebar/card.module.css +3 -0
  47. app/components/sidebar/card.tsx +19 -0
  48. app/components/sidebar/index.tsx +87 -0
  49. app/components/value-panel/index.tsx +79 -0
  50. app/components/value-panel/style.module.css +3 -0
.editorconfig ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EditorConfig is awesome: https://EditorConfig.org
2
+
3
+ # top-most EditorConfig file
4
+ root = true
5
+
6
+ # Unix-style newlines with a newline ending every file
7
+ [*]
8
+ end_of_line = lf
9
+ insert_final_newline = true
10
+
11
+ # Matches multiple files with brace expansion notation
12
+ # Set default charset
13
+ [*.{js,tsx}]
14
+ charset = utf-8
15
+ indent_style = space
16
+ indent_size = 2
17
+
18
+
19
+ # Matches the exact files either package.json or .travis.yml
20
+ [{package.json,.travis.yml}]
21
+ indent_style = space
22
+ indent_size = 2
.eslintrc.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": [
3
+ "@antfu",
4
+ "plugin:react-hooks/recommended"
5
+ ],
6
+ "rules": {
7
+ "@typescript-eslint/consistent-type-definitions": [
8
+ "error",
9
+ "type"
10
+ ],
11
+ "no-console": "off",
12
+ "indent": "off",
13
+ "@typescript-eslint/indent": [
14
+ "error",
15
+ 2,
16
+ {
17
+ "SwitchCase": 1,
18
+ "flatTernaryExpressions": false,
19
+ "ignoredNodes": [
20
+ "PropertyDefinition[decorators]",
21
+ "TSUnionType",
22
+ "FunctionExpression[params]:has(Identifier[decorators])"
23
+ ]
24
+ }
25
+ ],
26
+ "react-hooks/exhaustive-deps": "warn"
27
+ }
28
+ }
.gitignore ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+
8
+ # testing
9
+ /coverage
10
+
11
+ # next.js
12
+ /.next/
13
+ /out/
14
+
15
+ # production
16
+ /build
17
+
18
+ # misc
19
+ .DS_Store
20
+ *.pem
21
+
22
+ # debug
23
+ npm-debug.log*
24
+ yarn-debug.log*
25
+ yarn-error.log*
26
+ .pnpm-debug.log*
27
+
28
+ # local env files
29
+ .env*.local
30
+
31
+ # vercel
32
+ .vercel
33
+
34
+ # typescript
35
+ *.tsbuildinfo
36
+ next-env.d.ts
37
+
38
+ # npm
39
+ package-lock.json
40
+
41
+ # yarn
42
+ .pnp.cjs
43
+ .pnp.loader.mjs
44
+ .yarn/
45
+ yarn.lock
46
+ .yarnrc.yml
47
+
48
+ # pmpm
49
+ pnpm-lock.yaml
.vscode/launch.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "0.1.0",
3
+ "configurations": [
4
+ {
5
+ "name": "Next.js: debug server-side",
6
+ "type": "node-terminal",
7
+ "request": "launch",
8
+ "command": "npm run dev"
9
+ },
10
+ {
11
+ "name": "Next.js: debug client-side",
12
+ "type": "chrome",
13
+ "request": "launch",
14
+ "url": "http://localhost:3000"
15
+ },
16
+ {
17
+ "name": "Next.js: debug full stack",
18
+ "type": "node-terminal",
19
+ "request": "launch",
20
+ "command": "npm run dev",
21
+ "serverReadyAction": {
22
+ "pattern": "started server on .+, url: (https?://.+)",
23
+ "uriFormat": "%s",
24
+ "action": "debugWithChrome"
25
+ }
26
+ }
27
+ ]
28
+ }
.vscode/settings.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "typescript.tsdk": ".yarn/cache/typescript-patch-72dc6f164f-ab417a2f39.zip/node_modules/typescript/lib",
3
+ "typescript.enablePromptUseWorkspaceTsdk": true,
4
+ "prettier.enable": false,
5
+ "editor.formatOnSave": true,
6
+ "editor.codeActionsOnSave": {
7
+ "source.fixAll.eslint": true
8
+ },
9
+ "[python]": {
10
+ "editor.formatOnType": true
11
+ },
12
+ "[html]": {
13
+ "editor.defaultFormatter": "vscode.html-language-features"
14
+ },
15
+ "[typescriptreact]": {
16
+ "editor.defaultFormatter": "vscode.typescript-language-features"
17
+ },
18
+ "[javascript]": {
19
+ "editor.defaultFormatter": "vscode.typescript-language-features"
20
+ },
21
+ "[javascriptreact]": {
22
+ "editor.defaultFormatter": "vscode.typescript-language-features"
23
+ },
24
+ "[jsonc]": {
25
+ "editor.defaultFormatter": "vscode.json-language-features"
26
+ },
27
+ "i18n-ally.localesPaths": [
28
+ "i18n",
29
+ "i18n/lang",
30
+ "app/api/messages"
31
+ ]
32
+ }
DEVELOPER.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Conversion Web App Template
2
+ 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).
3
+
4
+ ## Config App
5
+ Config app in `config/index.ts`.Please config:
6
+ - APP_ID
7
+ - API_KEY
8
+
9
+ More config:
10
+ ```js
11
+ export const APP_INFO: AppInfo = {
12
+ "title": 'Chat APP',
13
+ "description": '',
14
+ "copyright": '',
15
+ "privacy_policy": '',
16
+ "default_language": 'zh-Hans'
17
+ }
18
+
19
+ export const isShowPrompt = true
20
+ export const promptTemplate = ''
21
+ ```
22
+
23
+ ## Getting Started
24
+ First, install dependencies:
25
+ ```bash
26
+ npm install
27
+ # or
28
+ yarn
29
+ # or
30
+ pnpm install
31
+ ```
32
+
33
+ Then, run the development server:
34
+
35
+ ```bash
36
+ npm run dev
37
+ # or
38
+ yarn dev
39
+ # or
40
+ pnpm dev
41
+ ```
42
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
43
+
44
+ ## Using Docker
45
+
46
+ ```
47
+ docker build . -t <DOCKER_HUB_REPO>/webapp-conversation:latest
48
+ # now you can access it in port 3000
49
+ docker run -p 3000:3000 <DOCKER_HUB_REPO>/webapp-conversation:latest
50
+ ```
51
+
52
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
53
+
54
+ ## Learn More
55
+
56
+ To learn more about Next.js, take a look at the following resources:
57
+
58
+ - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
59
+ - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
60
+
61
+ You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
62
+
63
+ ## Deploy on Vercel
64
+
65
+ The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
66
+
67
+ Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM --platform=linux/amd64 node:19-bullseye-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY . .
6
+
7
+ RUN yarn install
8
+ RUN yarn build
9
+
10
+ EXPOSE 3000
11
+
12
+ CMD ["yarn","start"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2023 DorkAI Networking
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,10 +1,11 @@
1
  ---
2
  title: ChatUIPro
3
- emoji: 📉
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: ChatUIPro
3
+ emoji: 🐨
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: docker
7
  pinned: false
8
+ license: openrail
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app/api/chat-messages/route.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type NextRequest } from 'next/server'
2
+ import { getInfo, client } from '@/app/api/utils/common'
3
+ import { OpenAIStream } from '@/app/api/utils/stream'
4
+
5
+ export async function POST(request: NextRequest) {
6
+ const body = await request.json()
7
+ const {
8
+ inputs,
9
+ query,
10
+ conversation_id: conversationId,
11
+ response_mode: responseMode
12
+ } = body
13
+ const { user } = getInfo(request);
14
+ const res = await client.createChatMessage(inputs, query, user, responseMode, conversationId)
15
+ const stream = await OpenAIStream(res as any)
16
+ return new Response(stream as any)
17
+ }
app/api/conversations/route.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type NextRequest } from 'next/server'
2
+ import { NextResponse } from 'next/server'
3
+ import { getInfo, setSession, client } from '@/app/api/utils/common'
4
+
5
+ export async function GET(request: NextRequest) {
6
+ const { sessionId, user } = getInfo(request);
7
+ const { data }: any = await client.getConversations(user);
8
+ return NextResponse.json(data, {
9
+ headers: setSession(sessionId)
10
+ })
11
+ }
app/api/messages/[messageId]/feedbacks/route.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type NextRequest } from 'next/server'
2
+ import { NextResponse } from 'next/server'
3
+ import { getInfo, client } from '@/app/api/utils/common'
4
+
5
+ export async function POST(request: NextRequest, { params }: {
6
+ params: { messageId: string }
7
+ }) {
8
+ const body = await request.json()
9
+ const {
10
+ rating
11
+ } = body
12
+ const { messageId } = params
13
+ const { user } = getInfo(request);
14
+ const { data } = await client.messageFeedback(messageId, rating, user)
15
+ return NextResponse.json(data)
16
+ }
app/api/messages/route.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type NextRequest } from 'next/server'
2
+ import { NextResponse } from 'next/server'
3
+ import { getInfo, setSession, client } from '@/app/api/utils/common'
4
+
5
+ export async function GET(request: NextRequest) {
6
+ const { sessionId, user } = getInfo(request);
7
+ const { searchParams } = new URL(request.url);
8
+ const conversationId = searchParams.get('conversation_id')
9
+ const { data }: any = await client.getConversationMessages(user, conversationId as string);
10
+ return NextResponse.json(data, {
11
+ headers: setSession(sessionId)
12
+ })
13
+ }
app/api/parameters/route.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type NextRequest } from 'next/server'
2
+ import { NextResponse } from 'next/server'
3
+ import { getInfo, setSession, client } from '@/app/api/utils/common'
4
+
5
+ export async function GET(request: NextRequest) {
6
+ const { sessionId, user } = getInfo(request);
7
+ const { data } = await client.getApplicationParameters(user);
8
+ return NextResponse.json(data as object, {
9
+ headers: setSession(sessionId)
10
+ })
11
+ }
app/api/utils/common.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type NextRequest } from 'next/server'
2
+ import { APP_ID, API_KEY, API_URL } from '@/config'
3
+ import { ChatClient } from 'dify-client'
4
+ import { v4 } from 'uuid'
5
+
6
+ const userPrefix = `user_${APP_ID}:`;
7
+
8
+ export const getInfo = (request: NextRequest) => {
9
+ const sessionId = request.cookies.get('session_id')?.value || v4();
10
+ const user = userPrefix + sessionId;
11
+ return {
12
+ sessionId,
13
+ user
14
+ }
15
+ }
16
+
17
+ export const setSession = (sessionId: string) => {
18
+ return { 'Set-Cookie': `session_id=${sessionId}` }
19
+ }
20
+
21
+ export const client = new ChatClient(API_KEY, API_URL ? API_URL : undefined)
app/api/utils/stream.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export async function OpenAIStream(res: { body: any }) {
2
+ const reader = res.body.getReader();
3
+
4
+ const stream = new ReadableStream({
5
+ // https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams
6
+ // https://github.com/whichlight/chatgpt-api-streaming/blob/master/pages/api/OpenAIStream.ts
7
+ start(controller) {
8
+ return pump();
9
+ function pump() {
10
+ return reader.read().then(({ done, value }: any) => {
11
+ // When no more data needs to be consumed, close the stream
12
+ if (done) {
13
+ controller.close();
14
+ return;
15
+ }
16
+ // Enqueue the next data chunk into our target stream
17
+ controller.enqueue(value);
18
+ return pump();
19
+ });
20
+ }
21
+ },
22
+ });
23
+
24
+ return stream;
25
+ }
app/components/app-unavailable.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import type { FC } from 'react'
3
+ import React from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+
6
+ type IAppUnavailableProps = {
7
+ isUnknwonReason: boolean
8
+ errMessage?: string
9
+ }
10
+
11
+ const AppUnavailable: FC<IAppUnavailableProps> = ({
12
+ isUnknwonReason,
13
+ errMessage,
14
+ }) => {
15
+ const { t } = useTranslation()
16
+ let message = errMessage
17
+ if (!errMessage) {
18
+ message = (isUnknwonReason ? t('app.common.appUnkonwError') : t('app.common.appUnavailable')) as string
19
+ }
20
+
21
+ return (
22
+ <div className='flex items-center justify-center w-screen h-screen'>
23
+ <h1 className='mr-5 h-[50px] leading-[50px] pr-5 text-[24px] font-medium'
24
+ style={{
25
+ borderRight: '1px solid rgba(0,0,0,.3)',
26
+ }}>{(errMessage || isUnknwonReason) ? 500 : 404}</h1>
27
+ <div className='text-sm'>{message}</div>
28
+ </div>
29
+ )
30
+ }
31
+ export default React.memo(AppUnavailable)
app/components/base/app-icon/index.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FC } from 'react'
2
+ import classNames from 'classnames'
3
+ import style from './style.module.css'
4
+
5
+ export type AppIconProps = {
6
+ size?: 'tiny' | 'small' | 'medium' | 'large'
7
+ rounded?: boolean
8
+ icon?: string
9
+ background?: string
10
+ className?: string
11
+ }
12
+
13
+ const AppIcon: FC<AppIconProps> = ({
14
+ size = 'medium',
15
+ rounded = false,
16
+ background,
17
+ className,
18
+ }) => {
19
+ return (
20
+ <span
21
+ className={classNames(
22
+ style.appIcon,
23
+ size !== 'medium' && style[size],
24
+ rounded && style.rounded,
25
+ className ?? '',
26
+ )}
27
+ style={{
28
+ background,
29
+ }}
30
+ >
31
+ 🤖
32
+ </span>
33
+ )
34
+ }
35
+
36
+ export default AppIcon
app/components/base/app-icon/style.module.css ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .appIcon {
2
+ @apply flex items-center justify-center relative w-9 h-9 text-lg bg-teal-100 rounded-lg grow-0 shrink-0;
3
+ }
4
+ .appIcon.large {
5
+ @apply w-10 h-10;
6
+ }
7
+ .appIcon.small {
8
+ @apply w-8 h-8;
9
+ }
10
+ .appIcon.tiny {
11
+ @apply w-6 h-6 text-base;
12
+ }
13
+ .appIcon.rounded {
14
+ @apply rounded-full;
15
+ }
app/components/base/auto-height-textarea/index.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { forwardRef, useEffect, useRef } from 'react'
2
+ import cn from 'classnames'
3
+
4
+ type IProps = {
5
+ placeholder?: string
6
+ value: string
7
+ onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
8
+ className?: string
9
+ minHeight?: number
10
+ maxHeight?: number
11
+ autoFocus?: boolean
12
+ controlFocus?: number
13
+ onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
14
+ onKeyUp?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
15
+ }
16
+
17
+ const AutoHeightTextarea = forwardRef(
18
+ (
19
+ { value, onChange, placeholder, className, minHeight = 36, maxHeight = 96, autoFocus, controlFocus, onKeyDown, onKeyUp }: IProps,
20
+ outerRef: any,
21
+ ) => {
22
+ const ref = outerRef || useRef<HTMLTextAreaElement>(null)
23
+
24
+ const doFocus = () => {
25
+ if (ref.current) {
26
+ ref.current.setSelectionRange(value.length, value.length)
27
+ ref.current.focus()
28
+ return true
29
+ }
30
+ return false
31
+ }
32
+
33
+ const focus = () => {
34
+ if (!doFocus()) {
35
+ let hasFocus = false
36
+ const runId = setInterval(() => {
37
+ hasFocus = doFocus()
38
+ if (hasFocus)
39
+ clearInterval(runId)
40
+ }, 100)
41
+ }
42
+ }
43
+
44
+ useEffect(() => {
45
+ if (autoFocus)
46
+ focus()
47
+ }, [])
48
+ useEffect(() => {
49
+ if (controlFocus)
50
+ focus()
51
+ }, [controlFocus])
52
+
53
+ return (
54
+ <div className='relative'>
55
+ <div className={cn(className, 'invisible whitespace-pre-wrap break-all overflow-y-auto')} style={{ minHeight, maxHeight }}>
56
+ {!value ? placeholder : value.replace(/\n$/, '\n ')}
57
+ </div>
58
+ <textarea
59
+ ref={ref}
60
+ autoFocus={autoFocus}
61
+ className={cn(className, 'absolute inset-0 resize-none overflow-hidden')}
62
+ placeholder={placeholder}
63
+ onChange={onChange}
64
+ onKeyDown={onKeyDown}
65
+ onKeyUp={onKeyUp}
66
+ value={value}
67
+ />
68
+ </div>
69
+ )
70
+ },
71
+ )
72
+
73
+ export default AutoHeightTextarea
app/components/base/button/index.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FC, MouseEventHandler } from 'react'
2
+ import React from 'react'
3
+ import Spinner from '@/app/components/base/spinner'
4
+
5
+ export type IButtonProps = {
6
+ type?: string
7
+ className?: string
8
+ disabled?: boolean
9
+ loading?: boolean
10
+ children: React.ReactNode
11
+ onClick?: MouseEventHandler<HTMLDivElement>
12
+ }
13
+
14
+ const Button: FC<IButtonProps> = ({
15
+ type,
16
+ disabled,
17
+ children,
18
+ className,
19
+ onClick,
20
+ loading = false,
21
+ }) => {
22
+ let style = 'cursor-pointer'
23
+ switch (type) {
24
+ case 'primary':
25
+ style = (disabled || loading) ? 'bg-primary-600/75 cursor-not-allowed text-white' : 'bg-primary-600 hover:bg-primary-600/75 hover:shadow-md cursor-pointer text-white hover:shadow-sm'
26
+ break
27
+ default:
28
+ style = disabled ? 'border-solid border border-gray-200 bg-gray-200 cursor-not-allowed text-gray-800' : 'border-solid border border-gray-200 cursor-pointer text-gray-500 hover:bg-white hover:shadow-sm hover:border-gray-300'
29
+ break
30
+ }
31
+
32
+ return (
33
+ <div
34
+ className={`flex justify-center items-center content-center h-9 leading-5 rounded-lg px-4 py-2 text-base ${style} ${className && className}`}
35
+ onClick={disabled ? undefined : onClick}
36
+ >
37
+ {children}
38
+ {/* Spinner is hidden when loading is false */}
39
+ <Spinner loading={loading} className='!text-white !h-3 !w-3 !border-2 !ml-1' />
40
+ </div>
41
+ )
42
+ }
43
+
44
+ export default React.memo(Button)
app/components/base/loading/index.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ import './style.css'
4
+
5
+ type ILoadingProps = {
6
+ type?: 'area' | 'app'
7
+ }
8
+ const Loading = (
9
+ { type = 'area' }: ILoadingProps = { type: 'area' },
10
+ ) => {
11
+ return (
12
+ <div className={`flex w-full justify-center items-center ${type === 'app' ? 'h-full' : ''}`}>
13
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className='spin-animation'>
14
+ <g clipPath="url(#clip0_324_2488)">
15
+ <path d="M15 0H10C9.44772 0 9 0.447715 9 1V6C9 6.55228 9.44772 7 10 7H15C15.5523 7 16 6.55228 16 6V1C16 0.447715 15.5523 0 15 0Z" fill="#1C64F2" />
16
+ <path opacity="0.5" d="M15 9H10C9.44772 9 9 9.44772 9 10V15C9 15.5523 9.44772 16 10 16H15C15.5523 16 16 15.5523 16 15V10C16 9.44772 15.5523 9 15 9Z" fill="#1C64F2" />
17
+ <path opacity="0.1" d="M6 9H1C0.447715 9 0 9.44772 0 10V15C0 15.5523 0.447715 16 1 16H6C6.55228 16 7 15.5523 7 15V10C7 9.44772 6.55228 9 6 9Z" fill="#1C64F2" />
18
+ <path opacity="0.2" d="M6 0H1C0.447715 0 0 0.447715 0 1V6C0 6.55228 0.447715 7 1 7H6C6.55228 7 7 6.55228 7 6V1C7 0.447715 6.55228 0 6 0Z" fill="#1C64F2" />
19
+ </g>
20
+ <defs>
21
+ <clipPath id="clip0_324_2488">
22
+ <rect width="16" height="16" fill="white" />
23
+ </clipPath>
24
+ </defs>
25
+ </svg>
26
+
27
+ </div>
28
+ )
29
+ }
30
+
31
+ export default Loading
app/components/base/loading/style.css ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .spin-animation path {
2
+ animation: custom 2s linear infinite;
3
+ }
4
+
5
+ @keyframes custom {
6
+ 0% {
7
+ opacity: 0;
8
+ }
9
+
10
+ 25% {
11
+ opacity: 0.1;
12
+ }
13
+
14
+ 50% {
15
+ opacity: 0.2;
16
+ }
17
+
18
+ 75% {
19
+ opacity: 0.5;
20
+ }
21
+
22
+ 100% {
23
+ opacity: 1;
24
+ }
25
+ }
26
+
27
+ .spin-animation path:nth-child(1) {
28
+ animation-delay: 0s;
29
+ }
30
+
31
+ .spin-animation path:nth-child(2) {
32
+ animation-delay: 0.5s;
33
+ }
34
+
35
+ .spin-animation path:nth-child(3) {
36
+ animation-delay: 1s;
37
+ }
38
+
39
+ .spin-animation path:nth-child(4) {
40
+ animation-delay: 1.5s;
41
+ }
app/components/base/markdown.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ReactMarkdown from 'react-markdown'
2
+ import 'katex/dist/katex.min.css'
3
+ import RemarkMath from 'remark-math'
4
+ import RemarkBreaks from 'remark-breaks'
5
+ import RehypeKatex from 'rehype-katex'
6
+ import RemarkGfm from 'remark-gfm'
7
+ import SyntaxHighlighter from 'react-syntax-highlighter'
8
+ import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
9
+
10
+ export function Markdown(props: { content: string }) {
11
+ return (
12
+ <div className="markdown-body">
13
+ <ReactMarkdown
14
+ remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
15
+ rehypePlugins={[
16
+ RehypeKatex,
17
+ ]}
18
+ components={{
19
+ code({ node, inline, className, children, ...props }) {
20
+ const match = /language-(\w+)/.exec(className || '')
21
+ return !inline && match
22
+ ? (
23
+ <SyntaxHighlighter
24
+ {...props}
25
+ children={String(children).replace(/\n$/, '')}
26
+ style={atelierHeathLight}
27
+ language={match[1]}
28
+ showLineNumbers
29
+ PreTag="div"
30
+ />
31
+ )
32
+ : (
33
+ <code {...props} className={className}>
34
+ {children}
35
+ </code>
36
+ )
37
+ },
38
+ }}
39
+ linkTarget={'_blank'}
40
+ >
41
+ {props.content}
42
+ </ReactMarkdown>
43
+ </div>
44
+ )
45
+ }
app/components/base/select/index.tsx ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import type { FC } from 'react'
3
+ import React, { Fragment, useEffect, useState } from 'react'
4
+ import { Combobox, Listbox, Transition } from '@headlessui/react'
5
+ import classNames from 'classnames'
6
+ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/20/solid'
7
+
8
+ const defaultItems = [
9
+ { value: 1, name: 'option1' },
10
+ { value: 2, name: 'option2' },
11
+ { value: 3, name: 'option3' },
12
+ { value: 4, name: 'option4' },
13
+ { value: 5, name: 'option5' },
14
+ { value: 6, name: 'option6' },
15
+ { value: 7, name: 'option7' },
16
+ ]
17
+
18
+ export type Item = {
19
+ value: number | string
20
+ name: string
21
+ }
22
+
23
+ export type ISelectProps = {
24
+ className?: string
25
+ items?: Item[]
26
+ defaultValue?: number | string
27
+ disabled?: boolean
28
+ onSelect: (value: Item) => void
29
+ allowSearch?: boolean
30
+ bgClassName?: string
31
+ }
32
+ const Select: FC<ISelectProps> = ({
33
+ className,
34
+ items = defaultItems,
35
+ defaultValue = 1,
36
+ disabled = false,
37
+ onSelect,
38
+ allowSearch = true,
39
+ bgClassName = 'bg-gray-100',
40
+ }) => {
41
+ const [query, setQuery] = useState('')
42
+ const [open, setOpen] = useState(false)
43
+
44
+ const [selectedItem, setSelectedItem] = useState<Item | null>(null)
45
+ useEffect(() => {
46
+ let defaultSelect = null
47
+ const existed = items.find((item: Item) => item.value === defaultValue)
48
+ if (existed)
49
+ defaultSelect = existed
50
+
51
+ setSelectedItem(defaultSelect)
52
+ }, [defaultValue])
53
+
54
+ const filteredItems: Item[]
55
+ = query === ''
56
+ ? items
57
+ : items.filter((item) => {
58
+ return item.name.toLowerCase().includes(query.toLowerCase())
59
+ })
60
+
61
+ return (
62
+ <Combobox
63
+ as="div"
64
+ disabled={disabled}
65
+ value={selectedItem}
66
+ className={className}
67
+ onChange={(value: Item) => {
68
+ if (!disabled) {
69
+ setSelectedItem(value)
70
+ setOpen(false)
71
+ onSelect(value)
72
+ }
73
+ }}>
74
+ <div className={classNames('relative')}>
75
+ <div className='group text-gray-800'>
76
+ {allowSearch
77
+ ? <Combobox.Input
78
+ className={`w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-not-allowed`}
79
+ onChange={(event) => {
80
+ if (!disabled)
81
+ setQuery(event.target.value)
82
+ }}
83
+ displayValue={(item: Item) => item?.name}
84
+ />
85
+ : <Combobox.Button onClick={
86
+ () => {
87
+ if (!disabled)
88
+ setOpen(!open)
89
+ }
90
+ } className={`flex items-center h-9 w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200`}>
91
+ {selectedItem?.name}
92
+ </Combobox.Button>}
93
+ <Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none group-hover:bg-gray-200" onClick={
94
+ () => {
95
+ if (!disabled)
96
+ setOpen(!open)
97
+ }
98
+ }>
99
+ {open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
100
+ </Combobox.Button>
101
+ </div>
102
+
103
+ {filteredItems.length > 0 && (
104
+ <Combobox.Options className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
105
+ {filteredItems.map((item: Item) => (
106
+ <Combobox.Option
107
+ key={item.value}
108
+ value={item}
109
+ className={({ active }: { active: boolean }) =>
110
+ classNames(
111
+ 'relative cursor-default select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700',
112
+ active ? 'bg-gray-100' : '',
113
+ )
114
+ }
115
+ >
116
+ {({ /* active, */ selected }) => (
117
+ <>
118
+ <span className={classNames('block truncate', selected && 'font-normal')}>{item.name}</span>
119
+ {selected && (
120
+ <span
121
+ className={classNames(
122
+ 'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
123
+ )}
124
+ >
125
+ <CheckIcon className="h-5 w-5" aria-hidden="true" />
126
+ </span>
127
+ )}
128
+ </>
129
+ )}
130
+ </Combobox.Option>
131
+ ))}
132
+ </Combobox.Options>
133
+ )}
134
+ </div>
135
+ </Combobox >
136
+ )
137
+ }
138
+
139
+ const SimpleSelect: FC<ISelectProps> = ({
140
+ className,
141
+ items = defaultItems,
142
+ defaultValue = 1,
143
+ disabled = false,
144
+ onSelect,
145
+ }) => {
146
+ const [selectedItem, setSelectedItem] = useState<Item | null>(null)
147
+ useEffect(() => {
148
+ let defaultSelect = null
149
+ const existed = items.find((item: Item) => item.value === defaultValue)
150
+ if (existed)
151
+ defaultSelect = existed
152
+
153
+ setSelectedItem(defaultSelect)
154
+ }, [defaultValue])
155
+
156
+ return (
157
+ <Listbox
158
+ value={selectedItem}
159
+ onChange={(value: Item) => {
160
+ if (!disabled) {
161
+ setSelectedItem(value)
162
+ onSelect(value)
163
+ }
164
+ }}
165
+ >
166
+ <div className="relative h-9">
167
+ <Listbox.Button className={`w-full h-full rounded-lg border-0 bg-gray-100 py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-pointer ${className}`}>
168
+ <span className="block truncate text-left">{selectedItem?.name}</span>
169
+ <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
170
+ <ChevronDownIcon
171
+ className="h-5 w-5 text-gray-400"
172
+ aria-hidden="true"
173
+ />
174
+ </span>
175
+ </Listbox.Button>
176
+ <Transition
177
+ as={Fragment}
178
+ leave="transition ease-in duration-100"
179
+ leaveFrom="opacity-100"
180
+ leaveTo="opacity-0"
181
+ >
182
+ <Listbox.Options className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
183
+ {items.map((item: Item) => (
184
+ <Listbox.Option
185
+ key={item.value}
186
+ className={({ active }) =>
187
+ `relative cursor-pointer select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700 ${active ? 'bg-gray-100' : ''
188
+ }`
189
+ }
190
+ value={item}
191
+ disabled={disabled}
192
+ >
193
+ {({ /* active, */ selected }) => (
194
+ <>
195
+ <span className={classNames('block truncate', selected && 'font-normal')}>{item.name}</span>
196
+ {selected && (
197
+ <span
198
+ className={classNames(
199
+ 'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
200
+ )}
201
+ >
202
+ <CheckIcon className="h-5 w-5" aria-hidden="true" />
203
+ </span>
204
+ )}
205
+ </>
206
+ )}
207
+ </Listbox.Option>
208
+ ))}
209
+ </Listbox.Options>
210
+ </Transition>
211
+ </div>
212
+ </Listbox>
213
+ )
214
+ }
215
+ export { SimpleSelect }
216
+ export default React.memo(Select)
app/components/base/spinner/index.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FC } from 'react'
2
+ import React from 'react'
3
+
4
+ type Props = {
5
+ loading?: boolean
6
+ className?: string
7
+ children?: React.ReactNode | string
8
+ }
9
+
10
+ const Spinner: FC<Props> = ({ loading = false, children, className }) => {
11
+ return (
12
+ <div
13
+ className={`inline-block text-gray-200 h-4 w-4 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] ${loading ? 'motion-reduce:animate-[spin_1.5s_linear_infinite]' : 'hidden'} ${className ?? ''}`}
14
+ role="status"
15
+ >
16
+ <span
17
+ className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]"
18
+ >Loading...</span>
19
+ {children}
20
+ </div>
21
+ )
22
+ }
23
+
24
+ export default Spinner
app/components/base/toast/index.tsx ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import classNames from 'classnames'
3
+ import type { ReactNode } from 'react'
4
+ import React, { useEffect, useState } from 'react'
5
+ import { createRoot } from 'react-dom/client'
6
+ import {
7
+ CheckCircleIcon,
8
+ ExclamationTriangleIcon,
9
+ InformationCircleIcon,
10
+ XCircleIcon,
11
+ } from '@heroicons/react/20/solid'
12
+ import { createContext } from 'use-context-selector'
13
+
14
+ export type IToastProps = {
15
+ type?: 'success' | 'error' | 'warning' | 'info'
16
+ duration?: number
17
+ message: string
18
+ children?: ReactNode
19
+ onClose?: () => void
20
+ }
21
+ type IToastContext = {
22
+ notify: (props: IToastProps) => void
23
+ }
24
+ const defaultDuring = 3000
25
+
26
+ export const ToastContext = createContext<IToastContext>({} as IToastContext)
27
+ const Toast = ({
28
+ type = 'info',
29
+ duration,
30
+ message,
31
+ children,
32
+ }: IToastProps) => {
33
+ // sometimes message is react node array. Not handle it.
34
+ if (typeof message !== 'string')
35
+ return null
36
+
37
+ return <div className={classNames(
38
+ 'fixed rounded-md p-4 my-4 mx-8 z-50',
39
+ 'top-0',
40
+ 'right-0',
41
+ type === 'success' ? 'bg-green-50' : '',
42
+ type === 'error' ? 'bg-red-50' : '',
43
+ type === 'warning' ? 'bg-yellow-50' : '',
44
+ type === 'info' ? 'bg-blue-50' : '',
45
+ )}>
46
+ <div className="flex">
47
+ <div className="flex-shrink-0">
48
+ {type === 'success' && <CheckCircleIcon className="w-5 h-5 text-green-400" aria-hidden="true" />}
49
+ {type === 'error' && <XCircleIcon className="w-5 h-5 text-red-400" aria-hidden="true" />}
50
+ {type === 'warning' && <ExclamationTriangleIcon className="w-5 h-5 text-yellow-400" aria-hidden="true" />}
51
+ {type === 'info' && <InformationCircleIcon className="w-5 h-5 text-blue-400" aria-hidden="true" />}
52
+ </div>
53
+ <div className="ml-3">
54
+ <h3 className={
55
+ classNames(
56
+ 'text-sm font-medium',
57
+ type === 'success' ? 'text-green-800' : '',
58
+ type === 'error' ? 'text-red-800' : '',
59
+ type === 'warning' ? 'text-yellow-800' : '',
60
+ type === 'info' ? 'text-blue-800' : '',
61
+ )
62
+ }>{message}</h3>
63
+ {children && <div className={
64
+ classNames(
65
+ 'mt-2 text-sm',
66
+ type === 'success' ? 'text-green-700' : '',
67
+ type === 'error' ? 'text-red-700' : '',
68
+ type === 'warning' ? 'text-yellow-700' : '',
69
+ type === 'info' ? 'text-blue-700' : '',
70
+ )
71
+ }>
72
+ {children}
73
+ </div>
74
+ }
75
+ </div>
76
+ </div>
77
+ </div>
78
+ }
79
+
80
+ export const ToastProvider = ({
81
+ children,
82
+ }: {
83
+ children: ReactNode
84
+ }) => {
85
+ const placeholder: IToastProps = {
86
+ type: 'info',
87
+ message: 'Toast message',
88
+ duration: 3000,
89
+ }
90
+ const [params, setParams] = React.useState<IToastProps>(placeholder)
91
+
92
+ const [mounted, setMounted] = useState(false)
93
+
94
+ useEffect(() => {
95
+ if (mounted) {
96
+ setTimeout(() => {
97
+ setMounted(false)
98
+ }, params.duration || defaultDuring)
99
+ }
100
+ }, [mounted])
101
+
102
+ return <ToastContext.Provider value={{
103
+ notify: (props) => {
104
+ setMounted(true)
105
+ setParams(props)
106
+ },
107
+ }}>
108
+ {mounted && <Toast {...params} />}
109
+ {children}
110
+ </ToastContext.Provider>
111
+ }
112
+
113
+ Toast.notify = ({
114
+ type,
115
+ message,
116
+ duration,
117
+ }: Pick<IToastProps, 'type' | 'message' | 'duration'>) => {
118
+ if (typeof window === 'object') {
119
+ const holder = document.createElement('div')
120
+ const root = createRoot(holder)
121
+
122
+ root.render(<Toast type={type} message={message} duration={duration} />)
123
+ document.body.appendChild(holder)
124
+ setTimeout(() => {
125
+ if (holder)
126
+ holder.remove()
127
+ }, duration || defaultDuring)
128
+ }
129
+ }
130
+
131
+ export default Toast
app/components/base/toast/style.module.css ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .toast {
2
+ display: flex;
3
+ justify-content: center;
4
+ align-items: center;
5
+ position: fixed;
6
+ width: 1.84rem;
7
+ height: 1.80rem;
8
+ left: 50%;
9
+ top: 50%;
10
+ transform: translateX(-50%) translateY(-50%);
11
+ background: #000000;
12
+ box-shadow: 0 -.04rem .1rem 1px rgba(255, 255, 255, 0.1);
13
+ border-radius: .1rem .1rem .1rem .1rem;
14
+ }
15
+
16
+ .main {
17
+ width: 2rem;
18
+ }
19
+
20
+ .icon {
21
+ margin-bottom: .2rem;
22
+ height: .4rem;
23
+ background: center center no-repeat;
24
+ background-size: contain;
25
+ }
26
+
27
+ /* .success {
28
+ background-image: url('./icons/success.svg');
29
+ }
30
+
31
+ .warning {
32
+ background-image: url('./icons/warning.svg');
33
+ }
34
+
35
+ .error {
36
+ background-image: url('./icons/error.svg');
37
+ } */
38
+
39
+ .text {
40
+ text-align: center;
41
+ font-size: .2rem;
42
+ color: rgba(255, 255, 255, 0.86);
43
+ }
app/components/base/tooltip/index.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import classNames from 'classnames'
3
+ import type { FC } from 'react'
4
+ import React from 'react'
5
+ import { Tooltip as ReactTooltip } from 'react-tooltip' // fixed version to 5.8.3 https://github.com/ReactTooltip/react-tooltip/issues/972
6
+ import 'react-tooltip/dist/react-tooltip.css'
7
+
8
+ type TooltipProps = {
9
+ selector: string
10
+ content?: string
11
+ htmlContent?: React.ReactNode
12
+ className?: string // This should use !impornant to override the default styles eg: '!bg-white'
13
+ position?: 'top' | 'right' | 'bottom' | 'left'
14
+ clickable?: boolean
15
+ children: React.ReactNode
16
+ }
17
+
18
+ const Tooltip: FC<TooltipProps> = ({
19
+ selector,
20
+ content,
21
+ position = 'top',
22
+ children,
23
+ htmlContent,
24
+ className,
25
+ clickable,
26
+ }) => {
27
+ return (
28
+ <div className='tooltip-container'>
29
+ {React.cloneElement(children as React.ReactElement, {
30
+ 'data-tooltip-id': selector,
31
+ })
32
+ }
33
+ <ReactTooltip
34
+ id={selector}
35
+ content={content}
36
+ className={classNames('!bg-white !text-xs !font-normal !text-gray-700 !shadow-lg !opacity-100', className)}
37
+ place={position}
38
+ clickable={clickable}
39
+ >
40
+ {htmlContent && htmlContent}
41
+ </ReactTooltip>
42
+ </div>
43
+ )
44
+ }
45
+
46
+ export default Tooltip
app/components/chat/icons/answer.svg ADDED
app/components/chat/icons/default-avatar.jpg ADDED
app/components/chat/icons/edit.svg ADDED
app/components/chat/icons/question.svg ADDED
app/components/chat/icons/robot.svg ADDED
app/components/chat/icons/send-active.svg ADDED
app/components/chat/icons/send.svg ADDED
app/components/chat/icons/typing.svg ADDED
app/components/chat/icons/user.svg ADDED
app/components/chat/index.tsx ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import type { FC, } from 'react'
3
+ import React, { useEffect, useRef } from 'react'
4
+ import cn from 'classnames'
5
+ import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
6
+ import { useTranslation } from 'react-i18next'
7
+ import s from './style.module.css'
8
+ import { randomString } from '@/utils/string'
9
+ import type { Feedbacktype, MessageRating } from '@/types/app'
10
+ import Tooltip from '@/app/components/base/tooltip'
11
+ import Toast from '@/app/components/base/toast'
12
+ import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
13
+ import { Markdown } from '@/app/components/base/markdown'
14
+ import LoadingAnim from './loading-anim'
15
+
16
+ export type FeedbackFunc = (messageId: string, feedback: Feedbacktype) => Promise<any>
17
+
18
+ export type IChatProps = {
19
+ chatList: IChatItem[]
20
+ /**
21
+ * Whether to display the editing area and rating status
22
+ */
23
+ feedbackDisabled?: boolean
24
+ /**
25
+ * Whether to display the input area
26
+ */
27
+ isHideSendInput?: boolean
28
+ onFeedback?: FeedbackFunc
29
+ checkCanSend?: () => boolean
30
+ onSend?: (message: string) => void
31
+ useCurrentUserAvatar?: boolean
32
+ isResponsing?: boolean
33
+ controlClearQuery?: number
34
+ controlFocus?: number
35
+ }
36
+
37
+ export type IChatItem = {
38
+ id: string
39
+ content: string
40
+ /**
41
+ * Specific message type
42
+ */
43
+ isAnswer: boolean
44
+ /**
45
+ * The user feedback result of this message
46
+ */
47
+ feedback?: Feedbacktype
48
+ /**
49
+ * Whether to hide the feedback area
50
+ */
51
+ feedbackDisabled?: boolean
52
+ isIntroduction?: boolean
53
+ useCurrentUserAvatar?: boolean
54
+ isOpeningStatement?: boolean
55
+ }
56
+
57
+ const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => (
58
+ <div
59
+ 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 ?? ''}`}
60
+ style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
61
+ onClick={onClick && onClick}
62
+ >
63
+ {innerContent}
64
+ </div>
65
+ )
66
+
67
+ const OpeningStatementIcon: FC<{ className?: string }> = ({ className }) => (
68
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
69
+ <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" />
70
+ </svg>
71
+ )
72
+
73
+ const RatingIcon: FC<{ isLike: boolean }> = ({ isLike }) => {
74
+ return isLike ? <HandThumbUpIcon className='w-4 h-4' /> : <HandThumbDownIcon className='w-4 h-4' />
75
+ }
76
+
77
+ const EditIcon: FC<{ className?: string }> = ({ className }) => {
78
+ return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
79
+ <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" />
80
+ </svg>
81
+ }
82
+
83
+ export const EditIconSolid: FC<{ className?: string }> = ({ className }) => {
84
+ return <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
85
+ <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" />
86
+ <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" />
87
+ </svg>
88
+ }
89
+
90
+ const IconWrapper: FC<{ children: React.ReactNode | string }> = ({ children }) => {
91
+ return <div className={'rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100'}>
92
+ {children}
93
+ </div>
94
+ }
95
+
96
+ type IAnswerProps = {
97
+ item: IChatItem
98
+ feedbackDisabled: boolean
99
+ onFeedback?: FeedbackFunc
100
+ isResponsing?: boolean
101
+ }
102
+
103
+ // The component needs to maintain its own state to control whether to display input component
104
+ const Answer: FC<IAnswerProps> = ({ item, feedbackDisabled = false, onFeedback, isResponsing }) => {
105
+ const { id, content, feedback } = item
106
+ const { t } = useTranslation()
107
+
108
+ /**
109
+ * Render feedback results (distinguish between users and administrators)
110
+ * User reviews cannot be cancelled in Console
111
+ * @param rating feedback result
112
+ * @param isUserFeedback Whether it is user's feedback
113
+ * @returns comp
114
+ */
115
+ const renderFeedbackRating = (rating: MessageRating | undefined) => {
116
+ if (!rating)
117
+ return null
118
+
119
+ const isLike = rating === 'like'
120
+ const ratingIconClassname = isLike ? 'text-primary-600 bg-primary-100 hover:bg-primary-200' : 'text-red-600 bg-red-100 hover:bg-red-200'
121
+ // The tooltip is always displayed, but the content is different for different scenarios.
122
+ return (
123
+ <Tooltip
124
+ selector={`user-feedback-${randomString(16)}`}
125
+ content={isLike ? '取消赞同' : '取消反对'}
126
+ >
127
+ <div
128
+ 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'}
129
+ style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
130
+ onClick={async () => {
131
+ await onFeedback?.(id, { rating: null })
132
+ }}
133
+ >
134
+ <div className={`${ratingIconClassname} rounded-lg h-6 w-6 flex items-center justify-center`}>
135
+ <RatingIcon isLike={isLike} />
136
+ </div>
137
+ </div>
138
+ </Tooltip>
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Different scenarios have different operation items.
144
+ * @returns comp
145
+ */
146
+ const renderItemOperation = () => {
147
+ const userOperation = () => {
148
+ return feedback?.rating
149
+ ? null
150
+ : <div className='flex gap-1'>
151
+ <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.like') as string}>
152
+ {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
153
+ </Tooltip>
154
+ <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.dislike') as string}>
155
+ {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
156
+ </Tooltip>
157
+ </div>
158
+ }
159
+
160
+ return (
161
+ <div className={`${s.itemOperation} flex gap-2`}>
162
+ {userOperation()}
163
+ </div>
164
+ )
165
+ }
166
+
167
+ return (
168
+ <div key={id}>
169
+ <div className='flex items-start'>
170
+ <div className={`${s.answerIcon} w-10 h-10 shrink-0`}>
171
+ {isResponsing &&
172
+ <div className={s.typeingIcon}>
173
+ <LoadingAnim type='avatar' />
174
+ </div>
175
+ }
176
+ </div>
177
+ <div className={`${s.answerWrap}`}>
178
+ <div className={`${s.answer} relative text-sm text-gray-900`}>
179
+ <div className={'ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl'}>
180
+ {item.isOpeningStatement && (
181
+ <div className='flex items-center mb-1 gap-1'>
182
+ <OpeningStatementIcon />
183
+ <div className='text-xs text-gray-500'>{t('app.chat.openingStatementTitle')}</div>
184
+ </div>
185
+ )}
186
+ {(isResponsing && !content) ? (
187
+ <div className='flex items-center justify-center w-6 h-5'>
188
+ <LoadingAnim type='text' />
189
+ </div>
190
+ ) : (
191
+ <Markdown content={content} />
192
+ )}
193
+ </div>
194
+ <div className='absolute top-[-14px] right-[-14px] flex flex-row justify-end gap-1'>
195
+ {!feedbackDisabled && !item.feedbackDisabled && renderItemOperation()}
196
+ {/* User feedback must be displayed */}
197
+ {!feedbackDisabled && renderFeedbackRating(feedback?.rating)}
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ )
204
+ }
205
+
206
+ type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'useCurrentUserAvatar'>
207
+
208
+ const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar }) => {
209
+ const userName = ''
210
+ return (
211
+ <div className='flex items-start justify-end' key={id}>
212
+ <div>
213
+ <div className={`${s.question} relative text-sm text-gray-900`}>
214
+ <div
215
+ className={'mr-2 py-3 px-4 bg-blue-500 rounded-tl-2xl rounded-b-2xl'}
216
+ >
217
+ <Markdown content={content} />
218
+ </div>
219
+ </div>
220
+ </div>
221
+ {useCurrentUserAvatar
222
+ ? (
223
+ <div className='w-10 h-10 shrink-0 leading-10 text-center mr-2 rounded-full bg-primary-600 text-white'>
224
+ {userName?.[0].toLocaleUpperCase()}
225
+ </div>
226
+ )
227
+ : (
228
+ <div className={`${s.questionIcon} w-10 h-10 shrink-0 `}></div>
229
+ )}
230
+ </div>
231
+ )
232
+ }
233
+
234
+ const Chat: FC<IChatProps> = ({
235
+ chatList,
236
+ feedbackDisabled = false,
237
+ isHideSendInput = false,
238
+ onFeedback,
239
+ checkCanSend,
240
+ onSend = () => { },
241
+ useCurrentUserAvatar,
242
+ isResponsing,
243
+ controlClearQuery,
244
+ controlFocus,
245
+ }) => {
246
+ const { t } = useTranslation()
247
+ const { notify } = Toast
248
+ const isUseInputMethod = useRef(false)
249
+
250
+ const [query, setQuery] = React.useState('')
251
+ const handleContentChange = (e: any) => {
252
+ const value = e.target.value
253
+ setQuery(value)
254
+ }
255
+
256
+ const logError = (message: string) => {
257
+ notify({ type: 'error', message, duration: 3000 })
258
+ }
259
+
260
+ const valid = () => {
261
+ if (!query || query.trim() === '') {
262
+ logError('Message cannot be empty')
263
+ return false
264
+ }
265
+ return true
266
+ }
267
+
268
+ useEffect(() => {
269
+ if (controlClearQuery)
270
+ setQuery('')
271
+ }, [controlClearQuery])
272
+
273
+ const handleSend = () => {
274
+ if (!valid() || (checkCanSend && !checkCanSend()))
275
+ return
276
+ onSend(query)
277
+ if (!isResponsing)
278
+ setQuery('')
279
+ }
280
+
281
+ const handleKeyUp = (e: any) => {
282
+ if (e.code === 'Enter') {
283
+ e.preventDefault()
284
+ // prevent send message when using input method enter
285
+ if (!e.shiftKey && !isUseInputMethod.current) {
286
+ handleSend()
287
+ }
288
+ }
289
+ }
290
+
291
+ const haneleKeyDown = (e: any) => {
292
+ isUseInputMethod.current = e.nativeEvent.isComposing
293
+ if (e.code === 'Enter' && !e.shiftKey) {
294
+ setQuery(query.replace(/\n$/, ''))
295
+ e.preventDefault()
296
+ }
297
+ }
298
+
299
+ return (
300
+ <div className={cn(!feedbackDisabled && 'px-3.5', 'h-full')}>
301
+ {/* Chat List */}
302
+ <div className="h-full space-y-[30px]">
303
+ {chatList.map((item) => {
304
+ if (item.isAnswer) {
305
+ const isLast = item.id === chatList[chatList.length - 1].id
306
+ return <Answer
307
+ key={item.id}
308
+ item={item}
309
+ feedbackDisabled={feedbackDisabled}
310
+ onFeedback={onFeedback}
311
+ isResponsing={isResponsing && isLast}
312
+ />
313
+ }
314
+ return <Question key={item.id} id={item.id} content={item.content} useCurrentUserAvatar={useCurrentUserAvatar} />
315
+ })}
316
+ </div>
317
+ {
318
+ !isHideSendInput && (
319
+ <div className={cn(!feedbackDisabled && '!left-3.5 !right-3.5', 'absolute z-10 bottom-0 left-0 right-0')}>
320
+ <div className="positive">
321
+ <AutoHeightTextarea
322
+ value={query}
323
+ onChange={handleContentChange}
324
+ onKeyUp={handleKeyUp}
325
+ onKeyDown={haneleKeyDown}
326
+ minHeight={48}
327
+ autoFocus
328
+ controlFocus={controlFocus}
329
+ 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`}
330
+ />
331
+ <div className="absolute top-0 right-2 flex items-center h-[48px]">
332
+ <div className={`${s.count} mr-4 h-5 leading-5 text-sm bg-gray-50 text-gray-500`}>{query.trim().length}</div>
333
+ <Tooltip
334
+ selector='send-tip'
335
+ htmlContent={
336
+ <div>
337
+ <div>{t('common.operation.send')} Enter</div>
338
+ <div>{t('common.operation.lineBreak')} Shift Enter</div>
339
+ </div>
340
+ }
341
+ >
342
+ <div className={`${s.sendBtn} w-8 h-8 cursor-pointer rounded-md`} onClick={handleSend}></div>
343
+ </Tooltip>
344
+ </div>
345
+ </div>
346
+ </div>
347
+ )
348
+ }
349
+ </div>
350
+ )
351
+ }
352
+
353
+ export default React.memo(Chat)
app/components/chat/loading-anim/index.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import React, { FC } from 'react'
3
+ import s from './style.module.css'
4
+
5
+ export interface ILoaidingAnimProps {
6
+ type: 'text' | 'avatar'
7
+ }
8
+
9
+ const LoaidingAnim: FC<ILoaidingAnimProps> = ({
10
+ type
11
+ }) => {
12
+ return (
13
+ <div className={`${s['dot-flashing']} ${s[type]}`}></div>
14
+ )
15
+ }
16
+ export default React.memo(LoaidingAnim)
app/components/chat/loading-anim/style.module.css ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .dot-flashing {
2
+ position: relative;
3
+ animation: 1s infinite linear alternate;
4
+ animation-delay: 0.5s;
5
+ }
6
+
7
+ .dot-flashing::before,
8
+ .dot-flashing::after {
9
+ content: "";
10
+ display: inline-block;
11
+ position: absolute;
12
+ top: 0;
13
+ animation: 1s infinite linear alternate;
14
+ }
15
+
16
+ .dot-flashing::before {
17
+ animation-delay: 0s;
18
+ }
19
+
20
+ .dot-flashing::after {
21
+ animation-delay: 1s;
22
+ }
23
+
24
+ @keyframes dot-flashing {
25
+ 0% {
26
+ background-color: #667085;
27
+ }
28
+
29
+ 50%,
30
+ 100% {
31
+ background-color: rgba(102, 112, 133, 0.3);
32
+ }
33
+ }
34
+
35
+ @keyframes dot-flashing-avatar {
36
+ 0% {
37
+ background-color: #155EEF;
38
+ }
39
+
40
+ 50%,
41
+ 100% {
42
+ background-color: rgba(21, 94, 239, 0.3);
43
+ }
44
+ }
45
+
46
+ .text,
47
+ .text::before,
48
+ .text::after {
49
+ width: 4px;
50
+ height: 4px;
51
+ border-radius: 50%;
52
+ background-color: #667085;
53
+ color: #667085;
54
+ animation-name: dot-flashing;
55
+ }
56
+
57
+ .text::before {
58
+ left: -7px;
59
+ }
60
+
61
+ .text::after {
62
+ left: 7px;
63
+ }
64
+
65
+ .avatar,
66
+ .avatar::before,
67
+ .avatar::after {
68
+ width: 2px;
69
+ height: 2px;
70
+ border-radius: 50%;
71
+ background-color: #155EEF;
72
+ color: #155EEF;
73
+ animation-name: dot-flashing-avatar;
74
+ }
75
+
76
+ .avatar::before {
77
+ left: -5px;
78
+ }
79
+
80
+ .avatar::after {
81
+ left: 5px;
82
+ }
app/components/chat/style.module.css ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .answerIcon {
2
+ position: relative;
3
+ background: url(./icons/robot.svg);
4
+ }
5
+
6
+ .typeingIcon {
7
+ position: absolute;
8
+ top: 0px;
9
+ left: 0px;
10
+ display: flex;
11
+ justify-content: center;
12
+ align-items: center;
13
+ width: 16px;
14
+ height: 16px;
15
+ background: #FFFFFF;
16
+ box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
17
+ border-radius: 16px;
18
+ }
19
+
20
+
21
+ .questionIcon {
22
+ background: url(./icons/default-avatar.jpg);
23
+ background-size: contain;
24
+ border-radius: 50%;
25
+ }
26
+
27
+ .answer::before,
28
+ .question::before {
29
+ content: '';
30
+ position: absolute;
31
+ top: 0;
32
+ width: 8px;
33
+ height: 12px;
34
+ }
35
+
36
+ .answer::before {
37
+ left: 0;
38
+ background: url(./icons/answer.svg) no-repeat;
39
+ }
40
+
41
+ .answerWrap .itemOperation {
42
+ display: none;
43
+ }
44
+
45
+ .answerWrap:hover .itemOperation {
46
+ display: flex;
47
+ }
48
+
49
+ .question::before {
50
+ right: 0;
51
+ background: url(./icons/question.svg) no-repeat;
52
+ }
53
+
54
+ .textArea {
55
+ padding-top: 13px;
56
+ padding-bottom: 13px;
57
+ padding-right: 90px;
58
+ border-radius: 12px;
59
+ line-height: 20px;
60
+ background-color: #fff;
61
+ }
62
+
63
+ .textArea:hover {
64
+ background-color: #fff;
65
+ }
66
+
67
+ /* .textArea:focus {
68
+ box-shadow: 0px 3px 15px -3px rgba(0, 0, 0, 0.1), 0px 4px 6px rgba(0, 0, 0, 0.05);
69
+ } */
70
+
71
+ .count {
72
+ /* display: none; */
73
+ padding: 0 2px;
74
+ }
75
+
76
+ .sendBtn {
77
+ background: url(./icons/send.svg) center center no-repeat;
78
+ }
79
+
80
+ .sendBtn:hover {
81
+ background-image: url(./icons/send-active.svg);
82
+ background-color: #EBF5FF;
83
+ }
84
+
85
+ .textArea:focus+div .count {
86
+ display: block;
87
+ }
88
+
89
+ .textArea:focus+div .sendBtn {
90
+ background-image: url(./icons/send-active.svg);
91
+ }
app/components/config-scence/index.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FC } from 'react'
2
+ import React from 'react'
3
+ import type { IWelcomeProps } from '../welcome'
4
+ import Welcome from '../welcome'
5
+
6
+ const ConfigSence: FC<IWelcomeProps> = (props) => {
7
+ return (
8
+ <div className='mb-5 antialiased font-sans overflow-hidden shrink-0'>
9
+ <Welcome {...props} />
10
+ </div>
11
+ )
12
+ }
13
+ export default React.memo(ConfigSence)
app/components/header.tsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FC } from 'react'
2
+ import React from 'react'
3
+ import {
4
+ Bars3Icon,
5
+ PencilSquareIcon,
6
+ } from '@heroicons/react/24/solid'
7
+ import AppIcon from '@/app/components/base/app-icon'
8
+ export type IHeaderProps = {
9
+ title: string
10
+ isMobile?: boolean
11
+ onShowSideBar?: () => void
12
+ onCreateNewChat?: () => void
13
+ }
14
+ const Header: FC<IHeaderProps> = ({
15
+ title,
16
+ isMobile,
17
+ onShowSideBar,
18
+ onCreateNewChat,
19
+ }) => {
20
+ return (
21
+ <div className="shrink-0 flex items-center justify-between h-12 px-3 bg-gray-100">
22
+ {isMobile
23
+ ? (
24
+ <div
25
+ className='flex items-center justify-center h-8 w-8 cursor-pointer'
26
+ onClick={() => onShowSideBar?.()}
27
+ >
28
+ <Bars3Icon className="h-4 w-4 text-gray-500" />
29
+ </div>
30
+ )
31
+ : <div></div>}
32
+ <div className='flex items-center space-x-2'>
33
+ <AppIcon size="small" />
34
+ <div className=" text-sm text-gray-800 font-bold">{title}</div>
35
+ </div>
36
+ {isMobile
37
+ ? (
38
+ <div className='flex items-center justify-center h-8 w-8 cursor-pointer'
39
+ onClick={() => onCreateNewChat?.()}
40
+ >
41
+ <PencilSquareIcon className="h-4 w-4 text-gray-500" />
42
+ </div>)
43
+ : <div></div>}
44
+ </div>
45
+ )
46
+ }
47
+
48
+ export default React.memo(Header)
app/components/index.tsx ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import type { FC } from 'react'
3
+ import React, { useEffect, useRef, useState } from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+ import produce from 'immer'
6
+ import { useBoolean, useGetState } from 'ahooks'
7
+ import useConversation from '@/hooks/use-conversation'
8
+ import Toast from '@/app/components/base/toast'
9
+ import Sidebar from '@/app/components/sidebar'
10
+ import ConfigSence from '@/app/components/config-scence'
11
+ import Header from '@/app/components/header'
12
+ import { fetchAppParams, fetchChatList, fetchConversations, sendChatMessage, updateFeedback } from '@/service'
13
+ import type { ConversationItem, Feedbacktype, IChatItem, PromptConfig, AppInfo } from '@/types/app'
14
+ import Chat from '@/app/components/chat'
15
+ import { setLocaleOnClient } from '@/i18n/client'
16
+ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
17
+ import Loading from '@/app/components/base/loading'
18
+ import { replaceVarWithValues } from '@/utils/prompt'
19
+ import AppUnavailable from '@/app/components/app-unavailable'
20
+ import { APP_ID, API_KEY, APP_INFO, isShowPrompt, promptTemplate } from '@/config'
21
+ import { userInputsFormToPromptVariables } from '@/utils/prompt'
22
+
23
+ const Main: FC = () => {
24
+ const { t } = useTranslation()
25
+ const media = useBreakpoints()
26
+ const isMobile = media === MediaType.mobile
27
+ const hasSetAppConfig = APP_ID && API_KEY
28
+
29
+ /*
30
+ * app info
31
+ */
32
+ const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
33
+ const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
34
+ const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
35
+ const [inited, setInited] = useState<boolean>(false)
36
+ // in mobile, show sidebar by click button
37
+ const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false)
38
+
39
+ useEffect(() => {
40
+ if (APP_INFO?.title) {
41
+ document.title = `${APP_INFO.title} - Powered by Dify`
42
+ }
43
+ }, [APP_INFO?.title])
44
+
45
+ /*
46
+ * conversation info
47
+ */
48
+ const {
49
+ conversationList,
50
+ setConversationList,
51
+ currConversationId,
52
+ setCurrConversationId,
53
+ getConversationIdFromStorage,
54
+ isNewConversation,
55
+ currConversationInfo,
56
+ currInputs,
57
+ newConversationInputs,
58
+ resetNewConversationInputs,
59
+ setCurrInputs,
60
+ setNewConversationInfo,
61
+ setExistConversationInfo,
62
+ } = useConversation()
63
+
64
+ const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false)
65
+ const [isChatStarted, { setTrue: setChatStarted, setFalse: setChatNotStarted }] = useBoolean(false)
66
+ const handleStartChat = (inputs: Record<string, any>) => {
67
+ setCurrInputs(inputs)
68
+ setChatStarted()
69
+ // parse variables in introduction
70
+ setChatList(generateNewChatListWithOpenstatement('', inputs))
71
+ }
72
+ const hasSetInputs = (() => {
73
+ if (!isNewConversation)
74
+ return true
75
+
76
+ return isChatStarted
77
+ })()
78
+
79
+ const conversationName = currConversationInfo?.name || t('app.chat.newChatDefaultName') as string
80
+ const conversationIntroduction = currConversationInfo?.introduction || ''
81
+
82
+ const handleConversationSwitch = () => {
83
+ if (!inited)
84
+ return
85
+
86
+ // update inputs of current conversation
87
+ let notSyncToStateIntroduction = ''
88
+ let notSyncToStateInputs: Record<string, any> | undefined | null = {}
89
+ if (!isNewConversation) {
90
+ const item = conversationList.find(item => item.id === currConversationId)
91
+ notSyncToStateInputs = item?.inputs || {}
92
+ setCurrInputs(notSyncToStateInputs as any)
93
+ notSyncToStateIntroduction = item?.introduction || ''
94
+ setExistConversationInfo({
95
+ name: item?.name || '',
96
+ introduction: notSyncToStateIntroduction,
97
+ })
98
+ }
99
+ else {
100
+ notSyncToStateInputs = newConversationInputs
101
+ setCurrInputs(notSyncToStateInputs)
102
+ }
103
+
104
+ // update chat list of current conversation
105
+ if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) {
106
+ fetchChatList(currConversationId).then((res: any) => {
107
+ const { data } = res
108
+ const newChatList: IChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs)
109
+
110
+ data.forEach((item: any) => {
111
+ newChatList.push({
112
+ id: `question-${item.id}`,
113
+ content: item.query,
114
+ isAnswer: false,
115
+ })
116
+ newChatList.push({
117
+ id: item.id,
118
+ content: item.answer,
119
+ feedback: item.feedback,
120
+ isAnswer: true,
121
+ })
122
+ })
123
+ setChatList(newChatList)
124
+ })
125
+ }
126
+
127
+ if (isNewConversation && isChatStarted)
128
+ setChatList(generateNewChatListWithOpenstatement())
129
+
130
+ setControlFocus(Date.now())
131
+ }
132
+ useEffect(handleConversationSwitch, [currConversationId, inited])
133
+
134
+ const handleConversationIdChange = (id: string) => {
135
+ if (id === '-1') {
136
+ createNewChat()
137
+ setConversationIdChangeBecauseOfNew(true)
138
+ }
139
+ else {
140
+ setConversationIdChangeBecauseOfNew(false)
141
+ }
142
+ // trigger handleConversationSwitch
143
+ setCurrConversationId(id, APP_ID)
144
+ hideSidebar()
145
+ }
146
+
147
+ /*
148
+ * chat info. chat is under conversation.
149
+ */
150
+ const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
151
+ const chatListDomRef = useRef<HTMLDivElement>(null)
152
+ useEffect(() => {
153
+ // scroll to bottom
154
+ if (chatListDomRef.current)
155
+ chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight
156
+ }, [chatList, currConversationId])
157
+ // user can not edit inputs if user had send message
158
+ const canEditInpus = !chatList.some(item => item.isAnswer === false) && isNewConversation
159
+ const createNewChat = () => {
160
+ // if new chat is already exist, do not create new chat
161
+ if (conversationList.some(item => item.id === '-1'))
162
+ return
163
+
164
+ setConversationList(produce(conversationList, (draft) => {
165
+ draft.unshift({
166
+ id: '-1',
167
+ name: t('app.chat.newChatDefaultName'),
168
+ inputs: newConversationInputs,
169
+ introduction: conversationIntroduction,
170
+ })
171
+ }))
172
+ }
173
+
174
+ // sometime introduction is not applied to state
175
+ const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => {
176
+ let caculatedIntroduction = introduction || conversationIntroduction || ''
177
+ const caculatedPromptVariables = inputs || currInputs || null
178
+ if (caculatedIntroduction && caculatedPromptVariables)
179
+ caculatedIntroduction = replaceVarWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
180
+
181
+ const openstatement = {
182
+ id: `${Date.now()}`,
183
+ content: caculatedIntroduction,
184
+ isAnswer: true,
185
+ feedbackDisabled: true,
186
+ isOpeningStatement: isShowPrompt,
187
+ }
188
+ if (caculatedIntroduction)
189
+ return [openstatement]
190
+
191
+ return []
192
+ }
193
+
194
+ // init
195
+ useEffect(() => {
196
+ if (!hasSetAppConfig) {
197
+ setAppUnavailable(true)
198
+ return
199
+ }
200
+ (async () => {
201
+ try {
202
+ const [conversationData, appParams] = await Promise.all([fetchConversations(), fetchAppParams()])
203
+
204
+ // handle current conversation id
205
+ const { data: conversations } = conversationData as { data: ConversationItem[] }
206
+ const _conversationId = getConversationIdFromStorage(APP_ID)
207
+ const isNotNewConversation = conversations.some(item => item.id === _conversationId)
208
+
209
+ // fetch new conversation info
210
+ const { user_input_form, opening_statement: introduction }: any = appParams
211
+ setLocaleOnClient(APP_INFO.default_language, true)
212
+ setNewConversationInfo({
213
+ name: t('app.chat.newChatDefaultName'),
214
+ introduction,
215
+ })
216
+ const prompt_variables = userInputsFormToPromptVariables(user_input_form)
217
+ setPromptConfig({
218
+ prompt_template: promptTemplate,
219
+ prompt_variables,
220
+ } as PromptConfig)
221
+
222
+ setConversationList(conversations as ConversationItem[])
223
+
224
+ if (isNotNewConversation)
225
+ setCurrConversationId(_conversationId, APP_ID, false)
226
+
227
+ setInited(true)
228
+ }
229
+ catch (e: any) {
230
+ if (e.status === 404) {
231
+ setAppUnavailable(true)
232
+ }
233
+ else {
234
+ setIsUnknwonReason(true)
235
+ setAppUnavailable(true)
236
+ }
237
+ }
238
+ })()
239
+ }, [])
240
+
241
+ const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
242
+ const { notify } = Toast
243
+ const logError = (message: string) => {
244
+ notify({ type: 'error', message })
245
+ }
246
+
247
+ const checkCanSend = () => {
248
+ if (!currInputs || !promptConfig?.prompt_variables)
249
+ return true
250
+
251
+ const inputLens = Object.values(currInputs).length
252
+ const promptVariablesLens = promptConfig.prompt_variables.length
253
+
254
+ const emytyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v)
255
+ if (emytyInput) {
256
+ logError(t('app.errorMessage.valueOfVarRequired'))
257
+ return false
258
+ }
259
+ return true
260
+ }
261
+
262
+ const [controlFocus, setControlFocus] = useState(0)
263
+ const handleSend = async (message: string) => {
264
+ if (isResponsing) {
265
+ notify({ type: 'info', message: t('app.errorMessage.waitForResponse') })
266
+ return
267
+ }
268
+ const data = {
269
+ inputs: currInputs,
270
+ query: message,
271
+ conversation_id: isNewConversation ? null : currConversationId,
272
+ }
273
+
274
+ // qustion
275
+ const questionId = `question-${Date.now()}`
276
+ const questionItem = {
277
+ id: questionId,
278
+ content: message,
279
+ isAnswer: false,
280
+ }
281
+
282
+ const placeholderAnswerId = `answer-placeholder-${Date.now()}`
283
+ const placeholderAnswerItem = {
284
+ id: placeholderAnswerId,
285
+ content: '',
286
+ isAnswer: true,
287
+ }
288
+
289
+ const newList = [...getChatList(), questionItem, placeholderAnswerItem]
290
+ setChatList(newList)
291
+
292
+ // answer
293
+ const responseItem = {
294
+ id: `${Date.now()}`,
295
+ content: '',
296
+ isAnswer: true,
297
+ }
298
+
299
+ let tempNewConversationId = ''
300
+ setResponsingTrue()
301
+ sendChatMessage(data, {
302
+ onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId }: any) => {
303
+ responseItem.content = responseItem.content + message
304
+ responseItem.id = messageId
305
+ if (isFirstMessage && newConversationId)
306
+ tempNewConversationId = newConversationId
307
+
308
+ // closesure new list is outdated.
309
+ const newListWithAnswer = produce(
310
+ getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
311
+ (draft) => {
312
+ if (!draft.find(item => item.id === questionId))
313
+ draft.push({ ...questionItem })
314
+
315
+ draft.push({ ...responseItem })
316
+ })
317
+ setChatList(newListWithAnswer)
318
+ },
319
+ async onCompleted() {
320
+ setResponsingFalse()
321
+ if (!tempNewConversationId) {
322
+ return
323
+ }
324
+ if (getConversationIdChangeBecauseOfNew()) {
325
+ const { data: conversations }: any = await fetchConversations()
326
+ setConversationList(conversations as ConversationItem[])
327
+ }
328
+ setConversationIdChangeBecauseOfNew(false)
329
+ resetNewConversationInputs()
330
+ setChatNotStarted()
331
+ setCurrConversationId(tempNewConversationId, APP_ID, true)
332
+ },
333
+ onError() {
334
+ setResponsingFalse()
335
+ // role back placeholder answer
336
+ setChatList(produce(getChatList(), (draft) => {
337
+ draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
338
+ }))
339
+ },
340
+ })
341
+ }
342
+
343
+ const handleFeedback = async (messageId: string, feedback: Feedbacktype) => {
344
+ await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } })
345
+ const newChatList = chatList.map((item) => {
346
+ if (item.id === messageId) {
347
+ return {
348
+ ...item,
349
+ feedback,
350
+ }
351
+ }
352
+ return item
353
+ })
354
+ setChatList(newChatList)
355
+ notify({ type: 'success', message: t('common.api.success') })
356
+ }
357
+
358
+ const renderSidebar = () => {
359
+ if (!APP_ID || !APP_INFO || !promptConfig)
360
+ return null
361
+ return (
362
+ <Sidebar
363
+ list={conversationList}
364
+ onCurrentIdChange={handleConversationIdChange}
365
+ currentId={currConversationId}
366
+ copyRight={APP_INFO.copyright || APP_INFO.title}
367
+ />
368
+ )
369
+ }
370
+
371
+ if (appUnavailable)
372
+ return <AppUnavailable isUnknwonReason={isUnknwonReason} errMessage={!hasSetAppConfig ? 'Please set APP_ID and API_KEY in config/index.tsx' : ''} />
373
+
374
+ if (!APP_ID || !APP_INFO || !promptConfig)
375
+ return <Loading type='app' />
376
+
377
+ return (
378
+ <div className='bg-gray-100'>
379
+ <Header
380
+ title={APP_INFO.title}
381
+ isMobile={isMobile}
382
+ onShowSideBar={showSidebar}
383
+ onCreateNewChat={() => handleConversationIdChange('-1')}
384
+ />
385
+ <div className="flex rounded-t-2xl bg-white overflow-hidden">
386
+ {/* sidebar */}
387
+ {!isMobile && renderSidebar()}
388
+ {isMobile && isShowSidebar && (
389
+ <div className='fixed inset-0 z-50'
390
+ style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
391
+ onClick={hideSidebar}
392
+ >
393
+ <div className='inline-block' onClick={e => e.stopPropagation()}>
394
+ {renderSidebar()}
395
+ </div>
396
+ </div>
397
+ )}
398
+ {/* main */}
399
+ <div className='flex-grow flex flex-col h-[calc(100vh_-_3rem)] overflow-y-auto'>
400
+ <ConfigSence
401
+ conversationName={conversationName}
402
+ hasSetInputs={hasSetInputs}
403
+ isPublicVersion={isShowPrompt}
404
+ siteInfo={APP_INFO}
405
+ promptConfig={promptConfig}
406
+ onStartChat={handleStartChat}
407
+ canEidtInpus={canEditInpus}
408
+ savedInputs={currInputs as Record<string, any>}
409
+ onInputsChange={setCurrInputs}
410
+ ></ConfigSence>
411
+
412
+ {
413
+ hasSetInputs && (
414
+ <div className='relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full pb-[66px] mx-auto mb-3.5 overflow-hidden'>
415
+ <div className='h-full overflow-y-auto' ref={chatListDomRef}>
416
+ <Chat
417
+ chatList={chatList}
418
+ onSend={handleSend}
419
+ onFeedback={handleFeedback}
420
+ isResponsing={isResponsing}
421
+ checkCanSend={checkCanSend}
422
+ controlFocus={controlFocus}
423
+ />
424
+ </div>
425
+ </div>)
426
+ }
427
+ </div>
428
+ </div>
429
+ </div>
430
+ )
431
+ }
432
+
433
+ export default React.memo(Main)
app/components/sidebar/card.module.css ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .card:hover {
2
+ background: linear-gradient(0deg, rgba(235, 245, 255, 0.4), rgba(235, 245, 255, 0.4)), #FFFFFF;
3
+ }
app/components/sidebar/card.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import s from './card.module.css'
4
+
5
+ type PropType = {
6
+ children: React.ReactNode
7
+ text?: string
8
+ }
9
+ function Card({ children, text }: PropType) {
10
+ const { t } = useTranslation()
11
+ return (
12
+ <div className={`${s.card} box-border w-full flex flex-col items-start px-4 py-3 rounded-lg border-solid border border-gray-200 cursor-pointer hover:border-primary-300`}>
13
+ <div className='text-gray-400 font-medium text-xs mb-2'>{text ?? t('app.chat.powerBy')}</div>
14
+ {children}
15
+ </div>
16
+ )
17
+ }
18
+
19
+ export default Card
app/components/sidebar/index.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import type { FC } from 'react'
3
+ import { useTranslation } from 'react-i18next'
4
+ import {
5
+ ChatBubbleOvalLeftEllipsisIcon,
6
+ PencilSquareIcon,
7
+ } from '@heroicons/react/24/outline'
8
+ import { ChatBubbleOvalLeftEllipsisIcon as ChatBubbleOvalLeftEllipsisSolidIcon } from '@heroicons/react/24/solid'
9
+ import Button from '@/app/components/base/button'
10
+ // import Card from './card'
11
+ import type { ConversationItem } from '@/types/app'
12
+
13
+ function classNames(...classes: any[]) {
14
+ return classes.filter(Boolean).join(' ')
15
+ }
16
+
17
+ const MAX_CONVERSATION_LENTH = 20
18
+
19
+ export type ISidebarProps = {
20
+ copyRight: string
21
+ currentId: string
22
+ onCurrentIdChange: (id: string) => void
23
+ list: ConversationItem[]
24
+ }
25
+
26
+ const Sidebar: FC<ISidebarProps> = ({
27
+ copyRight,
28
+ currentId,
29
+ onCurrentIdChange,
30
+ list,
31
+ }) => {
32
+ const { t } = useTranslation()
33
+ return (
34
+ <div
35
+ className="shrink-0 flex flex-col overflow-y-auto bg-white pc:w-[244px] tablet:w-[192px] mobile:w-[240px] border-r border-gray-200 tablet:h-[calc(100vh_-_3rem)] mobile:h-screen"
36
+ >
37
+ {list.length < MAX_CONVERSATION_LENTH && (
38
+ <div className="flex flex-shrink-0 p-4 !pb-0">
39
+ <Button
40
+ onClick={() => { onCurrentIdChange('-1') }}
41
+ className="group block w-full flex-shrink-0 !justify-start !h-9 text-primary-600 items-center text-sm">
42
+ <PencilSquareIcon className="mr-2 h-4 w-4" /> {t('app.chat.newChat')}
43
+ </Button>
44
+ </div>
45
+ )}
46
+
47
+ <nav className="mt-4 flex-1 space-y-1 bg-white p-4 !pt-0">
48
+ {list.map((item) => {
49
+ const isCurrent = item.id === currentId
50
+ const ItemIcon
51
+ = isCurrent ? ChatBubbleOvalLeftEllipsisSolidIcon : ChatBubbleOvalLeftEllipsisIcon
52
+ return (
53
+ <div
54
+ onClick={() => onCurrentIdChange(item.id)}
55
+ key={item.id}
56
+ className={classNames(
57
+ isCurrent
58
+ ? 'bg-primary-50 text-primary-600'
59
+ : 'text-gray-700 hover:bg-gray-100 hover:text-gray-700',
60
+ 'group flex items-center rounded-md px-2 py-2 text-sm font-medium cursor-pointer',
61
+ )}
62
+ >
63
+ <ItemIcon
64
+ className={classNames(
65
+ isCurrent
66
+ ? 'text-primary-600'
67
+ : 'text-gray-400 group-hover:text-gray-500',
68
+ 'mr-3 h-5 w-5 flex-shrink-0',
69
+ )}
70
+ aria-hidden="true"
71
+ />
72
+ {item.name}
73
+ </div>
74
+ )
75
+ })}
76
+ </nav>
77
+ {/* <a className="flex flex-shrink-0 p-4" href="https://langgenius.ai/" target="_blank">
78
+ <Card><div className="flex flex-row items-center"><ChatBubbleOvalLeftEllipsisSolidIcon className="text-primary-600 h-6 w-6 mr-2" /><span>LangGenius</span></div></Card>
79
+ </a> */}
80
+ <div className="flex flex-shrink-0 pr-4 pb-4 pl-4">
81
+ <div className="text-gray-400 font-normal text-xs">© {copyRight} {(new Date()).getFullYear()}</div>
82
+ </div>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ export default React.memo(Sidebar)
app/components/value-panel/index.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import type { FC, ReactNode } from 'react'
3
+ import React from 'react'
4
+ import cn from 'classnames'
5
+ import { useTranslation } from 'react-i18next'
6
+ import s from './style.module.css'
7
+ import { StarIcon } from '@/app/components//welcome/massive-component'
8
+ import Button from '@/app/components/base/button'
9
+
10
+ export type ITemplateVarPanelProps = {
11
+ className?: string
12
+ header: ReactNode
13
+ children?: ReactNode | null
14
+ isFold: boolean
15
+ }
16
+
17
+ const TemplateVarPanel: FC<ITemplateVarPanelProps> = ({
18
+ className,
19
+ header,
20
+ children,
21
+ isFold,
22
+ }) => {
23
+ return (
24
+ <div className={cn(isFold ? 'border border-indigo-100' : s.boxShodow, className, 'rounded-xl ')}>
25
+ {/* header */}
26
+ <div
27
+ className={cn(isFold && 'rounded-b-xl', 'rounded-t-xl px-6 py-4 bg-indigo-25 text-xs')}
28
+ >
29
+ {header}
30
+ </div>
31
+ {/* body */}
32
+ {!isFold && children && (
33
+ <div className='rounded-b-xl p-6'>
34
+ {children}
35
+ </div>
36
+ )}
37
+ </div>
38
+ )
39
+ }
40
+
41
+ export const PanelTitle: FC<{ title: string; className?: string }> = ({
42
+ title,
43
+ className,
44
+ }) => {
45
+ return (
46
+ <div className={cn(className, 'flex items-center space-x-1 text-indigo-600')}>
47
+ <StarIcon />
48
+ <span className='text-xs'>{title}</span>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export const VarOpBtnGroup: FC<{ className?: string; onConfirm: () => void; onCancel: () => void }> = ({
54
+ className,
55
+ onConfirm,
56
+ onCancel,
57
+ }) => {
58
+ const { t } = useTranslation()
59
+
60
+ return (
61
+ <div className={cn(className, 'flex mt-3 space-x-2 mobile:ml-0 tablet:ml-[128px] text-sm')}>
62
+ <Button
63
+ className='text-sm'
64
+ type='primary'
65
+ onClick={onConfirm}
66
+ >
67
+ {t('common.operation.save')}
68
+ </Button>
69
+ <Button
70
+ className='text-sm'
71
+ onClick={onCancel}
72
+ >
73
+ {t('common.operation.cancel')}
74
+ </Button>
75
+ </div >
76
+ )
77
+ }
78
+
79
+ export default React.memo(TemplateVarPanel)
app/components/value-panel/style.module.css ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .boxShodow {
2
+ box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
3
+ }