From 8acd8f6fbd5e82806b57401ec6e3b275e18a0b9a Mon Sep 17 00:00:00 2001 From: Luyu Zhang Date: Wed, 12 Apr 2023 22:37:12 +0800 Subject: [PATCH 01/85] Initial commit --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..873e44b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 LangGenius + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 97d3a6277deccf9051f04a2f14562329e420320b Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 14 Apr 2023 20:35:08 +0800 Subject: [PATCH 02/85] feat: init --- .editorconfig | 22 + .eslintrc.json | 28 + .gitignore | 49 + .vscode/launch.json | 28 + .vscode/settings.json | 27 + README.md | 53 + app/api/chat-messages/route.ts | 17 + app/api/conversations/route.ts | 11 + app/api/messages/route.ts | 13 + app/api/parameters/route.ts | 11 + app/api/sdk.js | 102 ++ app/api/site/route.ts | 11 + app/api/utils/common.ts | 20 + app/api/utils/stream.ts | 25 + app/components/app-unavailable.tsx | 31 + app/components/base/app-icon/index.tsx | 36 + app/components/base/app-icon/style.module.css | 15 + .../base/auto-height-textarea/index.tsx | 73 ++ app/components/base/button/index.tsx | 44 + app/components/base/loading/index.tsx | 31 + app/components/base/loading/style.css | 41 + app/components/base/markdown.tsx | 45 + app/components/base/select/index.tsx | 216 ++++ app/components/base/spinner/index.tsx | 24 + app/components/base/toast/index.tsx | 131 +++ app/components/base/toast/style.module.css | 43 + app/components/base/tooltip/index.tsx | 46 + app/components/chat/icons/answer.svg | 3 + app/components/chat/icons/default-avatar.jpg | Bin 0 -> 2183 bytes app/components/chat/icons/edit.svg | 3 + app/components/chat/icons/question.svg | 3 + app/components/chat/icons/robot.svg | 10 + app/components/chat/icons/send-active.svg | 3 + app/components/chat/icons/send.svg | 3 + app/components/chat/icons/typing.svg | 19 + app/components/chat/icons/user.svg | 10 + app/components/chat/index.tsx | 336 ++++++ app/components/chat/style.module.css | 90 ++ app/components/config-scence/index.tsx | 13 + app/components/header.tsx | 48 + app/components/index.tsx | 461 ++++++++ app/components/sidebar/card.module.css | 3 + app/components/sidebar/card.tsx | 19 + app/components/sidebar/index.tsx | 87 ++ app/components/value-panel/index.tsx | 79 ++ app/components/value-panel/style.module.css | 3 + app/components/welcome/index.tsx | 339 ++++++ app/components/welcome/massive-component.tsx | 90 ++ app/components/welcome/style.module.css | 22 + app/layout.tsx | 25 + app/page.tsx | 15 + app/styles/globals.css | 128 ++ app/styles/markdown.scss | 1041 +++++++++++++++++ config/index.ts | 25 + hooks/use-breakpoints.ts | 27 + hooks/use-conversation.ts | 66 ++ i18n/client.ts | 18 + i18n/i18next-config.ts | 38 + i18n/i18next-serverside-config.ts | 26 + i18n/index.ts | 6 + i18n/lang/app.en.ts | 33 + i18n/lang/app.zh.ts | 28 + i18n/lang/common.en.ts | 22 + i18n/lang/common.zh.ts | 22 + i18n/server.ts | 29 + next.config.js | 21 + package.json | 68 ++ postcss.config.js | 6 + public/favicon.ico | Bin 0 -> 15406 bytes service/base.ts | 223 ++++ service/index.ts | 37 + tailwind.config.js | 66 ++ tsconfig.json | 42 + types/app.ts | 75 ++ typography.js | 357 ++++++ utils/prompt.ts | 12 + utils/string.ts | 6 + 77 files changed, 5299 insertions(+) create mode 100644 .editorconfig create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 app/api/chat-messages/route.ts create mode 100644 app/api/conversations/route.ts create mode 100644 app/api/messages/route.ts create mode 100644 app/api/parameters/route.ts create mode 100644 app/api/sdk.js create mode 100644 app/api/site/route.ts create mode 100644 app/api/utils/common.ts create mode 100644 app/api/utils/stream.ts create mode 100644 app/components/app-unavailable.tsx create mode 100644 app/components/base/app-icon/index.tsx create mode 100644 app/components/base/app-icon/style.module.css create mode 100644 app/components/base/auto-height-textarea/index.tsx create mode 100644 app/components/base/button/index.tsx create mode 100644 app/components/base/loading/index.tsx create mode 100644 app/components/base/loading/style.css create mode 100644 app/components/base/markdown.tsx create mode 100644 app/components/base/select/index.tsx create mode 100644 app/components/base/spinner/index.tsx create mode 100644 app/components/base/toast/index.tsx create mode 100644 app/components/base/toast/style.module.css create mode 100644 app/components/base/tooltip/index.tsx create mode 100644 app/components/chat/icons/answer.svg create mode 100644 app/components/chat/icons/default-avatar.jpg create mode 100644 app/components/chat/icons/edit.svg create mode 100644 app/components/chat/icons/question.svg create mode 100644 app/components/chat/icons/robot.svg create mode 100644 app/components/chat/icons/send-active.svg create mode 100644 app/components/chat/icons/send.svg create mode 100644 app/components/chat/icons/typing.svg create mode 100644 app/components/chat/icons/user.svg create mode 100644 app/components/chat/index.tsx create mode 100644 app/components/chat/style.module.css create mode 100644 app/components/config-scence/index.tsx create mode 100644 app/components/header.tsx create mode 100644 app/components/index.tsx create mode 100644 app/components/sidebar/card.module.css create mode 100644 app/components/sidebar/card.tsx create mode 100644 app/components/sidebar/index.tsx create mode 100644 app/components/value-panel/index.tsx create mode 100644 app/components/value-panel/style.module.css create mode 100644 app/components/welcome/index.tsx create mode 100644 app/components/welcome/massive-component.tsx create mode 100644 app/components/welcome/style.module.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/styles/globals.css create mode 100644 app/styles/markdown.scss create mode 100644 config/index.ts create mode 100644 hooks/use-breakpoints.ts create mode 100644 hooks/use-conversation.ts create mode 100644 i18n/client.ts create mode 100644 i18n/i18next-config.ts create mode 100644 i18n/i18next-serverside-config.ts create mode 100644 i18n/index.ts create mode 100644 i18n/lang/app.en.ts create mode 100644 i18n/lang/app.zh.ts create mode 100644 i18n/lang/common.en.ts create mode 100644 i18n/lang/common.zh.ts create mode 100644 i18n/server.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/favicon.ico create mode 100644 service/base.ts create mode 100644 service/index.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 types/app.ts create mode 100644 typography.js create mode 100644 utils/prompt.ts create mode 100644 utils/string.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e1d3f0b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Matches multiple files with brace expansion notation +# Set default charset +[*.{js,tsx}] +charset = utf-8 +indent_style = space +indent_size = 2 + + +# Matches the exact files either package.json or .travis.yml +[{package.json,.travis.yml}] +indent_style = space +indent_size = 2 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..9d8ea2c --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,28 @@ +{ + "extends": [ + "@antfu", + "plugin:react-hooks/recommended" + ], + "rules": { + "@typescript-eslint/consistent-type-definitions": [ + "error", + "type" + ], + "no-console": "off", + "indent": "off", + "@typescript-eslint/indent": [ + "error", + 2, + { + "SwitchCase": 1, + "flatTernaryExpressions": false, + "ignoredNodes": [ + "PropertyDefinition[decorators]", + "TSUnionType", + "FunctionExpression[params]:has(Identifier[decorators])" + ] + } + ], + "react-hooks/exhaustive-deps": "warn" + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c01328 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# npm +package-lock.json + +# yarn +.pnp.cjs +.pnp.loader.mjs +.yarn/ +yarn.lock +.yarnrc.yml + +# pmpm +pnpm-lock.yaml \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..9e36291 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.1.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev", + "serverReadyAction": { + "pattern": "started server on .+, url: (https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..edbfba9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "typescript.tsdk": ".yarn/cache/typescript-patch-72dc6f164f-ab417a2f39.zip/node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "prettier.enable": false, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "[python]": { + "editor.formatOnType": true + }, + "[html]": { + "editor.defaultFormatter": "vscode.html-language-features" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features" + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6e6d1e --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Conversion Web App Template +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Set App Info +Set app info in `config/index.ts`. Includes: +- APP_ID +- API_KEY +- APP_INFO + +## Getting Started +First, install dependencies: +```bash +npm install +# or +yarn +# or +pnpm install +``` + +Then, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. + +The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +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. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/app/api/chat-messages/route.ts b/app/api/chat-messages/route.ts new file mode 100644 index 0000000..a046f95 --- /dev/null +++ b/app/api/chat-messages/route.ts @@ -0,0 +1,17 @@ +import { type NextRequest } from 'next/server' +import { getInfo, client } from '@/app/api/utils/common' +import { OpenAIStream } from '@/app/api/utils/stream' + +export async function POST(request: NextRequest) { + const body = await request.json() + const { + inputs, + query, + conversation_id: conversationId, + response_mode: responseMode + } = body + const { user } = getInfo(request); + const res = await client.createChatMessage(inputs, query, user, responseMode, conversationId) + const stream = await OpenAIStream(res as any) + return new Response(stream as any) +} \ No newline at end of file diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts new file mode 100644 index 0000000..9a73a3d --- /dev/null +++ b/app/api/conversations/route.ts @@ -0,0 +1,11 @@ +import { type NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getInfo, setSession, client } from '@/app/api/utils/common' + +export async function GET(request: NextRequest) { + const { sessionId, user } = getInfo(request); + const { data }: any = await client.getConversations(user); + return NextResponse.json(data, { + headers: setSession(sessionId) + }) +} \ No newline at end of file diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 0000000..57a027c --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,13 @@ +import { type NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getInfo, setSession, client } from '@/app/api/utils/common' + +export async function GET(request: NextRequest) { + const { sessionId, user } = getInfo(request); + const { searchParams } = new URL(request.url); + const conversationId = searchParams.get('conversation_id') + const { data }: any = await client.getConversationMessages(user, conversationId as string); + return NextResponse.json(data, { + headers: setSession(sessionId) + }) +} \ No newline at end of file diff --git a/app/api/parameters/route.ts b/app/api/parameters/route.ts new file mode 100644 index 0000000..1c1d917 --- /dev/null +++ b/app/api/parameters/route.ts @@ -0,0 +1,11 @@ +import { type NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getInfo, setSession, client } from '@/app/api/utils/common' + +export async function GET(request: NextRequest) { + const { sessionId, user } = getInfo(request); + const { data } = await client.getApplicationParameters(user); + return NextResponse.json(data as object, { + headers: setSession(sessionId) + }) +} \ No newline at end of file diff --git a/app/api/sdk.js b/app/api/sdk.js new file mode 100644 index 0000000..6e8c51d --- /dev/null +++ b/app/api/sdk.js @@ -0,0 +1,102 @@ +import axios from 'axios' + +export class LangGeniusClient { + constructor(apiKey, baseUrl = 'https://api.langgenius.ai/v1') { + this.apiKey = apiKey + this.baseUrl = baseUrl + } + + async sendRequest(method, endpoint, data = null, params = null, stream = false) { + const headers = { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + } + + const url = `${this.baseUrl}${endpoint}` + let response + if (!stream) { + response = await axios({ + method, + url, + data, + params, + headers, + responseType: stream ? 'stream' : 'json', + }) + } else { + response = await fetch(url, { + headers, + method, + body: JSON.stringify(data), + }) + } + + return response + } + + messageFeedback(messageId, rating, user) { + const data = { + rating, + user, + } + return this.sendRequest('POST', `/messages/${messageId}/feedbacks`, data) + } + + getApplicationParameters(user) { + const params = { user } + return this.sendRequest('GET', '/parameters', null, params) + } +} + +export class CompletionClient extends LangGeniusClient { + createCompletionMessage(inputs, query, responseMode, user) { + const data = { + inputs, + query, + responseMode, + user, + } + return this.sendRequest('POST', '/completion-messages', data, null, responseMode === 'streaming') + } +} + +export class ChatClient extends LangGeniusClient { + createChatMessage(inputs, query, user, responseMode = 'blocking', conversationId = null) { + const data = { + inputs, + query, + user, + responseMode, + } + if (conversationId) + data.conversation_id = conversationId + + return this.sendRequest('POST', '/chat-messages', data, null, responseMode === 'streaming') + } + + getConversationMessages(user, conversationId = '', firstId = null, limit = null) { + const params = { user } + + if (conversationId) + params.conversation_id = conversationId + + if (firstId) + params.first_id = firstId + + if (limit) + params.limit = limit + + return this.sendRequest('GET', '/messages', null, params) + } + + getConversations(user, firstId = null, limit = null, pinned = null) { + const params = { user, first_id: firstId, limit, pinned } + return this.sendRequest('GET', '/conversations', null, params) + } + + renameConversation(conversationId, name, user) { + const data = { name, user } + return this.sendRequest('PATCH', `/conversations/${conversationId}`, data) + } +} + diff --git a/app/api/site/route.ts b/app/api/site/route.ts new file mode 100644 index 0000000..86aff37 --- /dev/null +++ b/app/api/site/route.ts @@ -0,0 +1,11 @@ +import { type NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getInfo, setSession } from '@/app/api/utils/common' +import { APP_INFO } from '@/config' + +export async function GET(request: NextRequest) { + const { sessionId } = getInfo(request); + return NextResponse.json(APP_INFO, { + headers: setSession(sessionId) + }) +} \ No newline at end of file diff --git a/app/api/utils/common.ts b/app/api/utils/common.ts new file mode 100644 index 0000000..6a3a46a --- /dev/null +++ b/app/api/utils/common.ts @@ -0,0 +1,20 @@ +import { type NextRequest } from 'next/server' +import { APP_ID, API_KEY } from '@/config' +import { ChatClient } from '../sdk' +const userPrefix = `user_${APP_ID}:`; +const uuid = require('uuid') + +export const getInfo = (request: NextRequest) => { + const sessionId = request.cookies.get('session_id')?.value || uuid.v4(); + const user = userPrefix + sessionId; + return { + sessionId, + user + } +} + +export const setSession = (sessionId: string) => { + return { 'Set-Cookie': `session_id=${sessionId}` } +} + +export const client = new ChatClient(API_KEY) \ No newline at end of file diff --git a/app/api/utils/stream.ts b/app/api/utils/stream.ts new file mode 100644 index 0000000..2da1359 --- /dev/null +++ b/app/api/utils/stream.ts @@ -0,0 +1,25 @@ +export async function OpenAIStream(res: { body: any }) { + const reader = res.body.getReader(); + + const stream = new ReadableStream({ + // https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams + // https://github.com/whichlight/chatgpt-api-streaming/blob/master/pages/api/OpenAIStream.ts + start(controller) { + return pump(); + function pump() { + return reader.read().then(({ done, value }: any) => { + // When no more data needs to be consumed, close the stream + if (done) { + controller.close(); + return; + } + // Enqueue the next data chunk into our target stream + controller.enqueue(value); + return pump(); + }); + } + }, + }); + + return stream; +} \ No newline at end of file diff --git a/app/components/app-unavailable.tsx b/app/components/app-unavailable.tsx new file mode 100644 index 0000000..ce4d7c7 --- /dev/null +++ b/app/components/app-unavailable.tsx @@ -0,0 +1,31 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' + +type IAppUnavailableProps = { + isUnknwonReason: boolean + errMessage?: string +} + +const AppUnavailable: FC = ({ + isUnknwonReason, + errMessage, +}) => { + const { t } = useTranslation() + let message = errMessage + if (!errMessage) { + message = (isUnknwonReason ? t('app.common.appUnkonwError') : t('app.common.appUnavailable')) as string + } + + return ( +
+

