diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index e1d3f0b..0000000 --- a/.editorconfig +++ /dev/null @@ -1,22 +0,0 @@ -# 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/.env.example b/.env.example deleted file mode 100644 index 49b936d..0000000 --- a/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# APP ID -NEXT_PUBLIC_APP_ID= -# APP API key -NEXT_PUBLIC_APP_KEY= -# API url prefix -NEXT_PUBLIC_API_URL= diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 631cb2b..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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" - } -} diff --git a/.gitignore b/.gitignore index 9c0404b..5ef6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,12 @@ # dependencies /node_modules /.pnp -.pnp.js +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions # testing /coverage @@ -17,7 +22,6 @@ # misc .DS_Store -.vscode *.pem # debug @@ -26,9 +30,8 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# local env files -.env*.local -.env +# env files (can opt-in for committing if needed) +.env* # vercel .vercel @@ -36,16 +39,3 @@ yarn-error.log* # 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 diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 2a80d06..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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" - } - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index b30daf1..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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": "explicit" - }, - "[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" - }, - "i18n-ally.localesPaths": [ - "i18n", - "i18n/lang", - "app/api/messages" - ] -} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index de34e1d..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM --platform=linux/amd64 node:19-bullseye-slim - -WORKDIR /app - -COPY . . - -RUN yarn install -RUN yarn build - -EXPOSE 3000 - -CMD ["yarn","start"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index c729e97..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 Dify - -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. diff --git a/README.md b/README.md index 14fb2a5..e215bc4 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,8 @@ -# Conversation Web App Template -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). - -## Config App -Create a file named `.env.local` in the current directory and copy the contents from `.env.example`. Setting the following content: -``` -# APP ID: This is the unique identifier for your app. You can find it in the app's detail page URL. -# For example, in the URL `https://cloud.dify.ai/app/xxx/workflow`, the value `xxx` is your APP ID. -NEXT_PUBLIC_APP_ID= - -# APP API Key: This is the key used to authenticate your app's API requests. -# You can generate it on the app's "API Access" page by clicking the "API Key" button in the top-right corner. -NEXT_PUBLIC_APP_KEY= - -# APP URL: This is the API's base URL. If you're using the Dify cloud service, set it to: https://api.dify.ai/v1. -NEXT_PUBLIC_API_URL= -``` - -Config more in `config/index.ts` file: -```js -export const APP_INFO: AppInfo = { - title: 'Chat APP', - description: '', - copyright: '', - privacy_policy: '', - default_language: 'zh-Hans' -} - -export const isShowPrompt = true -export const promptTemplate = '' -``` +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). ## Getting Started -First, install dependencies: -```bash -npm install -# or -yarn -# or -pnpm install -``` -Then, run the development server: +First, run the development server: ```bash npm run dev @@ -48,19 +10,16 @@ npm run dev yarn dev # or pnpm dev -``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -## Using Docker - -``` -docker build . -t /webapp-conversation:latest -# now you can access it in port 3000 -docker run -p 3000:3000 /webapp-conversation:latest +# or +bun 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. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + ## Learn More To learn more about Next.js, take a look at the following resources: @@ -68,13 +27,10 @@ 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! +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ## Deploy on Vercel -> ⚠️ If you are using [Vercel Hobby](https://vercel.com/pricing), your message will be truncated due to the limitation of 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. +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/app/api/chat-messages/route.ts b/app/api/chat-messages/route.ts deleted file mode 100644 index 849be1f..0000000 --- a/app/api/chat-messages/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { type NextRequest } from 'next/server' -import { client, getInfo } from '@/app/api/utils/common' - -export async function POST(request: NextRequest) { - const body = await request.json() - const { - inputs, - query, - files, - conversation_id: conversationId, - response_mode: responseMode, - } = body - const { user } = getInfo(request) - const res = await client.createChatMessage(inputs, query, user, responseMode, conversationId, files) - return new Response(res.data as any) -} diff --git a/app/api/conversations/[conversationId]/name/route.ts b/app/api/conversations/[conversationId]/name/route.ts deleted file mode 100644 index d9dd327..0000000 --- a/app/api/conversations/[conversationId]/name/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { type NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { client, getInfo } from '@/app/api/utils/common' - -export async function POST(request: NextRequest, { params }: { - params: { conversationId: string } -}) { - const body = await request.json() - const { - auto_generate, - name, - } = body - const { conversationId } = params - const { user } = getInfo(request) - - // auto generate name - const { data } = await client.renameConversation(conversationId, name, user, auto_generate) - return NextResponse.json(data) -} diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts deleted file mode 100644 index 642da1b..0000000 --- a/app/api/conversations/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { type NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { client, getInfo, setSession } from '@/app/api/utils/common' - -export async function GET(request: NextRequest) { - const { sessionId, user } = getInfo(request) - try { - const { data }: any = await client.getConversations(user) - return NextResponse.json(data, { - headers: setSession(sessionId), - }) - } - catch (error: any) { - return NextResponse.json({ - data: [], - error: error.message, - }) - } -} diff --git a/app/api/file-upload/route.ts b/app/api/file-upload/route.ts deleted file mode 100644 index fa09906..0000000 --- a/app/api/file-upload/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type NextRequest } from 'next/server' -import { client, getInfo } from '@/app/api/utils/common' - -export async function POST(request: NextRequest) { - try { - const formData = await request.formData() - const { user } = getInfo(request) - formData.append('user', user) - const res = await client.fileUpload(formData) - return new Response(res.data.id as any) - } - catch (e: any) { - return new Response(e.message) - } -} diff --git a/app/api/messages/[messageId]/feedbacks/route.ts b/app/api/messages/[messageId]/feedbacks/route.ts deleted file mode 100644 index c701e15..0000000 --- a/app/api/messages/[messageId]/feedbacks/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { type NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { client, getInfo } from '@/app/api/utils/common' - -export async function POST(request: NextRequest, { params }: { - params: { messageId: string } -}) { - const body = await request.json() - const { - rating, - } = body - const { messageId } = params - const { user } = getInfo(request) - const { data } = await client.messageFeedback(messageId, rating, user) - return NextResponse.json(data) -} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts deleted file mode 100644 index 0edafe7..0000000 --- a/app/api/messages/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { client, getInfo, setSession } 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), - }) -} diff --git a/app/api/parameters/route.ts b/app/api/parameters/route.ts deleted file mode 100644 index f133da8..0000000 --- a/app/api/parameters/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { type NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { client, getInfo, setSession } from '@/app/api/utils/common' - -export async function GET(request: NextRequest) { - const { sessionId, user } = getInfo(request) - try { - const { data } = await client.getApplicationParameters(user) - return NextResponse.json(data as object, { - headers: setSession(sessionId), - }) - } - catch (error) { - return NextResponse.json([]) - } -} diff --git a/app/api/utils/common.ts b/app/api/utils/common.ts deleted file mode 100644 index 109ee4b..0000000 --- a/app/api/utils/common.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type NextRequest } from 'next/server' -import { ChatClient } from 'dify-client' -import { v4 } from 'uuid' -import { API_KEY, API_URL, APP_ID } from '@/config' - -const userPrefix = `user_${APP_ID}:` - -export const getInfo = (request: NextRequest) => { - const sessionId = request.cookies.get('session_id')?.value || 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, API_URL || undefined) diff --git a/app/components/app-unavailable.tsx b/app/components/app-unavailable.tsx deleted file mode 100644 index 495c8ce..0000000 --- a/app/components/app-unavailable.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' -import type { FC } from 'react' -import React from 'react' -import { useTranslation } from 'react-i18next' - -type IAppUnavailableProps = { - isUnknownReason: boolean - errMessage?: string -} - -const AppUnavailable: FC = ({ - isUnknownReason, - errMessage, -}) => { - const { t } = useTranslation() - let message = errMessage - if (!errMessage) - message = (isUnknownReason ? t('app.common.appUnkonwError') : t('app.common.appUnavailable')) as string - - return ( -
-

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

