Add app files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .editorconfig +22 -0
- .eslintrc.json +28 -0
- .gitignore +49 -0
- .vscode/launch.json +28 -0
- .vscode/settings.json +32 -0
- DEVELOPER.md +67 -0
- Dockerfile +12 -0
- LICENSE +21 -0
- README.md +4 -3
- app/api/chat-messages/route.ts +17 -0
- app/api/conversations/route.ts +11 -0
- app/api/messages/[messageId]/feedbacks/route.ts +16 -0
- app/api/messages/route.ts +13 -0
- app/api/parameters/route.ts +11 -0
- app/api/utils/common.ts +21 -0
- app/api/utils/stream.ts +25 -0
- app/components/app-unavailable.tsx +31 -0
- app/components/base/app-icon/index.tsx +36 -0
- app/components/base/app-icon/style.module.css +15 -0
- app/components/base/auto-height-textarea/index.tsx +73 -0
- app/components/base/button/index.tsx +44 -0
- app/components/base/loading/index.tsx +31 -0
- app/components/base/loading/style.css +41 -0
- app/components/base/markdown.tsx +45 -0
- app/components/base/select/index.tsx +216 -0
- app/components/base/spinner/index.tsx +24 -0
- app/components/base/toast/index.tsx +131 -0
- app/components/base/toast/style.module.css +43 -0
- app/components/base/tooltip/index.tsx +46 -0
- app/components/chat/icons/answer.svg +3 -0
- app/components/chat/icons/default-avatar.jpg +0 -0
- app/components/chat/icons/edit.svg +3 -0
- app/components/chat/icons/question.svg +3 -0
- app/components/chat/icons/robot.svg +10 -0
- app/components/chat/icons/send-active.svg +3 -0
- app/components/chat/icons/send.svg +3 -0
- app/components/chat/icons/typing.svg +19 -0
- app/components/chat/icons/user.svg +10 -0
- app/components/chat/index.tsx +353 -0
- app/components/chat/loading-anim/index.tsx +16 -0
- app/components/chat/loading-anim/style.module.css +82 -0
- app/components/chat/style.module.css +91 -0
- app/components/config-scence/index.tsx +13 -0
- app/components/header.tsx +48 -0
- app/components/index.tsx +433 -0
- app/components/sidebar/card.module.css +3 -0
- app/components/sidebar/card.tsx +19 -0
- app/components/sidebar/index.tsx +87 -0
- app/components/value-panel/index.tsx +79 -0
- 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:
|
5 |
-
colorTo:
|
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 |
+
}
|