{(errMessage || isUnknwonReason) ? 500 : 404}

+
{message}
+
+ ) +} +export default React.memo(AppUnavailable) diff --git a/app/components/base/app-icon/index.tsx b/app/components/base/app-icon/index.tsx new file mode 100644 index 0000000..b70991b --- /dev/null +++ b/app/components/base/app-icon/index.tsx @@ -0,0 +1,36 @@ +import type { FC } from 'react' +import classNames from 'classnames' +import style from './style.module.css' + +export type AppIconProps = { + size?: 'tiny' | 'small' | 'medium' | 'large' + rounded?: boolean + icon?: string + background?: string + className?: string +} + +const AppIcon: FC = ({ + size = 'medium', + rounded = false, + background, + className, +}) => { + return ( + + 🤖 + + ) +} + +export default AppIcon diff --git a/app/components/base/app-icon/style.module.css b/app/components/base/app-icon/style.module.css new file mode 100644 index 0000000..43098fd --- /dev/null +++ b/app/components/base/app-icon/style.module.css @@ -0,0 +1,15 @@ +.appIcon { + @apply flex items-center justify-center relative w-9 h-9 text-lg bg-teal-100 rounded-lg grow-0 shrink-0; +} +.appIcon.large { + @apply w-10 h-10; +} +.appIcon.small { + @apply w-8 h-8; +} +.appIcon.tiny { + @apply w-6 h-6 text-base; +} +.appIcon.rounded { + @apply rounded-full; +} diff --git a/app/components/base/auto-height-textarea/index.tsx b/app/components/base/auto-height-textarea/index.tsx new file mode 100644 index 0000000..0fe7ef8 --- /dev/null +++ b/app/components/base/auto-height-textarea/index.tsx @@ -0,0 +1,73 @@ +import { forwardRef, useEffect, useRef } from 'react' +import cn from 'classnames' + +type IProps = { + placeholder?: string + value: string + onChange: (e: React.ChangeEvent) => void + className?: string + minHeight?: number + maxHeight?: number + autoFocus?: boolean + controlFocus?: number + onKeyDown?: (e: React.KeyboardEvent) => void + onKeyUp?: (e: React.KeyboardEvent) => void +} + +const AutoHeightTextarea = forwardRef( + ( + { value, onChange, placeholder, className, minHeight = 36, maxHeight = 96, autoFocus, controlFocus, onKeyDown, onKeyUp }: IProps, + outerRef: any, + ) => { + const ref = outerRef || useRef(null) + + const doFocus = () => { + if (ref.current) { + ref.current.setSelectionRange(value.length, value.length) + ref.current.focus() + return true + } + return false + } + + const focus = () => { + if (!doFocus()) { + let hasFocus = false + const runId = setInterval(() => { + hasFocus = doFocus() + if (hasFocus) + clearInterval(runId) + }, 100) + } + } + + useEffect(() => { + if (autoFocus) + focus() + }, []) + useEffect(() => { + if (controlFocus) + focus() + }, [controlFocus]) + + return ( +
+
+ {!value ? placeholder : value.replace(/\n$/, '\n ')} +
+ + {files.length > 0 && ( +
+ +
+ )} +
+
+ + + + {effectiveVisionSettings.enabled && ( + <> + + + )} +
+
+ + +
+
+ + + + ); +}; +export default Chat; diff --git a/src/components/layouts/chat/icons/answer.svg b/src/components/layouts/chat/icons/answer.svg new file mode 100644 index 0000000..e983039 --- /dev/null +++ b/src/components/layouts/chat/icons/answer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/layouts/chat/icons/default-avatar.jpg b/src/components/layouts/chat/icons/default-avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..396d5dd291b44bc440da35e655774626ce1bae20 GIT binary patch literal 2183 zcmbW1dpy*67stQ9-^?(^ZColeZo6z$YLw_{G2>R`(&Q3P$P|@rz+)uxgs0ag5l@gWAD)E zztA`D(UG@k-xAs}i#AW9jT7jzDYRxB?V3j?-lLUc=<^x0WeRPXMk`;VBg<&>6xuO| zb}yh4D;AvqNG~|pTDf0@7JHs$CenyFYW-Zs4OXfDLKrKrNVU{m6_R}8|2gx#r8V7_ zu#RojVL#RQ^_7(FO6M(2WbLlCYc%WKR~`}8&U|cF?a^;Ns!%OLhU>L^?P$zv1;p%G z;61YPTTv*Zz;6*4y`u4?_~K zQqbJ?=)s-S8cffbilaX37m=ysrYVl*ZC+K$zFQO-IkG9;=&CG~qoL@L+ni()Yd%o% zvnHQe&R&+4=^Ilh6A!WwU<|+tO6W#N=$?G;$wJ^uVIyo%C#K$c!5XQw_I)S z1Z2~8Y6RVt5hU<2wE>~zogfg01G-cY>fi+gcsw`*rN^gX?!Y5suEKPND8Yp$Q>fw& zZ^vXJS~39wyEbSKKk}EC&>rej@0x8%1COglR2)Zc;cDc9H&0qTK1fq3RC0$6;234W zA3AQ}1}I1fHFlCOB)DUWV88AMpUh^grja4g4^=`9gp+E;vOUtZa^r^)W5kL|D3~zp z3DwZ+eWb`TWL&70N%neE z!NIUhD180*HV}?1+uX+0C{}h@}s%j=J}k-0&IZxj?WE2aP&fmv==Lr{$?$M*J9I~ zRzhKPj-q>6>B)1Er=vgRh+urbz81GJEA3QiH!HYcSJ>HX-@!er`-FuqrzdU=Pf}RC z9MMcyzrLjp8sX`BUD}rA&CPW@QAIV&{5>dMJfNE2TmS2GN6O3dB9D#Kr*S8_sg2z4 z7hklONheu~6BuS~N0PY!-e_8;!G6yFs0$hSU; z8rqcK6JtqA9%>znm`_iBXlR}h#Uh=HDSJiEuWpbZa86!TWnR!w815wWHMHCrjkFrv zY>cWZ_Gxx82I17PJ}-y!W7Ex-A|DxvozL5`Bb-{@f}YOp&pdRBskSz7Gmay#by}-# z;J#3xDh$&2G04ke2*dQGk(IAAKC>JD>9Z{S6Ke^1 zLrHXuvT#nLTs@F0Y9wB6^4-Exep0;FUg9!kclBL1nSgd9^OVY=o7)1rPfcVJYUM$LRTv;Exn$M!-o7v3J_wrUk8QOZ9G8#Y)zjTfhM&d$$ z1<^2t>M^pWv(fU%F)C&};H|Khe(wN|9==85XK`F>0hT6NqC&a0OE}~aOjYLsnv?;! z34I56P`X6Zf$}^Fn#EE2NamTmm|~5|Ih;QDgNq%n!j{)>VePUUR5D-FmjCi-i>y^h)bkP=}A~&jR21OCU8Ak1&9yfi68+$)PD$nKMjx- zF31^Roven4k~|0{0fj_)XcDON4d^o6>98DRuByrI zxQ86EdQH;7v_Uo;TDGM`_aI-NR`aA#h3h$an905aGB3}EDU-q_WAbB1?ADuZE5qFS s87)kBr-f92fg@+YmECigj^Xs982;qb2t(s+qc0?Iuye63wWhQG290UxH2?qr literal 0 HcmV?d00001 diff --git a/src/components/layouts/chat/icons/edit.svg b/src/components/layouts/chat/icons/edit.svg new file mode 100644 index 0000000..a922970 --- /dev/null +++ b/src/components/layouts/chat/icons/edit.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/layouts/chat/icons/question.svg b/src/components/layouts/chat/icons/question.svg new file mode 100644 index 0000000..39904f5 --- /dev/null +++ b/src/components/layouts/chat/icons/question.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/layouts/chat/icons/robot.svg b/src/components/layouts/chat/icons/robot.svg new file mode 100644 index 0000000..a50c888 --- /dev/null +++ b/src/components/layouts/chat/icons/robot.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/layouts/chat/icons/send-active.svg b/src/components/layouts/chat/icons/send-active.svg new file mode 100644 index 0000000..03d4734 --- /dev/null +++ b/src/components/layouts/chat/icons/send-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/layouts/chat/icons/send.svg b/src/components/layouts/chat/icons/send.svg new file mode 100644 index 0000000..e977ef9 --- /dev/null +++ b/src/components/layouts/chat/icons/send.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/layouts/chat/icons/typing.svg b/src/components/layouts/chat/icons/typing.svg new file mode 100644 index 0000000..7b28f0e --- /dev/null +++ b/src/components/layouts/chat/icons/typing.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/components/layouts/chat/icons/user.svg b/src/components/layouts/chat/icons/user.svg new file mode 100644 index 0000000..556aaf7 --- /dev/null +++ b/src/components/layouts/chat/icons/user.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/layouts/chat/loading-anim/index.tsx b/src/components/layouts/chat/loading-anim/index.tsx new file mode 100644 index 0000000..2b885fc --- /dev/null +++ b/src/components/layouts/chat/loading-anim/index.tsx @@ -0,0 +1,13 @@ +'use client'; +import type { FC } from 'react'; +import React from 'react'; +import s from './style.module.css'; + +export type ILoaidingAnimProps = { + type: 'text' | 'avatar'; +}; + +const LoaidingAnim: FC = ({ type }) => { + return
; +}; +export default React.memo(LoaidingAnim); diff --git a/src/components/layouts/chat/loading-anim/style.module.css b/src/components/layouts/chat/loading-anim/style.module.css new file mode 100644 index 0000000..1c1bb45 --- /dev/null +++ b/src/components/layouts/chat/loading-anim/style.module.css @@ -0,0 +1,82 @@ +.dot-flashing { + position: relative; + animation: 1s infinite linear alternate; + animation-delay: 0.5s; +} + +.dot-flashing::before, +.dot-flashing::after { + content: ''; + display: inline-block; + position: absolute; + top: 0; + animation: 1s infinite linear alternate; +} + +.dot-flashing::before { + animation-delay: 0s; +} + +.dot-flashing::after { + animation-delay: 1s; +} + +@keyframes dot-flashing { + 0% { + background-color: #667085; + } + + 50%, + 100% { + background-color: rgba(102, 112, 133, 0.3); + } +} + +@keyframes dot-flashing-avatar { + 0% { + background-color: #155eef; + } + + 50%, + 100% { + background-color: rgba(21, 94, 239, 0.3); + } +} + +.text, +.text::before, +.text::after { + width: 4px; + height: 4px; + border-radius: 50%; + background-color: #667085; + color: #667085; + animation-name: dot-flashing; +} + +.text::before { + left: -7px; +} + +.text::after { + left: 7px; +} + +.avatar, +.avatar::before, +.avatar::after { + width: 2px; + height: 2px; + border-radius: 50%; + background-color: #155eef; + color: #155eef; + animation-name: dot-flashing-avatar; +} + +.avatar::before { + left: -5px; +} + +.avatar::after { + left: 5px; +} diff --git a/src/components/layouts/chat/question/index.tsx b/src/components/layouts/chat/question/index.tsx new file mode 100644 index 0000000..3f0cb61 --- /dev/null +++ b/src/components/layouts/chat/question/index.tsx @@ -0,0 +1,37 @@ +'use client'; +import type { FC } from 'react'; +import React from 'react'; +import type { IChatItem } from '../type'; +import style from './style.module.scss'; + +import { Markdown } from '@/components/base/markdown'; +import ImageGallery from '@/components/base/image-gallery'; + +type IQuestionProps = Pick & { + imgSrcs?: string[]; +}; + +const Question: FC = ({ id, content, useCurrentUserAvatar, imgSrcs }) => { + const userName = ''; + return ( +
+
+
+
+ {imgSrcs && imgSrcs.length > 0 && } + +
+
+
+ {useCurrentUserAvatar ? ( +
+ {userName?.[0].toLocaleUpperCase()} +
+ ) : ( +
+ )} +
+ ); +}; + +export default React.memo(Question); diff --git a/src/components/layouts/chat/question/style.module.scss b/src/components/layouts/chat/question/style.module.scss new file mode 100644 index 0000000..5919f54 --- /dev/null +++ b/src/components/layouts/chat/question/style.module.scss @@ -0,0 +1,37 @@ +.question-box { + display: flex; + justify-content: flex-end; + align-items: flex-start; + margin: 40px 0; + .question { + position: relative; + font-size: 14px; + line-height: 1.4; + color: black; + &::before { + content: ''; + position: absolute; + top: 0; + width: 8px; + height: 12px; + right: 0; + background: url(../icons/question.svg) no-repeat; + } + .question-child { + margin-right: 8px; + padding: 12px 16px; + background-color: #e1effe; + border-top-left-radius: 16px; + border-bottom-right-radius: 16px; + border-bottom-left-radius: 16px; + } + } + .questionIcon { + background: url(../icons/default-avatar.jpg); + background-size: contain; + border-radius: 50%; + width: 40px; + height: 40px; + flex-shrink: 0; + } +} diff --git a/src/components/layouts/chat/thought/index.tsx b/src/components/layouts/chat/thought/index.tsx new file mode 100644 index 0000000..7cf74f5 --- /dev/null +++ b/src/components/layouts/chat/thought/index.tsx @@ -0,0 +1,48 @@ +'use client'; +import type { FC } from 'react'; +import React from 'react'; +import type { ThoughtItem, ToolInfoInThought } from '../type'; +import Tool from './tool'; +import type { Emoji } from '@/types/tools'; + +export type IThoughtProps = { + thought: ThoughtItem; + allToolIcons: Record; + isFinished: boolean; +}; + +function getValue(value: string, isValueArray: boolean, index: number) { + if (isValueArray) { + try { + return JSON.parse(value)[index]; + } catch (e) {} + } + return value; +} + +const Thought: FC = ({ thought, allToolIcons, isFinished }) => { + const [toolNames, isValueArray]: [string[], boolean] = (() => { + try { + if (Array.isArray(JSON.parse(thought.tool))) return [JSON.parse(thought.tool), true]; + } catch (e) {} + return [[thought.tool], false]; + })(); + + const toolThoughtList = toolNames.map((toolName, index) => { + return { + name: toolName, + input: getValue(thought.tool_input, isValueArray, index), + output: getValue(thought.observation, isValueArray, index), + isFinished + }; + }); + + return ( +
+ {toolThoughtList.map((item: ToolInfoInThought, index) => ( + + ))} +
+ ); +}; +export default React.memo(Thought); diff --git a/src/components/layouts/chat/thought/panel.tsx b/src/components/layouts/chat/thought/panel.tsx new file mode 100644 index 0000000..c04dfa9 --- /dev/null +++ b/src/components/layouts/chat/thought/panel.tsx @@ -0,0 +1,24 @@ +'use client'; +import type { FC } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +type Props = { + isRequest: boolean; + toolName: string; + content: string; +}; + +const Panel: FC = ({ isRequest, toolName, content }) => { + const { t } = useTranslation(); + + return ( +
+
+ {t(`tools.thought.${isRequest ? 'requestTitle' : 'responseTitle'}`)} {toolName} +
+
{content}
+
+ ); +}; +export default React.memo(Panel); diff --git a/src/components/layouts/chat/thought/style.module.css b/src/components/layouts/chat/thought/style.module.css new file mode 100644 index 0000000..eb281de --- /dev/null +++ b/src/components/layouts/chat/thought/style.module.css @@ -0,0 +1,9 @@ +.wrap { + background-color: rgba(255, 255, 255, 0.92); +} + +.wrapHoverEffect:hover { + box-shadow: + 0px 1px 2px 0px rgba(16, 24, 40, 0.06), + 0px 1px 3px 0px rgba(16, 24, 40, 0.1); +} diff --git a/src/components/layouts/chat/thought/tool.tsx b/src/components/layouts/chat/thought/tool.tsx new file mode 100644 index 0000000..b3d28dd --- /dev/null +++ b/src/components/layouts/chat/thought/tool.tsx @@ -0,0 +1,82 @@ +'use client'; +import type { FC } from 'react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import cn from 'classnames'; +import type { ToolInfoInThought } from '../type'; +import Panel from './panel'; +import Loading02 from '@/components/base/icons/line/loading-02'; +import ChevronDown from '@/components/base/icons/line/arrows/chevron-down'; +import CheckCircle from '@/components/base/icons/solid/general/check-circle'; +import DataSetIcon from '@/components/base/icons/public/data-set'; +import type { Emoji } from '@/types/tools'; +import AppIcon from '@/components/base/app-icon'; + +type Props = { + payload: ToolInfoInThought; + allToolIcons?: Record; +}; + +const getIcon = (toolName: string, allToolIcons: Record) => { + if (toolName.startsWith('dataset-')) return ; + const icon = allToolIcons[toolName]; + if (!icon) return null; + return typeof icon === 'string' ? ( +
+ ) : ( + + ); +}; + +const Tool: FC = ({ payload, allToolIcons = {} }) => { + const { t } = useTranslation(); + const { name, input, isFinished, output } = payload; + const toolName = name.startsWith('dataset-') ? t('dataset.knowledge') : name; + const [isShowDetail, setIsShowDetail] = useState(false); + const icon = getIcon(toolName, allToolIcons) as any; + return ( +
+
+
setIsShowDetail(!isShowDetail)} + > + {!isFinished && } + {isFinished && !isShowDetail && } + {isFinished && isShowDetail && icon} + + {t(`tools.thought.${isFinished ? 'used' : 'using'}`)} + + + {toolName} + + +
+ {isShowDetail && ( +
+ + {output && } +
+ )} +
+
+ ); +}; +export default React.memo(Tool); diff --git a/src/components/layouts/chat/type.ts b/src/components/layouts/chat/type.ts new file mode 100644 index 0000000..515e7e4 --- /dev/null +++ b/src/components/layouts/chat/type.ts @@ -0,0 +1,134 @@ +import type { VisionFile } from '@/types/app'; + +export type LogAnnotation = { + content: string; + account: { + id: string; + name: string; + email: string; + }; + created_at: number; +}; + +export type Annotation = { + id: string; + authorName: string; + logAnnotation?: LogAnnotation; + created_at?: number; +}; + +export const MessageRatings = ['like', 'dislike', null] as const; +export type MessageRating = (typeof MessageRatings)[number]; + +export type MessageMore = { + time: string; + tokens: number; + latency: number | string; +}; + +export type Feedbacktype = { + rating: MessageRating; + content?: string | null; +}; + +export type FeedbackFunc = (messageId: string, feedback: Feedbacktype) => Promise; +export type SubmitAnnotationFunc = (messageId: string, content: string) => Promise; + +export type DisplayScene = 'web' | 'console'; + +export type ToolInfoInThought = { + name: string; + input: string; + output: string; + isFinished: boolean; +}; + +export type ThoughtItem = { + id: string; + tool: string; // plugin or dataset. May has multi. + thought: string; + tool_input: string; + message_id: string; + observation: string; + position: number; + files?: string[]; + message_files?: VisionFile[]; +}; + +export type CitationItem = { + content: string; + data_source_type: string; + dataset_name: string; + dataset_id: string; + document_id: string; + document_name: string; + hit_count: number; + index_node_hash: string; + segment_id: string; + segment_position: number; + score: number; + word_count: number; +}; + +export type IChatItem = { + id: string; + content: string; + citation?: CitationItem[]; + /** + * Specific message type + */ + isAnswer: boolean; + /** + * The user feedback result of this message + */ + feedback?: Feedbacktype; + /** + * The admin feedback result of this message + */ + adminFeedback?: Feedbacktype; + /** + * Whether to hide the feedback area + */ + feedbackDisabled?: boolean; + /** + * More information about this message + */ + more?: MessageMore; + annotation?: Annotation; + useCurrentUserAvatar?: boolean; + isOpeningStatement?: boolean; + suggestedQuestions?: string[]; + log?: { role: string; text: string }[]; + agent_thoughts?: ThoughtItem[]; + message_files?: VisionFile[]; +}; + +export type MessageEnd = { + id: string; + metadata: { + retriever_resources?: CitationItem[]; + annotation_reply: { + id: string; + account: { + id: string; + name: string; + }; + }; + }; +}; + +export type MessageReplace = { + id: string; + task_id: string; + answer: string; + conversation_id: string; +}; + +export type AnnotationReply = { + id: string; + task_id: string; + answer: string; + conversation_id: string; + annotation_id: string; + annotation_author_name: string; +}; diff --git a/src/components/layouts/footer/footer.module.scss b/src/components/layouts/footer/footer.module.scss new file mode 100644 index 0000000..51e6d75 --- /dev/null +++ b/src/components/layouts/footer/footer.module.scss @@ -0,0 +1,57 @@ +@use '@/styles/mixins.scss' as *; +@use '@/styles/variables.scss' as *; +@use '@/styles/responsive.scss' as *; + +.footer { + background: $background-dark; + padding: 26px 167px; + .footer-container { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + border-top: 1px solid rgba(109, 117, 143, 0.5); + padding-top: 24px; + .copyright-text { + font-size: 16px; + line-height: 1.375; + color: $text-muted; + margin: 0; + } + } +} + +.social-media-links { + display: flex; + gap: 16px; + li { + background-color: $primary-color; + border-radius: 4px; + width: 24px; + height: 24px; + a { + display: block; + width: inherit; + height: inherit; + position: relative; + } + img { + width: 12px; + height: 12px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } +} +// responsive +@include small { + .footer { + padding: 26px 30px; + .footer-container { + flex-direction: column; + gap: 24px; + } + } +} diff --git a/src/components/layouts/footer/footer.tsx b/src/components/layouts/footer/footer.tsx new file mode 100644 index 0000000..374b576 --- /dev/null +++ b/src/components/layouts/footer/footer.tsx @@ -0,0 +1,45 @@ +import FooterStyles from './footer.module.scss'; +import { FC } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; + +const Footer: FC = () => { + return ( + <> +
+
+

Copyright © 2025 Gamma | All Rights Reserved

+
    +
  • + + icons-facebook + +
  • +
  • + + icons-twitter + +
  • +
  • + + icons-instagram + +
  • +
  • + + icons-linkedIn + +
  • +
  • + + icons-youtube + +
  • +
+
+
+ + ); +}; + +export default Footer; diff --git a/src/components/layouts/header/header.module.scss b/src/components/layouts/header/header.module.scss new file mode 100644 index 0000000..bc28f0c --- /dev/null +++ b/src/components/layouts/header/header.module.scss @@ -0,0 +1,95 @@ +@use '@/styles/mixins.scss' as *; +@use '@/styles/variables.scss' as *; +@use '@/styles/responsive.scss' as *; + +.header { + background: $background-dark; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 30px; + .logo-brand { + display: flex; + gap: 20px; + align-items: center; + img { + flex-shrink: 0; + } + } + .brand-text { + display: flex; + flex-direction: column; + gap: 10px; + .brand-title { + font-size: 30px; + font-weight: 700; + color: $text-primary; + margin: 0; + line-height: 1.17; + } + .brand-subtitle { + font-weight: 400; + font-size: 24px; + line-height: 1.17; + color: $text-primary; + margin: 0; + } + } + .auth-nav { + display: flex; + align-items: center; + gap: 30px; + flex-shrink: 0; + .btn-login, + .btn-register { + line-height: 1.33; + min-height: 53px; + min-width: 106px; + } + a { + display: flex; + align-items: center; + justify-content: center; + } + .btn-login { + @include button(18px, 700, 10px 20px, 10px, $primary-color); + } + .btn-register { + @include button(18px, 700, 10px 20px, 10px, transparent, 1px solid $text-primary); + } + } +} +// responsive +@include medium { + .header { + flex-direction: column; + align-items: flex-start; + gap: 20px; + .brand-text { + .brand-title { + font-size: 24px; + } + .brand-subtitle { + font-size: 18px; + } + } + } +} +@include small { + .header { + .logo-brand { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + .auth-nav { + flex-direction: column; + width: 100%; + gap: 10px; + a, + button { + width: 100%; + } + } + } +} diff --git a/src/components/layouts/header/header.tsx b/src/components/layouts/header/header.tsx new file mode 100644 index 0000000..9a754a1 --- /dev/null +++ b/src/components/layouts/header/header.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { FC } from 'react'; +import Image from 'next/image'; +import HeaderStyles from './header.module.scss'; +import Link from 'next/link'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { signout } from '@/app/login/actions'; + +const Header: FC = () => { + const { isLogin, setLogin } = useAuthStore(); + + const handleLogout = async () => { + const rs = await signout(); + if (rs) { + setLogin(false); + } + }; + + return ( + <> +
+ + logo +
+