-
{message}
-
- ) -} -export default React.memo(AppUnavailable) diff --git a/app/components/base/action-button/index.tsx b/app/components/base/action-button/index.tsx deleted file mode 100644 index c90d1a8..0000000 --- a/app/components/base/action-button/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { CSSProperties } from 'react' -import React from 'react' -import { type VariantProps, cva } from 'class-variance-authority' -import classNames from '@/utils/classnames' - -enum ActionButtonState { - Destructive = 'destructive', - Active = 'active', - Disabled = 'disabled', - Default = '', - Hover = 'hover', -} - -const actionButtonVariants = cva( - 'action-btn', - { - variants: { - size: { - xs: 'action-btn-xs', - m: 'action-btn-m', - l: 'action-btn-l', - xl: 'action-btn-xl', - }, - }, - defaultVariants: { - size: 'm', - }, - }, -) - -export type ActionButtonProps = { - size?: 'xs' | 's' | 'm' | 'l' | 'xl' - state?: ActionButtonState - styleCss?: CSSProperties -} & React.ButtonHTMLAttributes & VariantProps - -function getActionButtonState(state: ActionButtonState) { - switch (state) { - case ActionButtonState.Destructive: - return 'action-btn-destructive' - case ActionButtonState.Active: - return 'action-btn-active' - case ActionButtonState.Disabled: - return 'action-btn-disabled' - case ActionButtonState.Hover: - return 'action-btn-hover' - default: - return '' - } -} - -const ActionButton = React.forwardRef( - ({ className, size, state = ActionButtonState.Default, styleCss, children, ...props }, ref) => { - return ( - - ) - }, -) -ActionButton.displayName = 'ActionButton' - -export default ActionButton -export { ActionButton, ActionButtonState, actionButtonVariants } diff --git a/app/components/base/app-icon/index.tsx b/app/components/base/app-icon/index.tsx deleted file mode 100644 index 48e1608..0000000 --- a/app/components/base/app-icon/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { FC } from 'react' -import classNames from 'classnames' -import style from './style.module.css' - -export type AppIconProps = { - size?: 'xs' | '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 deleted file mode 100644 index f73ba60..0000000 --- a/app/components/base/app-icon/style.module.css +++ /dev/null @@ -1,23 +0,0 @@ -.appIcon { - @apply flex items-center justify-center relative w-9 h-9 text-lg bg-teal-100 rounded-lg grow-0 shrink-0; -} - -.appIcon.large { - @apply w-10 h-10; -} - -.appIcon.small { - @apply w-8 h-8; -} - -.appIcon.xs { - @apply w-3 h-3 text-base; -} - -.appIcon.tiny { - @apply w-6 h-6 text-base; -} - -.appIcon.rounded { - @apply rounded-full; -} \ No newline at end of file diff --git a/app/components/base/auto-height-textarea/index.tsx b/app/components/base/auto-height-textarea/index.tsx deleted file mode 100644 index ee015fb..0000000 --- a/app/components/base/auto-height-textarea/index.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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, - ) => { - // eslint-disable-next-line react-hooks/rules-of-hooks - 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/app/components/chat/icons/answer.svg b/src/components/layouts/chat/icons/answer.svg similarity index 100% rename from app/components/chat/icons/answer.svg rename to src/components/layouts/chat/icons/answer.svg diff --git a/app/components/chat/icons/default-avatar.jpg b/src/components/layouts/chat/icons/default-avatar.jpg similarity index 100% rename from app/components/chat/icons/default-avatar.jpg rename to src/components/layouts/chat/icons/default-avatar.jpg diff --git a/app/components/chat/icons/edit.svg b/src/components/layouts/chat/icons/edit.svg similarity index 100% rename from app/components/chat/icons/edit.svg rename to src/components/layouts/chat/icons/edit.svg diff --git a/app/components/chat/icons/question.svg b/src/components/layouts/chat/icons/question.svg similarity index 100% rename from app/components/chat/icons/question.svg rename to src/components/layouts/chat/icons/question.svg diff --git a/app/components/chat/icons/robot.svg b/src/components/layouts/chat/icons/robot.svg similarity index 100% rename from app/components/chat/icons/robot.svg rename to src/components/layouts/chat/icons/robot.svg diff --git a/app/components/chat/icons/send-active.svg b/src/components/layouts/chat/icons/send-active.svg similarity index 100% rename from app/components/chat/icons/send-active.svg rename to src/components/layouts/chat/icons/send-active.svg diff --git a/app/components/chat/icons/send.svg b/src/components/layouts/chat/icons/send.svg similarity index 100% rename from app/components/chat/icons/send.svg rename to src/components/layouts/chat/icons/send.svg diff --git a/app/components/chat/icons/typing.svg b/src/components/layouts/chat/icons/typing.svg similarity index 100% rename from app/components/chat/icons/typing.svg rename to src/components/layouts/chat/icons/typing.svg diff --git a/app/components/chat/icons/user.svg b/src/components/layouts/chat/icons/user.svg similarity index 100% rename from app/components/chat/icons/user.svg rename to src/components/layouts/chat/icons/user.svg 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..74c9512 --- /dev/null +++ b/src/components/layouts/main/main.tsx @@ -0,0 +1,706 @@ +'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'; +import { useAuthStore } from '@/stores/useAuthStore'; + +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 { hideHistoryChat, setHideHistoryCHat } = useAuthStore(); + 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); + setHideHistoryCHat(true); + }; + + 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 ( + <> +
+ + {!hideHistoryChat && } +
+ + ); +}; + +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/app/components/welcome/icons/logo.png b/src/components/welcome/icons/logo.png similarity index 100% rename from app/components/welcome/icons/logo.png rename to src/components/welcome/icons/logo.png diff --git a/src/components/welcome/index.tsx b/src/components/welcome/index.tsx new file mode 100644 index 0000000..634f51e --- /dev/null +++ b/src/components/welcome/index.tsx @@ -0,0 +1,370 @@ +'use client'; +import type { FC } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import TemplateVarPanel, { PanelTitle, VarOpBtnGroup } from '../value-panel'; +import FileUploaderInAttachmentWrapper from '../base/file-uploader-in-attachment'; +import s from './style.module.css'; +import { AppInfoComp, ChatBtn, EditBtn, FootLogo, PromptTemplate } from './massive-component'; +import type { AppInfo, PromptConfig } from '@/types/app'; +import Toast from '@/components/base/toast'; +import Select from '@/components/base/select'; +import { DEFAULT_VALUE_MAX_LEN } from '@/config'; + +// regex to match the {{}} and replace it with a span +const regex = /\{\{([^}]+)\}\}/g; + +export type IWelcomeProps = { + conversationName: string; + hasSetInputs: boolean; + isPublicVersion: boolean; + siteInfo: AppInfo; + promptConfig: PromptConfig; + onStartChat: (inputs: Record) => 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' && ( +