SỞ KHOA HỌC VÀ CÔNG NGHỆ HÀ NỘI

+

+ Trang AI Hỏi đáp – Tra cứu chính sách KH&CN +

+
+ + +
+ + ); +}; + +export default Header; diff --git a/src/components/layouts/history-chat/history-chat.module.scss b/src/components/layouts/history-chat/history-chat.module.scss new file mode 100644 index 0000000..c081d6f --- /dev/null +++ b/src/components/layouts/history-chat/history-chat.module.scss @@ -0,0 +1,97 @@ +@use '@/styles/mixins.scss' as *; +@use '@/styles/variables.scss' as *; +@use '@/styles/responsive.scss' as *; + +.history-chat { + display: flex; + flex-direction: column; + align-items: center; + gap: 32px; + padding: 80px 52px; + .history-chat-header { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + .history-chat-title { + font-weight: 700; + font-size: 24px; + line-height: 1; + color: $text-secondary; + margin: 0; + } + .history-chat-subtitle { + font-weight: 400; + font-size: 16px; + line-height: 1.125; + color: $text-secondary; + margin: 0; + max-width: 420px; + } + } +} +.tabs { + display: inline-flex; + border: 1px solid $primary-color; + border-radius: 6px; + padding: 4px; + gap: 6px; + li { + @include button(16px, 400, 10px 8px, 4px, transparent); + color: $text-muted; + transition: all 0.2s; + &.active { + background-color: $primary-color !important; + color: $text-primary !important; + box-shadow: 0px 1px 4px 0px rgba(78, 159, 255, 0.2); + } + } +} +.accordion { + width: 100%; + max-width: 584px; + display: flex; + flex-direction: column; + gap: 16px; + &-item { + background-color: $background-light; + border-radius: 6px; + padding: 20px 20px 20px 24px; + box-shadow: 0px 0.5px 2px 0px rgba(25, 33, 61, 0.1); + overflow: hidden; + } + &-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + width: 100%; + text-align: left; + color: $text-secondary; + padding: 6px 1px; + &:hover { + cursor: pointer; + } + } + &-content { + padding-top: 11px; + p { + font-weight: 400; + font-size: 16px; + line-height: 1.125; + color: $text-light-muted; + margin: 0; + } + } +} +// responsive { +@include small { + .history-chat { + padding: 40px 20px; + } + .tabs { + flex-wrap: wrap; + justify-content: center; + } +} diff --git a/src/components/layouts/history-chat/history-chat.tsx b/src/components/layouts/history-chat/history-chat.tsx new file mode 100644 index 0000000..330d3b1 --- /dev/null +++ b/src/components/layouts/history-chat/history-chat.tsx @@ -0,0 +1,103 @@ +'use client'; + +import HistoryChatStyles from './history-chat.module.scss'; +import { FC, useState, useEffect } from 'react'; +import Image from 'next/image'; + +const tabs = ['Mới nhất', 'Đã lưu', 'Thông tư', 'Luật', 'Chính sách']; + +interface HistoryChatItem { + agent_thoughts: any; + answer: string; + conversation_id: string; + created_at: number; + error: null | any; + feedback: null | any; + id: string; + inputs: any; + message_files: any; + parent_message_id: string; + query: string; + retriever_resources: any; + status: 'normal'; +} + +interface HistoryChatProps { + data: { + data: HistoryChatItem[]; + has_more: boolean; + limit: number; + }; +} + +const HistoryChat: FC = ({ data }) => { + const [activeTab, setActiveTab] = useState(0); + const [expandedItems, setExpandedItems] = useState([]); + + const handleTabClick = (index: number) => { + setActiveTab(index); + }; + + const handleToggleContent = (index: number) => { + setExpandedItems((prev) => { + if (prev.includes(index)) { + return prev.filter((item) => item !== index); + } else { + return [...prev, index]; + } + }); + }; + + return ( + <> +
+
+

Lịch sử trò chuyện

+

+ Bạn có thể tìm lại các cuộc trò truyện cũ, luật, thông tư nhiều người quan tâm dưới đây nhé. +

+
+
    + {tabs.map((tab, index) => ( +
  • handleTabClick(index)} + style={{ cursor: 'pointer' }} + > + {tab} +
  • + ))} +
+
+ {data?.data?.map((item, index) => { + const isExpanded = expandedItems.includes(index); + return ( +
+ + {isExpanded && ( +
+

{item.answer}

+
+ )} +
+ ); + })} +
+
+ + ); +}; + +export default HistoryChat; diff --git a/src/components/layouts/main/main.module.scss b/src/components/layouts/main/main.module.scss new file mode 100644 index 0000000..1c2c687 --- /dev/null +++ b/src/components/layouts/main/main.module.scss @@ -0,0 +1,7 @@ +@use '@/styles/mixins.scss' as *; +@use '@/styles/variables.scss' as *; +@use '@/styles/responsive.scss' as *; + +.main { + background: $background-dark; +} diff --git a/src/components/layouts/main/main.tsx b/src/components/layouts/main/main.tsx new file mode 100644 index 0000000..e4a7406 --- /dev/null +++ b/src/components/layouts/main/main.tsx @@ -0,0 +1,703 @@ +'use client'; +import type { FC } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import produce, { setAutoFreeze } from 'immer'; +import { useBoolean, useGetState } from 'ahooks'; +import useConversation from '@/hooks/use-conversation'; +import Toast from '@/components/base/toast'; +import { + fetchAppParams, + fetchChatList, + fetchConversations, + generationConversationName, + sendChatMessage, + updateFeedback +} from '@/service'; +import type { ChatItem, ConversationItem, Feedbacktype, PromptConfig, VisionFile, VisionSettings } from '@/types/app'; +import { Resolution, TransferMethod, WorkflowRunningStatus } from '@/types/app'; +import { setLocaleOnClient } from '@/i18n/client'; +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'; +import Loading from '@/components/base/loading'; +import { replaceVarWithValues, userInputsFormToPromptVariables } from '@/utils/prompt'; +import AppUnavailable from '@/components/app-unavailable'; +import { API_KEY, APP_ID, APP_INFO, isShowPrompt, promptTemplate } from '@/config'; +import type { Annotation as AnnotationType } from '@/types/log'; +import { addFileInfos, sortAgentSorts } from '@/utils/tools'; + +export type IMainProps = { + params: any; +}; + +import MainStyles from './main.module.scss'; +import HistoryChat from '@/components/layouts/history-chat/history-chat'; +import Chat from '@/components/layouts/chat/chat'; + +const Main: FC = () => { + const { t } = useTranslation(); + const media = useBreakpoints(); + const isMobile = media === MediaType.mobile; + const hasSetAppConfig = APP_ID && API_KEY; + + /* + * app info + */ + const [historyChatList, setHistoryChatList] = useState(null); + const [appUnavailable, setAppUnavailable] = useState(false); + const [isUnknownReason, setIsUnknownReason] = useState(false); + const [promptConfig, setPromptConfig] = useState(null); + const [inited, setInited] = useState(false); + // in mobile, show sidebar by click button + const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false); + const [visionConfig, setVisionConfig] = useState({ + enabled: true, + number_limits: 2, + detail: Resolution.low, + transfer_methods: [TransferMethod.local_file] + }); + useEffect(() => { + if (APP_INFO?.title) document.title = `${APP_INFO.title}`; + }, [APP_INFO?.title]); + + // onData change thought (the produce obj). https://github.com/immerjs/immer/issues/576 + useEffect(() => { + setAutoFreeze(false); + return () => { + setAutoFreeze(true); + }; + }, []); + + /* + * conversation info + */ + const { + conversationList, + setConversationList, + currConversationId, + getCurrConversationId, + setCurrConversationId, + getConversationIdFromStorage, + isNewConversation, + currConversationInfo, + currInputs, + newConversationInputs, + resetNewConversationInputs, + setCurrInputs, + setNewConversationInfo, + setExistConversationInfo + } = useConversation(); + + const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = + useGetState(false); + const [isChatStarted, { setTrue: setChatStarted, setFalse: setChatNotStarted }] = useBoolean(false); + const handleStartChat = (inputs: Record) => { + createNewChat(); + setConversationIdChangeBecauseOfNew(true); + setCurrInputs(inputs); + setChatStarted(); + // parse variables in introduction + setChatList(generateNewChatListWithOpenStatement('', inputs)); + }; + const hasSetInputs = (() => { + if (!isNewConversation) return true; + + return isChatStarted; + })(); + + const conversationName = currConversationInfo?.name || (t('app.chat.newChatDefaultName') as string); + const conversationIntroduction = currConversationInfo?.introduction || ''; + const suggestedQuestions = currConversationInfo?.suggested_questions || []; + + const handleConversationSwitch = () => { + if (!inited) return; + + // update inputs of current conversation + let notSyncToStateIntroduction = ''; + let notSyncToStateInputs: Record | undefined | null = {}; + if (!isNewConversation) { + const item = conversationList.find((item) => item.id === currConversationId); + notSyncToStateInputs = item?.inputs || {}; + setCurrInputs(notSyncToStateInputs as any); + notSyncToStateIntroduction = item?.introduction || ''; + setExistConversationInfo({ + name: item?.name || '', + introduction: notSyncToStateIntroduction, + suggested_questions: suggestedQuestions + }); + } else { + notSyncToStateInputs = newConversationInputs; + setCurrInputs(notSyncToStateInputs); + } + + // update chat list of current conversation + if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponding) { + fetchChatList(currConversationId).then((res: any) => { + const { data } = res; + const newChatList: ChatItem[] = generateNewChatListWithOpenStatement( + notSyncToStateIntroduction, + notSyncToStateInputs + ); + + data.forEach((item: any) => { + newChatList.push({ + id: `question-${item.id}`, + content: item.query, + isAnswer: false, + message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] + }); + newChatList.push({ + id: item.id, + content: item.answer, + agent_thoughts: addFileInfos( + item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, + item.message_files + ), + feedback: item.feedback, + isAnswer: true, + message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] + }); + }); + setChatList(newChatList); + }); + } + + if (isNewConversation && isChatStarted) setChatList(generateNewChatListWithOpenStatement()); + }; + useEffect(handleConversationSwitch, [currConversationId, inited]); + + const handleConversationIdChange = (id: string) => { + if (id === '-1') { + createNewChat(); + setConversationIdChangeBecauseOfNew(true); + } else { + setConversationIdChangeBecauseOfNew(false); + } + // trigger handleConversationSwitch + setCurrConversationId(id, APP_ID); + hideSidebar(); + }; + + /* + * chat info. chat is under conversation. + */ + const [chatList, setChatList, getChatList] = useGetState([]); + const chatListDomRef = useRef(null); + // get history chat + const getHistoryChat = async (currConversationId: string) => { + try { + const data = await fetchChatList(currConversationId); + setHistoryChatList(data); + // return data; + } catch (error) { + console.error(error); + return []; + } + }; + useEffect(() => { + currConversationId !== '-1' && getHistoryChat(currConversationId); + }, [currConversationId]); + // + useEffect(() => { + // scroll to bottom + if (chatListDomRef.current) chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight; + }, [chatList, currConversationId]); + // user can not edit inputs if user had send message + const canEditInputs = !chatList.some((item) => item.isAnswer === false) && isNewConversation; + const createNewChat = () => { + // if new chat is already exist, do not create new chat + if (conversationList.some((item) => item.id === '-1')) return; + + setConversationList( + produce(conversationList, (draft) => { + draft.unshift({ + id: '-1', + name: t('app.chat.newChatDefaultName'), + inputs: newConversationInputs, + introduction: conversationIntroduction, + suggested_questions: suggestedQuestions + }); + }) + ); + }; + + // sometime introduction is not applied to state + const generateNewChatListWithOpenStatement = (introduction?: string, inputs?: Record | null) => { + let calculatedIntroduction = introduction || conversationIntroduction || ''; + const calculatedPromptVariables = inputs || currInputs || null; + if (calculatedIntroduction && calculatedPromptVariables) + calculatedIntroduction = replaceVarWithValues( + calculatedIntroduction, + promptConfig?.prompt_variables || [], + calculatedPromptVariables + ); + + const openStatement = { + id: `${Date.now()}`, + content: calculatedIntroduction, + isAnswer: true, + feedbackDisabled: true, + isOpeningStatement: isShowPrompt, + suggestedQuestions: suggestedQuestions + }; + if (calculatedIntroduction) return [openStatement]; + + return []; + }; + + // init + useEffect(() => { + if (!hasSetAppConfig) { + setAppUnavailable(true); + return; + } + (async () => { + try { + const [conversationData, appParams] = await Promise.all([fetchConversations(), fetchAppParams()]); + + // handle current conversation id + const { data: conversations, error } = conversationData as { + data: ConversationItem[]; + error: string; + }; + if (error) { + Toast.notify({ type: 'error', message: error }); + throw new Error(error); + return; + } + const _conversationId = getConversationIdFromStorage(APP_ID); + const currentConversation = conversations.find((item) => item.id === _conversationId); + const isNotNewConversation = !!currentConversation; + + // fetch new conversation info + const { + user_input_form, + opening_statement: introduction, + file_upload, + system_parameters, + suggested_questions = [] + }: any = appParams; + setLocaleOnClient(APP_INFO.default_language, true); + setNewConversationInfo({ + name: t('app.chat.newChatDefaultName'), + introduction, + suggested_questions + }); + if (isNotNewConversation) { + setExistConversationInfo({ + name: currentConversation.name || t('app.chat.newChatDefaultName'), + introduction, + suggested_questions + }); + } + const prompt_variables = userInputsFormToPromptVariables(user_input_form); + setPromptConfig({ + prompt_template: promptTemplate, + prompt_variables + } as PromptConfig); + setVisionConfig({ + ...file_upload?.image, + image_file_size_limit: system_parameters?.system_parameters || 0 + }); + setConversationList(conversations as ConversationItem[]); + + if (isNotNewConversation) setCurrConversationId(_conversationId, APP_ID, false); + + setInited(true); + } catch (e: any) { + if (e.status === 404) { + setAppUnavailable(true); + } else { + setIsUnknownReason(true); + setAppUnavailable(true); + } + } + })(); + }, []); + + const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false); + const [abortController, setAbortController] = useState(null); + const { notify } = Toast; + const logError = (message: string) => { + notify({ type: 'error', message }); + }; + + const checkCanSend = () => { + if (currConversationId !== '-1') return true; + + if (!currInputs || !promptConfig?.prompt_variables) return true; + + const inputLens = Object.values(currInputs).length; + const promptVariablesLens = promptConfig.prompt_variables.length; + + const emptyInput = inputLens < promptVariablesLens || Object.values(currInputs).find((v) => !v); + if (emptyInput) { + logError(t('app.errorMessage.valueOfVarRequired')); + return false; + } + return true; + }; + + const [controlFocus, setControlFocus] = useState(0); + const [openingSuggestedQuestions, setOpeningSuggestedQuestions] = useState([]); + const [messageTaskId, setMessageTaskId] = useState(''); + const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false); + const [isRespondingConIsCurrCon, setIsRespondingConCurrCon, getIsRespondingConIsCurrCon] = useGetState(true); + const [userQuery, setUserQuery] = useState(''); + + const updateCurrentQA = ({ + responseItem, + questionId, + placeholderAnswerId, + questionItem + }: { + responseItem: ChatItem; + questionId: string; + placeholderAnswerId: string; + questionItem: ChatItem; + }) => { + // closesure new list is outdated. + const newListWithAnswer = produce( + getChatList().filter((item) => item.id !== responseItem.id && item.id !== placeholderAnswerId), + (draft) => { + if (!draft.find((item) => item.id === questionId)) draft.push({ ...questionItem }); + + draft.push({ ...responseItem }); + } + ); + setChatList(newListWithAnswer); + }; + + const transformToServerFile = (fileItem: any) => { + return { + type: 'image', + transfer_method: fileItem.transferMethod, + url: fileItem.url, + upload_file_id: fileItem.id + }; + }; + + const handleSend = async (message: string, files?: VisionFile[]) => { + if (isResponding) { + notify({ type: 'info', message: t('app.errorMessage.waitForResponse') }); + return; + } + const toServerInputs: Record = {}; + if (currInputs) { + Object.keys(currInputs).forEach((key) => { + const value = currInputs[key]; + if (value.supportFileType) toServerInputs[key] = transformToServerFile(value); + else if (value[0]?.supportFileType) + toServerInputs[key] = value.map((item: any) => transformToServerFile(item)); + else toServerInputs[key] = value; + }); + } + + const data: Record = { + inputs: toServerInputs, + query: message, + conversation_id: isNewConversation ? null : currConversationId + }; + + if (files && files?.length > 0) { + data.files = files.map((item) => { + if (item.transfer_method === TransferMethod.local_file) { + return { + ...item, + url: '' + }; + } + return item; + }); + } + + // question + const questionId = `question-${Date.now()}`; + const questionItem = { + id: questionId, + content: message, + isAnswer: false, + message_files: files + }; + + const placeholderAnswerId = `answer-placeholder-${Date.now()}`; + const placeholderAnswerItem = { + id: placeholderAnswerId, + content: '', + isAnswer: true + }; + + const newList = [...getChatList(), questionItem, placeholderAnswerItem]; + setChatList(newList); + + let isAgentMode = false; + + // answer + const responseItem: ChatItem = { + id: `${Date.now()}`, + content: '', + agent_thoughts: [], + message_files: [], + isAnswer: true + }; + let hasSetResponseId = false; + + const prevTempNewConversationId = getCurrConversationId() || '-1'; + let tempNewConversationId = ''; + + setRespondingTrue(); + sendChatMessage(data, { + getAbortController: (abortController) => { + setAbortController(abortController); + }, + onData: ( + message: string, + isFirstMessage: boolean, + { conversationId: newConversationId, messageId, taskId }: any + ) => { + if (!isAgentMode) { + responseItem.content = responseItem.content + message; + } else { + const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]; + if (lastThought) lastThought.thought = lastThought.thought + message; // need immer setAutoFreeze + } + if (messageId && !hasSetResponseId) { + responseItem.id = messageId; + hasSetResponseId = true; + } + + if (isFirstMessage && newConversationId) tempNewConversationId = newConversationId; + + setMessageTaskId(taskId); + // has switched to other conversation + if (prevTempNewConversationId !== getCurrConversationId()) { + setIsRespondingConCurrCon(false); + return; + } + updateCurrentQA({ + responseItem, + questionId, + placeholderAnswerId, + questionItem + }); + }, + async onCompleted(hasError?: boolean) { + if (hasError) return; + + if (getConversationIdChangeBecauseOfNew()) { + const { data: allConversations }: any = await fetchConversations(); + const newItem: any = await generationConversationName(allConversations[0].id); + + const newAllConversations = produce(allConversations, (draft: any) => { + draft[0].name = newItem.name; + }); + setConversationList(newAllConversations as any); + } + setConversationIdChangeBecauseOfNew(false); + resetNewConversationInputs(); + setChatNotStarted(); + setCurrConversationId(tempNewConversationId, APP_ID, true); + setRespondingFalse(); + }, + onFile(file) { + const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]; + if (lastThought) lastThought.message_files = [...(lastThought as any).message_files, { ...file }]; + + updateCurrentQA({ + responseItem, + questionId, + placeholderAnswerId, + questionItem + }); + }, + onThought(thought) { + isAgentMode = true; + const response = responseItem as any; + if (thought.message_id && !hasSetResponseId) { + response.id = thought.message_id; + hasSetResponseId = true; + } + // responseItem.id = thought.message_id; + if (response.agent_thoughts.length === 0) { + response.agent_thoughts.push(thought); + } else { + const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]; + // thought changed but still the same thought, so update. + if (lastThought.id === thought.id) { + thought.thought = lastThought.thought; + thought.message_files = lastThought.message_files; + responseItem.agent_thoughts![response.agent_thoughts.length - 1] = thought; + } else { + responseItem.agent_thoughts!.push(thought); + } + } + // has switched to other conversation + if (prevTempNewConversationId !== getCurrConversationId()) { + setIsRespondingConCurrCon(false); + return false; + } + + updateCurrentQA({ + responseItem, + questionId, + placeholderAnswerId, + questionItem + }); + }, + onMessageEnd: (messageEnd) => { + if (messageEnd.metadata?.annotation_reply) { + responseItem.id = messageEnd.id; + responseItem.annotation = { + id: messageEnd.metadata.annotation_reply.id, + authorName: messageEnd.metadata.annotation_reply.account.name + } as AnnotationType; + const newListWithAnswer = produce( + getChatList().filter((item) => item.id !== responseItem.id && item.id !== placeholderAnswerId), + (draft) => { + if (!draft.find((item) => item.id === questionId)) draft.push({ ...questionItem }); + + draft.push({ + ...responseItem + }); + } + ); + setChatList(newListWithAnswer); + return; + } + // not support show citation + // responseItem.citation = messageEnd.retriever_resources + const newListWithAnswer = produce( + getChatList().filter((item) => item.id !== responseItem.id && item.id !== placeholderAnswerId), + (draft) => { + if (!draft.find((item) => item.id === questionId)) draft.push({ ...questionItem }); + + draft.push({ ...responseItem }); + } + ); + setChatList(newListWithAnswer); + }, + onMessageReplace: (messageReplace) => { + setChatList( + produce(getChatList(), (draft) => { + const current = draft.find((item) => item.id === messageReplace.id); + + if (current) current.content = messageReplace.answer; + }) + ); + }, + onError() { + setRespondingFalse(); + // role back placeholder answer + setChatList( + produce(getChatList(), (draft) => { + draft.splice( + draft.findIndex((item) => item.id === placeholderAnswerId), + 1 + ); + }) + ); + }, + onWorkflowStarted: ({ workflow_run_id, task_id }) => { + // taskIdRef.current = task_id + responseItem.workflow_run_id = workflow_run_id; + responseItem.workflowProcess = { + status: WorkflowRunningStatus.Running, + tracing: [] + }; + setChatList( + produce(getChatList(), (draft) => { + const currentIndex = draft.findIndex((item) => item.id === responseItem.id); + draft[currentIndex] = { + ...draft[currentIndex], + ...responseItem + }; + }) + ); + }, + onWorkflowFinished: ({ data }) => { + responseItem.workflowProcess!.status = data.status as WorkflowRunningStatus; + setChatList( + produce(getChatList(), (draft) => { + const currentIndex = draft.findIndex((item) => item.id === responseItem.id); + draft[currentIndex] = { + ...draft[currentIndex], + ...responseItem + }; + }) + ); + }, + onNodeStarted: ({ data }) => { + responseItem.workflowProcess!.tracing!.push(data as any); + setChatList( + produce(getChatList(), (draft) => { + const currentIndex = draft.findIndex((item) => item.id === responseItem.id); + draft[currentIndex] = { + ...draft[currentIndex], + ...responseItem + }; + }) + ); + }, + onNodeFinished: ({ data }) => { + const currentIndex = responseItem.workflowProcess!.tracing!.findIndex( + (item) => item.node_id === data.node_id + ); + responseItem.workflowProcess!.tracing[currentIndex] = data as any; + setChatList( + produce(getChatList(), (draft) => { + const currentIndex = draft.findIndex((item) => item.id === responseItem.id); + draft[currentIndex] = { + ...draft[currentIndex], + ...responseItem + }; + }) + ); + } + }); + getHistoryChat(currConversationId); + }; + + const handleFeedback = async (messageId: string, feedback: Feedbacktype) => { + await updateFeedback({ + url: `/messages/${messageId}/feedbacks`, + body: { rating: feedback.rating } + }); + const newChatList = chatList.map((item) => { + if (item.id === messageId) { + return { + ...item, + feedback + }; + } + return item; + }); + setChatList(newChatList); + notify({ type: 'success', message: t('common.api.success') }); + }; + + if (appUnavailable) + return ( + + ); + + if (!APP_ID || !APP_INFO || !promptConfig) return ; + return ( + <> +
+ + +
+ + ); +}; + +export default Main; diff --git a/src/components/sidebar/card.module.css b/src/components/sidebar/card.module.css new file mode 100644 index 0000000..f7e1aa2 --- /dev/null +++ b/src/components/sidebar/card.module.css @@ -0,0 +1,3 @@ +.card:hover { + background: linear-gradient(0deg, rgba(235, 245, 255, 0.4), rgba(235, 245, 255, 0.4)), #ffffff; +} diff --git a/src/components/sidebar/card.tsx b/src/components/sidebar/card.tsx new file mode 100644 index 0000000..b3202a9 --- /dev/null +++ b/src/components/sidebar/card.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import s from './card.module.css'; + +type PropType = { + children: React.ReactNode; + text?: string; +}; +function Card({ children, text }: PropType) { + const { t } = useTranslation(); + return ( +
+
{text ?? t('app.chat.powerBy')}
+ {children} +
+ ); +} + +export default Card; diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx new file mode 100644 index 0000000..ad3252b --- /dev/null +++ b/src/components/sidebar/index.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChatBubbleOvalLeftEllipsisIcon, PencilSquareIcon } from '@heroicons/react/24/outline'; +import { ChatBubbleOvalLeftEllipsisIcon as ChatBubbleOvalLeftEllipsisSolidIcon } from '@heroicons/react/24/solid'; +import Button from '@/components/base/button'; +// import Card from './card' +import type { ConversationItem } from '@/types/app'; + +function classNames(...classes: any[]) { + return classes.filter(Boolean).join(' '); +} + +const MAX_CONVERSATION_LENTH = 20; + +export type ISidebarProps = { + copyRight: string; + currentId: string; + onCurrentIdChange: (id: string) => void; + list: ConversationItem[]; +}; + +const Sidebar: FC = ({ copyRight, currentId, onCurrentIdChange, list }) => { + const { t } = useTranslation(); + return ( +
+ {list.length < MAX_CONVERSATION_LENTH && ( +
+ +
+ )} + + + {/* +
LangGenius
+
*/} +
+
+ © {copyRight} {new Date().getFullYear()} +
+
+
+ ); +}; + +export default React.memo(Sidebar); diff --git a/src/components/value-panel/index.tsx b/src/components/value-panel/index.tsx new file mode 100644 index 0000000..2a4a7e3 --- /dev/null +++ b/src/components/value-panel/index.tsx @@ -0,0 +1,56 @@ +'use client'; +import type { FC, ReactNode } from 'react'; +import React from 'react'; +import cn from 'classnames'; +import { useTranslation } from 'react-i18next'; +import s from './style.module.css'; +import { StarIcon } from '@/components//welcome/massive-component'; +import Button from '@/components/base/button'; + +export type ITemplateVarPanelProps = { + className?: string; + header: ReactNode; + children?: ReactNode | null; + isFold: boolean; +}; + +const TemplateVarPanel: FC = ({ className, header, children, isFold }) => { + return ( +
+ {/* header */} +
{header}
+ {/* body */} + {!isFold && children &&
{children}
} +
+ ); +}; + +export const PanelTitle: FC<{ title: string; className?: string }> = ({ title, className }) => { + return ( +
+ + {title} +
+ ); +}; + +export const VarOpBtnGroup: FC<{ + className?: string; + onConfirm: () => void; + onCancel: () => void; +}> = ({ className, onConfirm, onCancel }) => { + const { t } = useTranslation(); + + return ( +
+ + +
+ ); +}; + +export default React.memo(TemplateVarPanel); diff --git a/src/components/value-panel/style.module.css b/src/components/value-panel/style.module.css new file mode 100644 index 0000000..f0b0ffc --- /dev/null +++ b/src/components/value-panel/style.module.css @@ -0,0 +1,5 @@ +.boxShodow { + box-shadow: + 0px 12px 16px -4px rgba(16, 24, 40, 0.08), + 0px 4px 6px -2px rgba(16, 24, 40, 0.03); +} diff --git a/src/components/welcome/icons/logo.png b/src/components/welcome/icons/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..800a070906310b5233de74d0a1448db8f7f6761e GIT binary patch literal 3921 zcmV-X53cZuP)kO zl9Q87GJb)qld$Bn)(!BJRQ>qn^PM04?EKOsR2W(m2*gD(i8LD=CXvZnBCOf+BAdm+< zfxI{u*0qV3e(}fdx1hq%p%$-@#i19ARwOH0m$a@~EVCsN%qg^lvFj9CLC{J786U=* z(5oSVDPc0?iLH?n?$6HtMVZGxBfkqnhI+j*p?7vp&;y#H8(EZEpJ`pa*xJxJ4jq}I zXFf%bu1-&SapB5^voHk65Qwb}%N7kTT(=~$?hJvpc&mig#nJ1BL`$3Ule6Io?n{RC z40+--f#u}0zk1WW@z|&F4HzP*@%Ed*FP=dUL?J$L@1Y~du3ws)U+uVrcwkN{Z6@sD zZc~fOP!K2^h3Qo1ur;!JNIPN8dPwVNA8re-4kAlrVcFqHB5XjX2(8wD>sOBJ4{= zT-Wh*3I8%i&P&ss->BR1$JfgYFg?rqYR9Rao#&BV-jE}!feb$oA_^0R-3$9GW)8n; z-sa5=t7@i`VU5I^35s3}Fa8OVca64LTYq(t*N>F96YGDpAk@U6Kra*b2;0;kur zueAnQDjh$Sb+7gsu_e&CV1TKW1~bOTKo5)@pRbx|&a zck9YvSXNvs@5-!u^SL7L?(EBmpQX1jE_0n)A}*mDM9SDoan_nM*+f-gz6k>c{YgyG zjlDn??4SD&9hqLwF>hbJ*d(%^L8n_R=sBFbc6DVE5?MZHsq$icc*gE;_wJ=A2f7Kj? z?LnQ1H2KhxQ|sB8NOYQ_rUxkCct9?V?S1>og_Eo8kL3iKWirB$A*+EDxM)jglq`bz zZ_-N}!9I)rqn=W0Yp}=90gY0$FTHJ>bOaabGZMipBC8Wyo}?`m(861Pn}cJb3sd-B2cy5+977ZMB+9D@wueCl!p?;@xvgEqvSG%V8-n0H4A1p4FyCp>-jweu2X7W}XlMblWE&A9d}38Tx;5&HYj|=m9pO_0#s+ z=9`N|U?yB6w||q$ngpUZbdt`SNh#2?<%sh%^Z^A8q@Bj00>034&p@wduuH`8%h2l@ zx7?*RzrWQ%UjJCa)Es$6s2-uV5l1E?6C!0@nH@{y-JA5<2s>+L(Iav&X_zowA`BC$ z>z6yWCUP;6vS&?!E(`Z$K`|$D0ETN^*P6sbz!{_ zs(bepYHxwKbjZ@QJB~2zWdln~3O}H7^JWiuna{Ix2bx{u!hC3)$pbJ(1R5I&Zp^@l zNXg<#w$nA+%>lb%z-ax$_F=X1?MikUunCapeUi#Cy2*lt<2K$FN`7-qmFk^|mE0JM zZzxGtaz`)p5qTFzx<=cgeeHCPE8R|7x0f|6w;g$I{I7@qS?r2`ztfi7kM^;>LVtJ{ z>Rn@C#z(-6)*NYSwxszUnba;CbGf1*rd^@`_rSIQ3mu{8J3O}op482JJ2{8aJ~!1} zE^}|Q{fN9`6ozvYkQ*?TjAZp=IBcY**+P*k`#`}b_UhjKU)ZHc8*!BHx;&y2_#uie zxr7k)frQbE6z$1$e_Wxbf2%=P{a)dxbD{d6VKd%{ja+ksJ7x2t zMu=s(9|^h%TY#bkV(Sxy@`*L_qM8zij!Z-#Yl*lfYsBrU=gxy6Iq1nT;z{J@i9s}< z%oUM~zASNql@j7D7%=J|Kf--u^?W;Z*UZB9=?e$Yr8U zCJ)5D?%Y{w$)ijX%5(OaYnK<&E*s4$0HYIa%y}Hw2Nnnev>drANJICONNG1l=-QCo z!CN3TQXEa_)Bi2F4Zvc$q|IJ)S?;Ibp?i!%Cfe&D>e2{Z6cM4iI^))8+11u+X*AlO z6pL0Ru{ycjPiE92x-mi0_lME)!h@4IPzz*7GnV8}`kDUUELcpJBqJwQxc25&62F)S ziiu;bkskx(tx%$gY&QoTLt2Rm76z3u(uAPNjPnU3iY-&@w&GEVcHljjIT#=&4;(#C zKmJ0#z2E8oen6LxcznLlvdYNFeju9%QYJem%(xMVCLNvVqccY)O{wZYG~-8<#UZ#G z8WQ!igJZ1bB%!iHMZh{egK6yUeIT#un)n`UF*t_;yLwYfsZ(?(jk3IfQSc1hW?(U` zJM9y>kiH)8!^B_MV&dng^+Xc~i4;8Y&^Rl^+pHz>J@%GNJ+S05e#S;(fxu^xRjP4# zAK`|FII}0Y&FvnP6M8PWi-EZZ_w{cW2i?zcKP+Rfjrs~CmH(cr$A#qA?P|q(dr`Y zrXyOgtFo5JPE_>29}eVwB=+0-)FJu{#dV}8>3W{Ie)-~UJ3ot_`>T|hUXeh8wy$>l zN2dSVpl9EJ_5PlK=DRa{n{iuiR)(7spID-pJYlS~3gJFra9x?ng}&zgE#pS3Cax>? zRJ@FgdGZ*OPVeFEAu5ZAmQ3qOZvaeS zlP8CXtdMnC;I?DvOy#;~zJJd?@oU&5yaj2(fUwaIA@ue7G!|UYR`q+8p=g0(_myZO zZ*d~RSj8Kbldnz{e<0$KTj%7EcG@W3C^SBI_df9xsIYFhZVOe9+XfN3$0&`$+;@Lm zW;uzl9FdX_PWcZjWWQntnH$NH2VLC5)`$hR0{3|j`genD)6*ZF!0i;xLkUDxv_2ac zvdU4F(6Das{`t1T#}%CW(wKaCBd>XMYIV}@a+Jf)DYG-o-2UO4LB>a4M8nsF6wlqg z6Su3!eP~fO=7+J7elH)AH(?XVhw*WAl@ZCY_3`ZpCHkiTD|*6;6Fp>w<|qyDgMGVY zWp!>5N{CY>4}Nyg9lqsJSRh6!s4=!aY1p1Q8uKT|xJI}^w>$3uBV)lf}O^xTNJ~znXTsH!EOz|a>QkQ$3&V{?~8XNeyo;wvvlIzN8 z7b~9EG1a->K&UWu;Lp!Me$be~r?+FoKn*Gk75pdl7_GQ{tA?yrs8FFog$flaRH#s) fLWK$y?il) => void; + canEditInputs: boolean; + savedInputs: Record; + onInputsChange: (inputs: Record) => void; +}; + +const Welcome: FC = ({ + conversationName, + hasSetInputs, + isPublicVersion, + siteInfo, + promptConfig, + onStartChat, + canEditInputs, + savedInputs, + onInputsChange +}) => { + console.log(promptConfig); + const { t } = useTranslation(); + const hasVar = promptConfig.prompt_variables.length > 0; + const [isFold, setIsFold] = useState(true); + const [inputs, setInputs] = useState>( + (() => { + if (hasSetInputs) return savedInputs; + + const res: Record = {}; + if (promptConfig) { + promptConfig.prompt_variables.forEach((item) => { + res[item.key] = ''; + }); + } + return res; + })() + ); + useEffect(() => { + if (!savedInputs) { + const res: Record = {}; + if (promptConfig) { + promptConfig.prompt_variables.forEach((item) => { + res[item.key] = ''; + }); + } + setInputs(res); + } else { + setInputs(savedInputs); + } + }, [savedInputs]); + + const highLightPromoptTemplate = (() => { + if (!promptConfig) return ''; + const res = promptConfig.prompt_template.replace(regex, (match, p1) => { + return `${inputs?.[p1] ? inputs?.[p1] : match}`; + }); + return res; + })(); + + const { notify } = Toast; + const logError = (message: string) => { + notify({ type: 'error', message, duration: 3000 }); + }; + + const renderHeader = () => { + return ( +
+
{conversationName}
+
+ ); + }; + + const renderInputs = () => { + return ( +
+ {promptConfig.prompt_variables.map((item) => ( +
+ + {item.type === 'select' && ( + { + setInputs({ ...inputs, [item.key]: e.target.value }); + }} + className={'w-full flex-grow py-2 pl-3 pr-3 box-border rounded-lg bg-gray-50'} + maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN} + /> + )} + {item.type === 'paragraph' && ( +