feat: init
22
.editorconfig
Normal file
@ -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
|
||||
28
.eslintrc.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
49
.gitignore
vendored
Normal file
@ -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
|
||||
28
.vscode/launch.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
27
.vscode/settings.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
53
README.md
Normal file
@ -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.
|
||||
17
app/api/chat-messages/route.ts
Normal file
@ -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)
|
||||
}
|
||||
11
app/api/conversations/route.ts
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
13
app/api/messages/route.ts
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
11
app/api/parameters/route.ts
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
102
app/api/sdk.js
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
11
app/api/site/route.ts
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
20
app/api/utils/common.ts
Normal file
@ -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)
|
||||
25
app/api/utils/stream.ts
Normal file
@ -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;
|
||||
}
|
||||
31
app/components/app-unavailable.tsx
Normal file
@ -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<IAppUnavailableProps> = ({
|
||||
isUnknwonReason,
|
||||
errMessage,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
let message = errMessage
|
||||
if (!errMessage) {
|
||||
message = (isUnknwonReason ? t('app.common.appUnkonwError') : t('app.common.appUnavailable')) as string
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center w-screen h-screen'>
|
||||
<h1 className='mr-5 h-[50px] leading-[50px] pr-5 text-[24px] font-medium'
|
||||
style={{
|
||||
borderRight: '1px solid rgba(0,0,0,.3)',
|
||||
}}>{(errMessage || isUnknwonReason) ? 500 : 404}</h1>
|
||||
<div className='text-sm'>{message}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AppUnavailable)
|
||||
36
app/components/base/app-icon/index.tsx
Normal file
@ -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<AppIconProps> = ({
|
||||
size = 'medium',
|
||||
rounded = false,
|
||||
background,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
style.appIcon,
|
||||
size !== 'medium' && style[size],
|
||||
rounded && style.rounded,
|
||||
className ?? '',
|
||||
)}
|
||||
style={{
|
||||
background,
|
||||
}}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppIcon
|
||||
15
app/components/base/app-icon/style.module.css
Normal file
@ -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;
|
||||
}
|
||||
73
app/components/base/auto-height-textarea/index.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { forwardRef, useEffect, useRef } from 'react'
|
||||
import cn from 'classnames'
|
||||
|
||||
type IProps = {
|
||||
placeholder?: string
|
||||
value: string
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
className?: string
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
autoFocus?: boolean
|
||||
controlFocus?: number
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
onKeyUp?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
|
||||
const AutoHeightTextarea = forwardRef(
|
||||
(
|
||||
{ value, onChange, placeholder, className, minHeight = 36, maxHeight = 96, autoFocus, controlFocus, onKeyDown, onKeyUp }: IProps,
|
||||
outerRef: any,
|
||||
) => {
|
||||
const ref = outerRef || useRef<HTMLTextAreaElement>(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 (
|
||||
<div className='relative'>
|
||||
<div className={cn(className, 'invisible whitespace-pre-wrap break-all overflow-y-auto')} style={{ minHeight, maxHeight }}>
|
||||
{!value ? placeholder : value.replace(/\n$/, '\n ')}
|
||||
</div>
|
||||
<textarea
|
||||
ref={ref}
|
||||
autoFocus={autoFocus}
|
||||
className={cn(className, 'absolute inset-0 resize-none overflow-hidden')}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default AutoHeightTextarea
|
||||
44
app/components/base/button/index.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { FC, MouseEventHandler } from 'react'
|
||||
import React from 'react'
|
||||
import Spinner from '@/app/components/base/spinner'
|
||||
|
||||
export type IButtonProps = {
|
||||
type?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
children: React.ReactNode
|
||||
onClick?: MouseEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
const Button: FC<IButtonProps> = ({
|
||||
type,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
loading = false,
|
||||
}) => {
|
||||
let style = 'cursor-pointer'
|
||||
switch (type) {
|
||||
case 'primary':
|
||||
style = (disabled || loading) ? 'bg-primary-600/75 cursor-not-allowed text-white' : 'bg-primary-600 hover:bg-primary-600/75 hover:shadow-md cursor-pointer text-white hover:shadow-sm'
|
||||
break
|
||||
default:
|
||||
style = disabled ? 'border-solid border border-gray-200 bg-gray-200 cursor-not-allowed text-gray-800' : 'border-solid border border-gray-200 cursor-pointer text-gray-500 hover:bg-white hover:shadow-sm hover:border-gray-300'
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center items-center content-center h-9 leading-5 rounded-lg px-4 py-2 text-base ${style} ${className && className}`}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
>
|
||||
{children}
|
||||
{/* Spinner is hidden when loading is false */}
|
||||
<Spinner loading={loading} className='!text-white !h-3 !w-3 !border-2 !ml-1' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Button)
|
||||
31
app/components/base/loading/index.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
|
||||
import './style.css'
|
||||
|
||||
type ILoadingProps = {
|
||||
type?: 'area' | 'app'
|
||||
}
|
||||
const Loading = (
|
||||
{ type = 'area' }: ILoadingProps = { type: 'area' },
|
||||
) => {
|
||||
return (
|
||||
<div className={`flex w-full justify-center items-center ${type === 'app' ? 'h-full' : ''}`}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className='spin-animation'>
|
||||
<g clipPath="url(#clip0_324_2488)">
|
||||
<path d="M15 0H10C9.44772 0 9 0.447715 9 1V6C9 6.55228 9.44772 7 10 7H15C15.5523 7 16 6.55228 16 6V1C16 0.447715 15.5523 0 15 0Z" fill="#1C64F2" />
|
||||
<path opacity="0.5" d="M15 9H10C9.44772 9 9 9.44772 9 10V15C9 15.5523 9.44772 16 10 16H15C15.5523 16 16 15.5523 16 15V10C16 9.44772 15.5523 9 15 9Z" fill="#1C64F2" />
|
||||
<path opacity="0.1" d="M6 9H1C0.447715 9 0 9.44772 0 10V15C0 15.5523 0.447715 16 1 16H6C6.55228 16 7 15.5523 7 15V10C7 9.44772 6.55228 9 6 9Z" fill="#1C64F2" />
|
||||
<path opacity="0.2" d="M6 0H1C0.447715 0 0 0.447715 0 1V6C0 6.55228 0.447715 7 1 7H6C6.55228 7 7 6.55228 7 6V1C7 0.447715 6.55228 0 6 0Z" fill="#1C64F2" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_324_2488">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Loading
|
||||
41
app/components/base/loading/style.css
Normal file
@ -0,0 +1,41 @@
|
||||
.spin-animation path {
|
||||
animation: custom 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes custom {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
25% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
75% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.spin-animation path:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.spin-animation path:nth-child(2) {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.spin-animation path:nth-child(3) {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.spin-animation path:nth-child(4) {
|
||||
animation-delay: 1.5s;
|
||||
}
|
||||
45
app/components/base/markdown.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import RemarkMath from 'remark-math'
|
||||
import RemarkBreaks from 'remark-breaks'
|
||||
import RehypeKatex from 'rehype-katex'
|
||||
import RemarkGfm from 'remark-gfm'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter'
|
||||
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
|
||||
|
||||
export function Markdown(props: { content: string }) {
|
||||
return (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||
rehypePlugins={[
|
||||
RehypeKatex,
|
||||
]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
return !inline && match
|
||||
? (
|
||||
<SyntaxHighlighter
|
||||
{...props}
|
||||
children={String(children).replace(/\n$/, '')}
|
||||
style={atelierHeathLight}
|
||||
language={match[1]}
|
||||
showLineNumbers
|
||||
PreTag="div"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<code {...props} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
linkTarget={'_blank'}
|
||||
>
|
||||
{props.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
216
app/components/base/select/index.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { Fragment, useEffect, useState } from 'react'
|
||||
import { Combobox, Listbox, Transition } from '@headlessui/react'
|
||||
import classNames from 'classnames'
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
const defaultItems = [
|
||||
{ value: 1, name: 'option1' },
|
||||
{ value: 2, name: 'option2' },
|
||||
{ value: 3, name: 'option3' },
|
||||
{ value: 4, name: 'option4' },
|
||||
{ value: 5, name: 'option5' },
|
||||
{ value: 6, name: 'option6' },
|
||||
{ value: 7, name: 'option7' },
|
||||
]
|
||||
|
||||
export type Item = {
|
||||
value: number | string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ISelectProps = {
|
||||
className?: string
|
||||
items?: Item[]
|
||||
defaultValue?: number | string
|
||||
disabled?: boolean
|
||||
onSelect: (value: Item) => void
|
||||
allowSearch?: boolean
|
||||
bgClassName?: string
|
||||
}
|
||||
const Select: FC<ISelectProps> = ({
|
||||
className,
|
||||
items = defaultItems,
|
||||
defaultValue = 1,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
allowSearch = true,
|
||||
bgClassName = 'bg-gray-100',
|
||||
}) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
|
||||
useEffect(() => {
|
||||
let defaultSelect = null
|
||||
const existed = items.find((item: Item) => item.value === defaultValue)
|
||||
if (existed)
|
||||
defaultSelect = existed
|
||||
|
||||
setSelectedItem(defaultSelect)
|
||||
}, [defaultValue])
|
||||
|
||||
const filteredItems: Item[]
|
||||
= query === ''
|
||||
? items
|
||||
: items.filter((item) => {
|
||||
return item.name.toLowerCase().includes(query.toLowerCase())
|
||||
})
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
disabled={disabled}
|
||||
value={selectedItem}
|
||||
className={className}
|
||||
onChange={(value: Item) => {
|
||||
if (!disabled) {
|
||||
setSelectedItem(value)
|
||||
setOpen(false)
|
||||
onSelect(value)
|
||||
}
|
||||
}}>
|
||||
<div className={classNames('relative')}>
|
||||
<div className='group text-gray-800'>
|
||||
{allowSearch
|
||||
? <Combobox.Input
|
||||
className={`w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-not-allowed`}
|
||||
onChange={(event) => {
|
||||
if (!disabled)
|
||||
setQuery(event.target.value)
|
||||
}}
|
||||
displayValue={(item: Item) => item?.name}
|
||||
/>
|
||||
: <Combobox.Button onClick={
|
||||
() => {
|
||||
if (!disabled)
|
||||
setOpen(!open)
|
||||
}
|
||||
} className={`flex items-center h-9 w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200`}>
|
||||
{selectedItem?.name}
|
||||
</Combobox.Button>}
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none group-hover:bg-gray-200" onClick={
|
||||
() => {
|
||||
if (!disabled)
|
||||
setOpen(!open)
|
||||
}
|
||||
}>
|
||||
{open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
|
||||
{filteredItems.length > 0 && (
|
||||
<Combobox.Options className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
|
||||
{filteredItems.map((item: Item) => (
|
||||
<Combobox.Option
|
||||
key={item.value}
|
||||
value={item}
|
||||
className={({ active }: { active: boolean }) =>
|
||||
classNames(
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700',
|
||||
active ? 'bg-gray-100' : '',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ /* active, */ selected }) => (
|
||||
<>
|
||||
<span className={classNames('block truncate', selected && 'font-normal')}>{item.name}</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</div>
|
||||
</Combobox >
|
||||
)
|
||||
}
|
||||
|
||||
const SimpleSelect: FC<ISelectProps> = ({
|
||||
className,
|
||||
items = defaultItems,
|
||||
defaultValue = 1,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
|
||||
useEffect(() => {
|
||||
let defaultSelect = null
|
||||
const existed = items.find((item: Item) => item.value === defaultValue)
|
||||
if (existed)
|
||||
defaultSelect = existed
|
||||
|
||||
setSelectedItem(defaultSelect)
|
||||
}, [defaultValue])
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
value={selectedItem}
|
||||
onChange={(value: Item) => {
|
||||
if (!disabled) {
|
||||
setSelectedItem(value)
|
||||
onSelect(value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative h-9">
|
||||
<Listbox.Button className={`w-full h-full rounded-lg border-0 bg-gray-100 py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-pointer ${className}`}>
|
||||
<span className="block truncate text-left">{selectedItem?.name}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
className="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
|
||||
{items.map((item: Item) => (
|
||||
<Listbox.Option
|
||||
key={item.value}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700 ${active ? 'bg-gray-100' : ''
|
||||
}`
|
||||
}
|
||||
value={item}
|
||||
disabled={disabled}
|
||||
>
|
||||
{({ /* active, */ selected }) => (
|
||||
<>
|
||||
<span className={classNames('block truncate', selected && 'font-normal')}>{item.name}</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
)
|
||||
}
|
||||
export { SimpleSelect }
|
||||
export default React.memo(Select)
|
||||
24
app/components/base/spinner/index.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
loading?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode | string
|
||||
}
|
||||
|
||||
const Spinner: FC<Props> = ({ loading = false, children, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={`inline-block text-gray-200 h-4 w-4 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] ${loading ? 'motion-reduce:animate-[spin_1.5s_linear_infinite]' : 'hidden'} ${className ?? ''}`}
|
||||
role="status"
|
||||
>
|
||||
<span
|
||||
className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]"
|
||||
>Loading...</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Spinner
|
||||
131
app/components/base/toast/index.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
import classNames from 'classnames'
|
||||
import type { ReactNode } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import { createContext } from 'use-context-selector'
|
||||
|
||||
export type IToastProps = {
|
||||
type?: 'success' | 'error' | 'warning' | 'info'
|
||||
duration?: number
|
||||
message: string
|
||||
children?: ReactNode
|
||||
onClose?: () => void
|
||||
}
|
||||
type IToastContext = {
|
||||
notify: (props: IToastProps) => void
|
||||
}
|
||||
const defaultDuring = 3000
|
||||
|
||||
export const ToastContext = createContext<IToastContext>({} as IToastContext)
|
||||
const Toast = ({
|
||||
type = 'info',
|
||||
duration,
|
||||
message,
|
||||
children,
|
||||
}: IToastProps) => {
|
||||
// sometimes message is react node array. Not handle it.
|
||||
if (typeof message !== 'string')
|
||||
return null
|
||||
|
||||
return <div className={classNames(
|
||||
'fixed rounded-md p-4 my-4 mx-8 z-50',
|
||||
'top-0',
|
||||
'right-0',
|
||||
type === 'success' ? 'bg-green-50' : '',
|
||||
type === 'error' ? 'bg-red-50' : '',
|
||||
type === 'warning' ? 'bg-yellow-50' : '',
|
||||
type === 'info' ? 'bg-blue-50' : '',
|
||||
)}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
{type === 'success' && <CheckCircleIcon className="w-5 h-5 text-green-400" aria-hidden="true" />}
|
||||
{type === 'error' && <XCircleIcon className="w-5 h-5 text-red-400" aria-hidden="true" />}
|
||||
{type === 'warning' && <ExclamationTriangleIcon className="w-5 h-5 text-yellow-400" aria-hidden="true" />}
|
||||
{type === 'info' && <InformationCircleIcon className="w-5 h-5 text-blue-400" aria-hidden="true" />}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className={
|
||||
classNames(
|
||||
'text-sm font-medium',
|
||||
type === 'success' ? 'text-green-800' : '',
|
||||
type === 'error' ? 'text-red-800' : '',
|
||||
type === 'warning' ? 'text-yellow-800' : '',
|
||||
type === 'info' ? 'text-blue-800' : '',
|
||||
)
|
||||
}>{message}</h3>
|
||||
{children && <div className={
|
||||
classNames(
|
||||
'mt-2 text-sm',
|
||||
type === 'success' ? 'text-green-700' : '',
|
||||
type === 'error' ? 'text-red-700' : '',
|
||||
type === 'warning' ? 'text-yellow-700' : '',
|
||||
type === 'info' ? 'text-blue-700' : '',
|
||||
)
|
||||
}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export const ToastProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const placeholder: IToastProps = {
|
||||
type: 'info',
|
||||
message: 'Toast message',
|
||||
duration: 3000,
|
||||
}
|
||||
const [params, setParams] = React.useState<IToastProps>(placeholder)
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
setTimeout(() => {
|
||||
setMounted(false)
|
||||
}, params.duration || defaultDuring)
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
return <ToastContext.Provider value={{
|
||||
notify: (props) => {
|
||||
setMounted(true)
|
||||
setParams(props)
|
||||
},
|
||||
}}>
|
||||
{mounted && <Toast {...params} />}
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
}
|
||||
|
||||
Toast.notify = ({
|
||||
type,
|
||||
message,
|
||||
duration,
|
||||
}: Pick<IToastProps, 'type' | 'message' | 'duration'>) => {
|
||||
if (typeof window === 'object') {
|
||||
const holder = document.createElement('div')
|
||||
const root = createRoot(holder)
|
||||
|
||||
root.render(<Toast type={type} message={message} duration={duration} />)
|
||||
document.body.appendChild(holder)
|
||||
setTimeout(() => {
|
||||
if (holder)
|
||||
holder.remove()
|
||||
}, duration || defaultDuring)
|
||||
}
|
||||
}
|
||||
|
||||
export default Toast
|
||||
43
app/components/base/toast/style.module.css
Normal file
@ -0,0 +1,43 @@
|
||||
.toast {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
width: 1.84rem;
|
||||
height: 1.80rem;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
background: #000000;
|
||||
box-shadow: 0 -.04rem .1rem 1px rgba(255, 255, 255, 0.1);
|
||||
border-radius: .1rem .1rem .1rem .1rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: .2rem;
|
||||
height: .4rem;
|
||||
background: center center no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
/* .success {
|
||||
background-image: url('./icons/success.svg');
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-image: url('./icons/warning.svg');
|
||||
}
|
||||
|
||||
.error {
|
||||
background-image: url('./icons/error.svg');
|
||||
} */
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
font-size: .2rem;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
46
app/components/base/tooltip/index.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
import classNames from 'classnames'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { Tooltip as ReactTooltip } from 'react-tooltip' // fixed version to 5.8.3 https://github.com/ReactTooltip/react-tooltip/issues/972
|
||||
import 'react-tooltip/dist/react-tooltip.css'
|
||||
|
||||
type TooltipProps = {
|
||||
selector: string
|
||||
content?: string
|
||||
htmlContent?: React.ReactNode
|
||||
className?: string // This should use !impornant to override the default styles eg: '!bg-white'
|
||||
position?: 'top' | 'right' | 'bottom' | 'left'
|
||||
clickable?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Tooltip: FC<TooltipProps> = ({
|
||||
selector,
|
||||
content,
|
||||
position = 'top',
|
||||
children,
|
||||
htmlContent,
|
||||
className,
|
||||
clickable,
|
||||
}) => {
|
||||
return (
|
||||
<div className='tooltip-container'>
|
||||
{React.cloneElement(children as React.ReactElement, {
|
||||
'data-tooltip-id': selector,
|
||||
})
|
||||
}
|
||||
<ReactTooltip
|
||||
id={selector}
|
||||
content={content}
|
||||
className={classNames('!bg-white !text-xs !font-normal !text-gray-700 !shadow-lg !opacity-100', className)}
|
||||
place={position}
|
||||
clickable={clickable}
|
||||
>
|
||||
{htmlContent && htmlContent}
|
||||
</ReactTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
3
app/components/chat/icons/answer.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.03647 1.5547C0.59343 0.890144 1.06982 0 1.86852 0H8V12L1.03647 1.5547Z" fill="#F3F4F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 202 B |
BIN
app/components/chat/icons/default-avatar.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
3
app/components/chat/icons/edit.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 11.9998L13.3332 12.7292C12.9796 13.1159 12.5001 13.3332 12.0001 13.3332C11.5001 13.3332 11.0205 13.1159 10.6669 12.7292C10.3128 12.3432 9.83332 12.1265 9.33345 12.1265C8.83359 12.1265 8.35409 12.3432 7.99998 12.7292M2 13.3332H3.11636C3.44248 13.3332 3.60554 13.3332 3.75899 13.2963C3.89504 13.2637 4.0251 13.2098 4.1444 13.1367C4.27895 13.0542 4.39425 12.9389 4.62486 12.7083L13 4.33316C13.5523 3.78087 13.5523 2.88544 13 2.33316C12.4477 1.78087 11.5523 1.78087 11 2.33316L2.62484 10.7083C2.39424 10.9389 2.27894 11.0542 2.19648 11.1888C2.12338 11.3081 2.0695 11.4381 2.03684 11.5742C2 11.7276 2 11.8907 2 12.2168V13.3332Z" stroke="#6B7280" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 804 B |
3
app/components/chat/icons/question.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.96353 1.5547C7.40657 0.890144 6.93018 0 6.13148 0H0V12L6.96353 1.5547Z" fill="#E1EFFE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 202 B |
10
app/components/chat/icons/robot.svg
Normal file
|
After Width: | Height: | Size: 36 KiB |
3
app/components/chat/icons/send-active.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.447 16.8939C23.6128 16.8108 23.7523 16.6832 23.8498 16.5253C23.9473 16.3674 23.9989 16.1855 23.9989 15.9999C23.9989 15.8144 23.9473 15.6325 23.8498 15.4746C23.7523 15.3167 23.6128 15.1891 23.447 15.1059L9.44697 8.10595C9.27338 8.01909 9.07827 7.98463 8.88543 8.00677C8.69259 8.02891 8.51036 8.1067 8.36098 8.23064C8.2116 8.35458 8.10151 8.51931 8.04415 8.70475C7.9868 8.89019 7.98465 9.08831 8.03797 9.27495L9.46697 14.2749C9.52674 14.4839 9.65297 14.6677 9.82655 14.7985C10.0001 14.9294 10.2116 15.0001 10.429 14.9999H15C15.2652 14.9999 15.5195 15.1053 15.7071 15.2928C15.8946 15.4804 16 15.7347 16 15.9999C16 16.2652 15.8946 16.5195 15.7071 16.7071C15.5195 16.8946 15.2652 16.9999 15 16.9999H10.429C10.2116 16.9998 10.0001 17.0705 9.82655 17.2013C9.65297 17.3322 9.52674 17.516 9.46697 17.7249L8.03897 22.7249C7.98554 22.9115 7.98756 23.1096 8.04478 23.2951C8.10201 23.4805 8.21195 23.6453 8.36122 23.7693C8.51049 23.8934 8.69263 23.9713 8.88542 23.9936C9.07821 24.0159 9.27332 23.9816 9.44697 23.8949L23.447 16.8949V16.8939Z" fill="#1C64F2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
app/components/chat/icons/send.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.447 16.8939C23.6128 16.8108 23.7523 16.6832 23.8498 16.5253C23.9473 16.3674 23.9989 16.1855 23.9989 15.9999C23.9989 15.8144 23.9473 15.6325 23.8498 15.4746C23.7523 15.3167 23.6128 15.1891 23.447 15.1059L9.44697 8.10595C9.27338 8.01909 9.07827 7.98463 8.88543 8.00677C8.69259 8.02891 8.51036 8.1067 8.36098 8.23064C8.2116 8.35458 8.10151 8.51931 8.04415 8.70475C7.9868 8.89019 7.98465 9.08831 8.03797 9.27495L9.46697 14.2749C9.52674 14.4839 9.65297 14.6677 9.82655 14.7985C10.0001 14.9294 10.2116 15.0001 10.429 14.9999H15C15.2652 14.9999 15.5195 15.1053 15.7071 15.2928C15.8946 15.4804 16 15.7347 16 15.9999C16 16.2652 15.8946 16.5195 15.7071 16.7071C15.5195 16.8946 15.2652 16.9999 15 16.9999H10.429C10.2116 16.9998 10.0001 17.0705 9.82655 17.2013C9.65297 17.3322 9.52674 17.516 9.46697 17.7249L8.03897 22.7249C7.98554 22.9115 7.98756 23.1096 8.04478 23.2951C8.10201 23.4805 8.21195 23.6453 8.36122 23.7693C8.51049 23.8934 8.69263 23.9713 8.88542 23.9936C9.07821 24.0159 9.27332 23.9816 9.44697 23.8949L23.447 16.8949V16.8939Z" fill="#D1D5DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
19
app/components/chat/icons/typing.svg
Normal file
@ -0,0 +1,19 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_2358_1380)">
|
||||
<rect x="2" y="1" width="16" height="16" rx="8" fill="white"/>
|
||||
<path opacity="0.7" d="M13.5 9H13.505M14 9C14 9.13261 13.9473 9.25979 13.8536 9.35355C13.7598 9.44732 13.6326 9.5 13.5 9.5C13.3674 9.5 13.2402 9.44732 13.1464 9.35355C13.0527 9.25979 13 9.13261 13 9C13 8.86739 13.0527 8.74021 13.1464 8.64645C13.2402 8.55268 13.3674 8.5 13.5 8.5C13.6326 8.5 13.7598 8.55268 13.8536 8.64645C13.9473 8.74021 14 8.86739 14 9Z" stroke="#155EEF" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path opacity="0.6" d="M10 9H10.005M10.5 9C10.5 9.13261 10.4473 9.25979 10.3536 9.35355C10.2598 9.44732 10.1326 9.5 10 9.5C9.86739 9.5 9.74021 9.44732 9.64645 9.35355C9.55268 9.25979 9.5 9.13261 9.5 9C9.5 8.86739 9.55268 8.74021 9.64645 8.64645C9.74021 8.55268 9.86739 8.5 10 8.5C10.1326 8.5 10.2598 8.55268 10.3536 8.64645C10.4473 8.74021 10.5 8.86739 10.5 9Z" stroke="#155EEF" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path opacity="0.3" d="M6.5 9H6.505M7 9C7 9.13261 6.94732 9.25979 6.85355 9.35355C6.75979 9.44732 6.63261 9.5 6.5 9.5C6.36739 9.5 6.24021 9.44732 6.14645 9.35355C6.05268 9.25979 6 9.13261 6 9C6 8.86739 6.05268 8.74021 6.14645 8.64645C6.24021 8.55268 6.36739 8.5 6.5 8.5C6.63261 8.5 6.75979 8.55268 6.85355 8.64645C6.94732 8.74021 7 8.86739 7 9Z" stroke="#155EEF" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_2358_1380" x="0" y="0" width="20" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2358_1380"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2358_1380" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
10
app/components/chat/icons/user.svg
Normal file
|
After Width: | Height: | Size: 74 KiB |
336
app/components/chat/index.tsx
Normal file
@ -0,0 +1,336 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import cn from 'classnames'
|
||||
import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import s from './style.module.css'
|
||||
import { randomString } from '@/utils/string'
|
||||
import type { Feedbacktype, MessageRating } from '@/types/app'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
|
||||
export type FeedbackFunc = (messageId: string, feedback: Feedbacktype) => Promise<any>
|
||||
|
||||
export type IChatProps = {
|
||||
chatList: IChatItem[]
|
||||
/**
|
||||
* Whether to display the editing area and rating status
|
||||
*/
|
||||
feedbackDisabled?: boolean
|
||||
/**
|
||||
* Whether to display the input area
|
||||
*/
|
||||
isHideSendInput?: boolean
|
||||
onFeedback?: FeedbackFunc
|
||||
checkCanSend?: () => boolean
|
||||
onSend?: (message: string) => void
|
||||
useCurrentUserAvatar?: boolean
|
||||
isResponsing?: boolean
|
||||
controlClearQuery?: number
|
||||
controlFocus?: number
|
||||
}
|
||||
|
||||
export type IChatItem = {
|
||||
id: string
|
||||
content: string
|
||||
/**
|
||||
* Specific message type
|
||||
*/
|
||||
isAnswer: boolean
|
||||
/**
|
||||
* The user feedback result of this message
|
||||
*/
|
||||
feedback?: Feedbacktype
|
||||
/**
|
||||
* Whether to hide the feedback area
|
||||
*/
|
||||
feedbackDisabled?: boolean
|
||||
isIntroduction?: boolean
|
||||
useCurrentUserAvatar?: boolean
|
||||
isOpeningStatement?: boolean
|
||||
}
|
||||
|
||||
const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => (
|
||||
<div
|
||||
className={`relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800 ${className ?? ''}`}
|
||||
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={onClick && onClick}
|
||||
>
|
||||
{innerContent}
|
||||
</div>
|
||||
)
|
||||
|
||||
const OpeningStatementIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M6.25002 1C3.62667 1 1.50002 3.12665 1.50002 5.75C1.50002 6.28 1.58702 6.79071 1.7479 7.26801C1.7762 7.35196 1.79285 7.40164 1.80368 7.43828L1.80722 7.45061L1.80535 7.45452C1.79249 7.48102 1.77339 7.51661 1.73766 7.58274L0.911727 9.11152C0.860537 9.20622 0.807123 9.30503 0.770392 9.39095C0.733879 9.47635 0.674738 9.63304 0.703838 9.81878C0.737949 10.0365 0.866092 10.2282 1.05423 10.343C1.21474 10.4409 1.38213 10.4461 1.475 10.4451C1.56844 10.444 1.68015 10.4324 1.78723 10.4213L4.36472 10.1549C4.406 10.1506 4.42758 10.1484 4.44339 10.1472L4.44542 10.147L4.45161 10.1492C4.47103 10.1562 4.49738 10.1663 4.54285 10.1838C5.07332 10.3882 5.64921 10.5 6.25002 10.5C8.87338 10.5 11 8.37335 11 5.75C11 3.12665 8.87338 1 6.25002 1ZM4.48481 4.29111C5.04844 3.81548 5.7986 3.9552 6.24846 4.47463C6.69831 3.9552 7.43879 3.82048 8.01211 4.29111C8.58544 4.76175 8.6551 5.562 8.21247 6.12453C7.93825 6.47305 7.24997 7.10957 6.76594 7.54348C6.58814 7.70286 6.49924 7.78255 6.39255 7.81466C6.30103 7.84221 6.19589 7.84221 6.10436 7.81466C5.99767 7.78255 5.90878 7.70286 5.73098 7.54348C5.24694 7.10957 4.55867 6.47305 4.28444 6.12453C3.84182 5.562 3.92117 4.76675 4.48481 4.29111Z" fill="#667085" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const RatingIcon: FC<{ isLike: boolean }> = ({ isLike }) => {
|
||||
return isLike ? <HandThumbUpIcon className='w-4 h-4' /> : <HandThumbDownIcon className='w-4 h-4' />
|
||||
}
|
||||
|
||||
const EditIcon: FC<{ className?: string }> = ({ className }) => {
|
||||
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path d="M14 11.9998L13.3332 12.7292C12.9796 13.1159 12.5001 13.3332 12.0001 13.3332C11.5001 13.3332 11.0205 13.1159 10.6669 12.7292C10.3128 12.3432 9.83332 12.1265 9.33345 12.1265C8.83359 12.1265 8.35409 12.3432 7.99998 12.7292M2 13.3332H3.11636C3.44248 13.3332 3.60554 13.3332 3.75899 13.2963C3.89504 13.2637 4.0251 13.2098 4.1444 13.1367C4.27895 13.0542 4.39425 12.9389 4.62486 12.7083L13 4.33316C13.5523 3.78087 13.5523 2.88544 13 2.33316C12.4477 1.78087 11.5523 1.78087 11 2.33316L2.62484 10.7083C2.39424 10.9389 2.27894 11.0542 2.19648 11.1888C2.12338 11.3081 2.0695 11.4381 2.03684 11.5742C2 11.7276 2 11.8907 2 12.2168V13.3332Z" stroke="#6B7280" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const EditIconSolid: FC<{ className?: string }> = ({ className }) => {
|
||||
return <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path fillRule="evenodd" clip-rule="evenodd" d="M10.8374 8.63108C11.0412 8.81739 11.0554 9.13366 10.8691 9.33747L10.369 9.88449C10.0142 10.2725 9.52293 10.5001 9.00011 10.5001C8.47746 10.5001 7.98634 10.2727 7.63157 9.8849C7.45561 9.69325 7.22747 9.59515 7.00014 9.59515C6.77271 9.59515 6.54446 9.69335 6.36846 9.88517C6.18177 10.0886 5.86548 10.1023 5.66201 9.91556C5.45853 9.72888 5.44493 9.41259 5.63161 9.20911C5.98678 8.82201 6.47777 8.59515 7.00014 8.59515C7.52251 8.59515 8.0135 8.82201 8.36867 9.20911L8.36924 9.20974C8.54486 9.4018 8.77291 9.50012 9.00011 9.50012C9.2273 9.50012 9.45533 9.40182 9.63095 9.20979L10.131 8.66276C10.3173 8.45895 10.6336 8.44476 10.8374 8.63108Z" fill="#6B7280" />
|
||||
<path fillRule="evenodd" clip-rule="evenodd" d="M7.89651 1.39656C8.50599 0.787085 9.49414 0.787084 10.1036 1.39656C10.7131 2.00604 10.7131 2.99419 10.1036 3.60367L3.82225 9.88504C3.81235 9.89494 3.80254 9.90476 3.79281 9.91451C3.64909 10.0585 3.52237 10.1855 3.3696 10.2791C3.23539 10.3613 3.08907 10.4219 2.93602 10.4587C2.7618 10.5005 2.58242 10.5003 2.37897 10.5001C2.3652 10.5001 2.35132 10.5001 2.33732 10.5001H1.50005C1.22391 10.5001 1.00005 10.2763 1.00005 10.0001V9.16286C1.00005 9.14886 1.00004 9.13497 1.00003 9.1212C0.999836 8.91776 0.999669 8.73838 1.0415 8.56416C1.07824 8.4111 1.13885 8.26479 1.22109 8.13058C1.31471 7.97781 1.44166 7.85109 1.58566 7.70736C1.5954 7.69764 1.60523 7.68783 1.61513 7.67793L7.89651 1.39656Z" fill="#6B7280" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
const IconWrapper: FC<{ children: React.ReactNode | string }> = ({ children }) => {
|
||||
return <div className={'rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100'}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
type IAnswerProps = {
|
||||
item: IChatItem
|
||||
feedbackDisabled: boolean
|
||||
onFeedback?: FeedbackFunc
|
||||
isResponsing?: boolean
|
||||
}
|
||||
|
||||
// The component needs to maintain its own state to control whether to display input component
|
||||
const Answer: FC<IAnswerProps> = ({ item, feedbackDisabled = false, onFeedback, isResponsing }) => {
|
||||
const { id, content, feedback } = item
|
||||
const { t } = useTranslation()
|
||||
|
||||
/**
|
||||
* Render feedback results (distinguish between users and administrators)
|
||||
* User reviews cannot be cancelled in Console
|
||||
* @param rating feedback result
|
||||
* @param isUserFeedback Whether it is user's feedback
|
||||
* @returns comp
|
||||
*/
|
||||
const renderFeedbackRating = (rating: MessageRating | undefined) => {
|
||||
if (!rating)
|
||||
return null
|
||||
|
||||
const isLike = rating === 'like'
|
||||
const ratingIconClassname = isLike ? 'text-primary-600 bg-primary-100 hover:bg-primary-200' : 'text-red-600 bg-red-100 hover:bg-red-200'
|
||||
// The tooltip is always displayed, but the content is different for different scenarios.
|
||||
return (
|
||||
<Tooltip
|
||||
selector={`user-feedback-${randomString(16)}`}
|
||||
content={isLike ? '取消赞同' : '取消反对'}
|
||||
>
|
||||
<div
|
||||
className={'relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800'}
|
||||
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={async () => {
|
||||
await onFeedback?.(id, { rating: null })
|
||||
}}
|
||||
>
|
||||
<div className={`${ratingIconClassname} rounded-lg h-6 w-6 flex items-center justify-center`}>
|
||||
<RatingIcon isLike={isLike} />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Different scenarios have different operation items.
|
||||
* @returns comp
|
||||
*/
|
||||
const renderItemOperation = () => {
|
||||
const userOperation = () => {
|
||||
return feedback?.rating
|
||||
? null
|
||||
: <div className='flex gap-1'>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.like') as string}>
|
||||
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
|
||||
</Tooltip>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.dislike') as string}>
|
||||
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${s.itemOperation} flex gap-2`}>
|
||||
{userOperation()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={id}>
|
||||
<div className='flex items-start'>
|
||||
<div className={`${s.answerIcon} ${isResponsing ? s.typeingIcon : ''} w-10 h-10 shrink-0`}></div>
|
||||
<div className={`${s.answerWrap}`}>
|
||||
<div className={`${s.answer} relative text-sm text-gray-900`}>
|
||||
<div className={'ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl'}>
|
||||
{item.isOpeningStatement && (
|
||||
<div className='flex items-center mb-1 gap-1'>
|
||||
<OpeningStatementIcon />
|
||||
<div className='text-xs text-gray-500'>{t('app.chat.openingStatementTitle')}</div>
|
||||
</div>
|
||||
)}
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
<div className='absolute top-[-14px] right-[-14px] flex flex-row justify-end gap-1'>
|
||||
{!feedbackDisabled && !item.feedbackDisabled && renderItemOperation()}
|
||||
{/* User feedback must be displayed */}
|
||||
{!feedbackDisabled && renderFeedbackRating(feedback?.rating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'useCurrentUserAvatar'>
|
||||
|
||||
const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar }) => {
|
||||
const userName = ''
|
||||
return (
|
||||
<div className='flex items-start justify-end' key={id}>
|
||||
<div>
|
||||
<div className={`${s.question} relative text-sm text-gray-900`}>
|
||||
<div
|
||||
className={'mr-2 py-3 px-4 bg-blue-500 rounded-tl-2xl rounded-b-2xl'}
|
||||
>
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{useCurrentUserAvatar
|
||||
? (
|
||||
<div className='w-10 h-10 shrink-0 leading-10 text-center mr-2 rounded-full bg-primary-600 text-white'>
|
||||
{userName?.[0].toLocaleUpperCase()}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={`${s.questionIcon} w-10 h-10 shrink-0 `}></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Chat: FC<IChatProps> = ({
|
||||
chatList,
|
||||
feedbackDisabled = false,
|
||||
isHideSendInput = false,
|
||||
onFeedback,
|
||||
checkCanSend,
|
||||
onSend = () => { },
|
||||
useCurrentUserAvatar,
|
||||
isResponsing,
|
||||
controlClearQuery,
|
||||
controlFocus,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = Toast
|
||||
|
||||
const [query, setQuery] = React.useState('')
|
||||
const handleContentChange = (e: any) => {
|
||||
const value = e.target.value
|
||||
setQuery(value)
|
||||
}
|
||||
|
||||
const logError = (message: string) => {
|
||||
notify({ type: 'error', message, duration: 3000 })
|
||||
}
|
||||
|
||||
const valid = () => {
|
||||
if (!query || query.trim() === '') {
|
||||
logError('Message cannot be empty')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (controlClearQuery)
|
||||
setQuery('')
|
||||
}, [controlClearQuery])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!valid() || (checkCanSend && !checkCanSend()))
|
||||
return
|
||||
onSend(query)
|
||||
if (!isResponsing)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: any) => {
|
||||
if (e.code === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (!e.shiftKey)
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const haneleKeyDown = (e: any) => {
|
||||
if (e.code === 'Enter' && !e.shiftKey) {
|
||||
setQuery(query.replace(/\n$/, ''))
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(!feedbackDisabled && 'px-3.5', 'h-full')}>
|
||||
{/* Chat List */}
|
||||
<div className="h-full space-y-[30px]">
|
||||
{chatList.map((item) => {
|
||||
if (item.isAnswer) {
|
||||
const isLast = item.id === chatList[chatList.length - 1].id
|
||||
return <Answer
|
||||
key={item.id}
|
||||
item={item}
|
||||
feedbackDisabled={feedbackDisabled}
|
||||
onFeedback={onFeedback}
|
||||
isResponsing={isResponsing && isLast}
|
||||
/>
|
||||
}
|
||||
return <Question key={item.id} id={item.id} content={item.content} useCurrentUserAvatar={useCurrentUserAvatar} />
|
||||
})}
|
||||
</div>
|
||||
{
|
||||
!isHideSendInput && (
|
||||
<div className={cn(!feedbackDisabled && '!left-3.5 !right-3.5', 'absolute z-10 bottom-0 left-0 right-0')}>
|
||||
<div className="positive">
|
||||
<AutoHeightTextarea
|
||||
value={query}
|
||||
onChange={handleContentChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={haneleKeyDown}
|
||||
minHeight={48}
|
||||
autoFocus
|
||||
controlFocus={controlFocus}
|
||||
className={`${cn(s.textArea)} resize-none block w-full pl-3 bg-gray-50 border border-gray-200 rounded-md focus:outline-none sm:text-sm text-gray-700`}
|
||||
/>
|
||||
<div className="absolute top-0 right-2 flex items-center h-[48px]">
|
||||
<div className={`${s.count} mr-4 h-5 leading-5 text-sm bg-gray-50 text-gray-500`}>{query.trim().length}</div>
|
||||
<Tooltip
|
||||
selector='send-tip'
|
||||
htmlContent={
|
||||
<div>
|
||||
<div>{t('common.operation.send')} Enter</div>
|
||||
<div>{t('common.operation.lineBreak')} Shift Enter</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={`${s.sendBtn} w-8 h-8 cursor-pointer rounded-md`} onClick={handleSend}></div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Chat)
|
||||
90
app/components/chat/style.module.css
Normal file
@ -0,0 +1,90 @@
|
||||
.answerIcon {
|
||||
background: url(./icons/robot.svg);
|
||||
}
|
||||
|
||||
.typeingIcon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.typeingIcon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: url(./icons/typing.svg) no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.questionIcon {
|
||||
background: url(./icons/default-avatar.jpg);
|
||||
background-size: contain;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.answer::before,
|
||||
.question::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 8px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.answer::before {
|
||||
left: 0;
|
||||
background: url(./icons/answer.svg) no-repeat;
|
||||
}
|
||||
|
||||
.answerWrap .itemOperation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.answerWrap:hover .itemOperation {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.question::before {
|
||||
right: 0;
|
||||
background: url(./icons/question.svg) no-repeat;
|
||||
}
|
||||
|
||||
.textArea {
|
||||
padding-top: 13px;
|
||||
padding-bottom: 13px;
|
||||
padding-right: 90px;
|
||||
border-radius: 12px;
|
||||
line-height: 20px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.textArea:hover {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* .textArea:focus {
|
||||
box-shadow: 0px 3px 15px -3px rgba(0, 0, 0, 0.1), 0px 4px 6px rgba(0, 0, 0, 0.05);
|
||||
} */
|
||||
|
||||
.count {
|
||||
/* display: none; */
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.sendBtn {
|
||||
background: url(./icons/send.svg) center center no-repeat;
|
||||
}
|
||||
|
||||
.sendBtn:hover {
|
||||
background-image: url(./icons/send-active.svg);
|
||||
background-color: #EBF5FF;
|
||||
}
|
||||
|
||||
.textArea:focus+div .count {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.textArea:focus+div .sendBtn {
|
||||
background-image: url(./icons/send-active.svg);
|
||||
}
|
||||
13
app/components/config-scence/index.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { IWelcomeProps } from '../welcome'
|
||||
import Welcome from '../welcome'
|
||||
|
||||
const ConfigSence: FC<IWelcomeProps> = (props) => {
|
||||
return (
|
||||
<div className='mb-5 antialiased font-sans overflow-hidden shrink-0'>
|
||||
<Welcome {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigSence)
|
||||
48
app/components/header.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
Bars3Icon,
|
||||
PencilSquareIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
export type IHeaderProps = {
|
||||
title: string
|
||||
isMobile?: boolean
|
||||
onShowSideBar?: () => void
|
||||
onCreateNewChat?: () => void
|
||||
}
|
||||
const Header: FC<IHeaderProps> = ({
|
||||
title,
|
||||
isMobile,
|
||||
onShowSideBar,
|
||||
onCreateNewChat,
|
||||
}) => {
|
||||
return (
|
||||
<div className="shrink-0 flex items-center justify-between h-12 px-3 bg-gray-100">
|
||||
{isMobile
|
||||
? (
|
||||
<div
|
||||
className='flex items-center justify-center h-8 w-8 cursor-pointer'
|
||||
onClick={() => onShowSideBar?.()}
|
||||
>
|
||||
<Bars3Icon className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
)
|
||||
: <div></div>}
|
||||
<div className='flex items-center space-x-2'>
|
||||
<AppIcon size="small" />
|
||||
<div className=" text-sm text-gray-800 font-bold">{title}</div>
|
||||
</div>
|
||||
{isMobile
|
||||
? (
|
||||
<div className='flex items-center justify-center h-8 w-8 cursor-pointer'
|
||||
onClick={() => onCreateNewChat?.()}
|
||||
>
|
||||
<PencilSquareIcon className="h-4 w-4 text-gray-500" />
|
||||
</div>)
|
||||
: <div></div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
461
app/components/index.tsx
Normal file
@ -0,0 +1,461 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import useConversation from '@/hooks/use-conversation'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Sidebar from '@/app/components/sidebar'
|
||||
import ConfigSence from '@/app/components/config-scence'
|
||||
import Header from '@/app/components/header'
|
||||
import { fetchAppInfo, fetchAppParams, fetchChatList, fetchConversations, sendChatMessage, updateFeedback } from '@/service'
|
||||
import type { ConversationItem, Feedbacktype, IChatItem, PromptConfig, SiteInfo } from '@/types/app'
|
||||
import Chat from '@/app/components/chat'
|
||||
import { setLocaleOnClient } from '@/i18n/client'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { replaceVarWithValues } from '@/utils/prompt'
|
||||
import AppUnavailable from '@/app/components/app-unavailable'
|
||||
import { APP_ID, API_KEY } from '@/config'
|
||||
|
||||
export type IMainProps = {
|
||||
params: {
|
||||
locale: string
|
||||
appId: string
|
||||
conversationId: string
|
||||
token: string
|
||||
}
|
||||
}
|
||||
|
||||
const Main: FC<IMainProps> = () => {
|
||||
const { t } = useTranslation()
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const hasSetAppConfig = APP_ID && API_KEY
|
||||
|
||||
/*
|
||||
* app info
|
||||
*/
|
||||
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
|
||||
const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
|
||||
const [appId, setAppId] = useState<string>('')
|
||||
const [isPublicVersion, setIsPublicVersion] = useState<boolean>(true)
|
||||
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>()
|
||||
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
|
||||
const [inited, setInited] = useState<boolean>(false)
|
||||
const [plan, setPlan] = useState<string>('basic') // basic/plus/pro
|
||||
// in mobile, show sidebar by click button
|
||||
const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (siteInfo?.title) {
|
||||
if (plan !== 'basic')
|
||||
document.title = `${siteInfo.title}`
|
||||
else
|
||||
document.title = `${siteInfo.title} - Powered by LangGenius`
|
||||
}
|
||||
}, [siteInfo?.title, plan])
|
||||
|
||||
/*
|
||||
* conversation info
|
||||
*/
|
||||
const {
|
||||
conversationList,
|
||||
setConversationList,
|
||||
currConversationId,
|
||||
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<string, any>) => {
|
||||
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 handleConversationSwitch = () => {
|
||||
if (!inited)
|
||||
return
|
||||
if (!appId) {
|
||||
// wait for appId
|
||||
setTimeout(handleConversationSwitch, 100)
|
||||
return
|
||||
}
|
||||
|
||||
// update inputs of current conversation
|
||||
let notSyncToStateIntroduction = ''
|
||||
let notSyncToStateInputs: Record<string, any> | 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,
|
||||
})
|
||||
}
|
||||
else {
|
||||
notSyncToStateInputs = newConversationInputs
|
||||
setCurrInputs(notSyncToStateInputs)
|
||||
}
|
||||
|
||||
// update chat list of current conversation
|
||||
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) {
|
||||
fetchChatList(currConversationId).then((res: any) => {
|
||||
const { data } = res
|
||||
const newChatList: IChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs)
|
||||
|
||||
data.forEach((item: any) => {
|
||||
newChatList.push({
|
||||
id: `question-${item.id}`,
|
||||
content: item.query,
|
||||
isAnswer: false,
|
||||
})
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
feedback: item.feedback,
|
||||
isAnswer: true,
|
||||
})
|
||||
})
|
||||
setChatList(newChatList)
|
||||
})
|
||||
}
|
||||
|
||||
if (isNewConversation && isChatStarted)
|
||||
setChatList(generateNewChatListWithOpenstatement())
|
||||
|
||||
setControlFocus(Date.now())
|
||||
}
|
||||
useEffect(handleConversationSwitch, [currConversationId, inited])
|
||||
|
||||
const handleConversationIdChange = (id: string) => {
|
||||
if (id === '-1') {
|
||||
createNewChat()
|
||||
setConversationIdChangeBecauseOfNew(true)
|
||||
}
|
||||
else {
|
||||
setConversationIdChangeBecauseOfNew(false)
|
||||
}
|
||||
// trigger handleConversationSwitch
|
||||
setCurrConversationId(id, appId)
|
||||
hideSidebar()
|
||||
}
|
||||
|
||||
/*
|
||||
* chat info. chat is under conversation.
|
||||
*/
|
||||
const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
|
||||
const chatListDomRef = useRef<HTMLDivElement>(null)
|
||||
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 canEditInpus = !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,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// sometime introduction is not applied to state
|
||||
const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => {
|
||||
let caculatedIntroduction = introduction || conversationIntroduction || ''
|
||||
const caculatedPromptVariables = inputs || currInputs || null
|
||||
if (caculatedIntroduction && caculatedPromptVariables)
|
||||
caculatedIntroduction = replaceVarWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
|
||||
|
||||
const openstatement = {
|
||||
id: `${Date.now()}`,
|
||||
content: caculatedIntroduction,
|
||||
isAnswer: true,
|
||||
feedbackDisabled: true,
|
||||
isOpeningStatement: isPublicVersion,
|
||||
}
|
||||
if (caculatedIntroduction)
|
||||
return [openstatement]
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
if (!hasSetAppConfig) {
|
||||
setAppUnavailable(true)
|
||||
return
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const [appData, conversationData, appParams] = await Promise.all([fetchAppInfo(), fetchConversations(), fetchAppParams()])
|
||||
const { app_id: appId, site: siteInfo, prompt_config, plan }: any = appData
|
||||
setAppId(appId)
|
||||
setPlan(plan)
|
||||
const tempIsPublicVersion = !!prompt_config
|
||||
setIsPublicVersion(tempIsPublicVersion)
|
||||
const prompt_template = tempIsPublicVersion ? prompt_config.prompt_template : ''
|
||||
|
||||
// handle current conversation id
|
||||
const { data: conversations } = conversationData as { data: ConversationItem[] }
|
||||
const _conversationId = getConversationIdFromStorage(appId)
|
||||
const isNotNewConversation = conversations.some(item => item.id === _conversationId)
|
||||
|
||||
// fetch new conversation info
|
||||
const { variables: prompt_variables, introduction }: any = appParams
|
||||
|
||||
setLocaleOnClient(siteInfo.default_language, true)
|
||||
setNewConversationInfo({
|
||||
name: t('app.chat.newChatDefaultName'),
|
||||
introduction,
|
||||
})
|
||||
setSiteInfo(siteInfo as SiteInfo)
|
||||
setPromptConfig({
|
||||
prompt_template,
|
||||
prompt_variables,
|
||||
} as PromptConfig)
|
||||
|
||||
setConversationList(conversations as ConversationItem[])
|
||||
|
||||
if (isNotNewConversation)
|
||||
setCurrConversationId(_conversationId, appId, false)
|
||||
|
||||
setInited(true)
|
||||
}
|
||||
catch (e: any) {
|
||||
if (e.status === 404) {
|
||||
setAppUnavailable(true)
|
||||
}
|
||||
else {
|
||||
setIsUnknwonReason(true)
|
||||
setAppUnavailable(true)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
|
||||
const { notify } = Toast
|
||||
const logError = (message: string) => {
|
||||
notify({ type: 'error', message })
|
||||
}
|
||||
|
||||
const checkCanSend = () => {
|
||||
if (!currInputs || !promptConfig?.prompt_variables)
|
||||
return true
|
||||
|
||||
const inputLens = Object.values(currInputs).length
|
||||
const promptVariablesLens = promptConfig.prompt_variables.length
|
||||
|
||||
const emytyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v)
|
||||
if (emytyInput) {
|
||||
logError(t('app.errorMessage.valueOfVarRequired'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const [controlFocus, setControlFocus] = useState(0)
|
||||
const handleSend = async (message: string) => {
|
||||
if (isResponsing) {
|
||||
notify({ type: 'info', message: t('app.errorMessage.waitForResponse') })
|
||||
return
|
||||
}
|
||||
const data = {
|
||||
inputs: currInputs,
|
||||
query: message,
|
||||
conversation_id: isNewConversation ? null : currConversationId,
|
||||
}
|
||||
|
||||
// qustion
|
||||
const questionId = `question-${Date.now()}`
|
||||
const questionItem = {
|
||||
id: questionId,
|
||||
content: message,
|
||||
isAnswer: false,
|
||||
}
|
||||
|
||||
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
|
||||
const placeholderAnswerItem = {
|
||||
id: placeholderAnswerId,
|
||||
content: '...',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
const newList = [...getChatList(), questionItem, placeholderAnswerItem]
|
||||
setChatList(newList)
|
||||
|
||||
// answer
|
||||
const responseItem = {
|
||||
id: `${Date.now()}`,
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
let tempNewConversationId = ''
|
||||
setResponsingTrue()
|
||||
sendChatMessage(data, {
|
||||
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId }: any) => {
|
||||
responseItem.content = responseItem.content + message
|
||||
responseItem.id = messageId
|
||||
if (isFirstMessage && newConversationId)
|
||||
tempNewConversationId = newConversationId
|
||||
|
||||
// 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)
|
||||
},
|
||||
async onCompleted() {
|
||||
setResponsingFalse()
|
||||
if (!tempNewConversationId) {
|
||||
return
|
||||
}
|
||||
if (getConversationIdChangeBecauseOfNew()) {
|
||||
const { data: conversations }: any = await fetchConversations()
|
||||
setConversationList(conversations as ConversationItem[])
|
||||
}
|
||||
setConversationIdChangeBecauseOfNew(false)
|
||||
resetNewConversationInputs()
|
||||
setChatNotStarted()
|
||||
setCurrConversationId(tempNewConversationId, appId, true)
|
||||
},
|
||||
onError() {
|
||||
setResponsingFalse()
|
||||
// role back placeholder answer
|
||||
setChatList(produce(getChatList(), (draft) => {
|
||||
draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
|
||||
}))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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') })
|
||||
}
|
||||
|
||||
const renderSidebar = () => {
|
||||
if (!appId || !siteInfo || !promptConfig)
|
||||
return null
|
||||
return (
|
||||
<Sidebar
|
||||
list={conversationList}
|
||||
onCurrentIdChange={handleConversationIdChange}
|
||||
currentId={currConversationId}
|
||||
copyRight={siteInfo.copyright || siteInfo.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (appUnavailable)
|
||||
return <AppUnavailable isUnknwonReason={isUnknwonReason} errMessage={!hasSetAppConfig && 'Please set APP_ID and API_KEY in config/index.tsx'} />
|
||||
|
||||
if (!appId || !siteInfo || !promptConfig)
|
||||
return <Loading type='app' />
|
||||
|
||||
return (
|
||||
<div className='bg-gray-100'>
|
||||
<Header
|
||||
title={siteInfo.title}
|
||||
isMobile={isMobile}
|
||||
onShowSideBar={showSidebar}
|
||||
onCreateNewChat={() => handleConversationIdChange('-1')}
|
||||
/>
|
||||
<div className="flex rounded-t-2xl bg-white overflow-hidden">
|
||||
{/* sidebar */}
|
||||
{!isMobile && renderSidebar()}
|
||||
{isMobile && isShowSidebar && (
|
||||
<div className='fixed inset-0 z-50'
|
||||
style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
|
||||
onClick={hideSidebar}
|
||||
>
|
||||
<div className='inline-block' onClick={e => e.stopPropagation()}>
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* main */}
|
||||
<div className='flex-grow flex flex-col h-[calc(100vh_-_3rem)] overflow-y-auto'>
|
||||
<ConfigSence
|
||||
conversationName={conversationName}
|
||||
hasSetInputs={hasSetInputs}
|
||||
isPublicVersion={isPublicVersion}
|
||||
siteInfo={siteInfo}
|
||||
promptConfig={promptConfig}
|
||||
onStartChat={handleStartChat}
|
||||
canEidtInpus={canEditInpus}
|
||||
savedInputs={currInputs as Record<string, any>}
|
||||
onInputsChange={setCurrInputs}
|
||||
plan={plan}
|
||||
></ConfigSence>
|
||||
|
||||
{
|
||||
hasSetInputs && (
|
||||
<div className='relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full pb-[66px] mx-auto mb-3.5 overflow-hidden'>
|
||||
<div className='h-full overflow-y-auto' ref={chatListDomRef}>
|
||||
<Chat
|
||||
chatList={chatList}
|
||||
onSend={handleSend}
|
||||
onFeedback={handleFeedback}
|
||||
isResponsing={isResponsing}
|
||||
checkCanSend={checkCanSend}
|
||||
controlFocus={controlFocus}
|
||||
/>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Main)
|
||||
3
app/components/sidebar/card.module.css
Normal file
@ -0,0 +1,3 @@
|
||||
.card:hover {
|
||||
background: linear-gradient(0deg, rgba(235, 245, 255, 0.4), rgba(235, 245, 255, 0.4)), #FFFFFF;
|
||||
}
|
||||
19
app/components/sidebar/card.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
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 (
|
||||
<div className={`${s.card} box-border w-full flex flex-col items-start px-4 py-3 rounded-lg border-solid border border-gray-200 cursor-pointer hover:border-primary-300`}>
|
||||
<div className='text-gray-400 font-medium text-xs mb-2'>{text ?? t('app.chat.powerBy')}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Card
|
||||
87
app/components/sidebar/index.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
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 '@/app/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<ISidebarProps> = ({
|
||||
copyRight,
|
||||
currentId,
|
||||
onCurrentIdChange,
|
||||
list,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 flex flex-col overflow-y-auto bg-white pc:w-[244px] tablet:w-[192px] mobile:w-[240px] border-r border-gray-200 tablet:h-[calc(100vh_-_3rem)] mobile:h-screen"
|
||||
>
|
||||
{list.length < MAX_CONVERSATION_LENTH && (
|
||||
<div className="flex flex-shrink-0 p-4 !pb-0">
|
||||
<Button
|
||||
onClick={() => { onCurrentIdChange('-1') }}
|
||||
className="group block w-full flex-shrink-0 !justify-start !h-9 text-primary-600 items-center text-sm">
|
||||
<PencilSquareIcon className="mr-2 h-4 w-4" /> {t('app.chat.newChat')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="mt-4 flex-1 space-y-1 bg-white p-4 !pt-0">
|
||||
{list.map((item) => {
|
||||
const isCurrent = item.id === currentId
|
||||
const ItemIcon
|
||||
= isCurrent ? ChatBubbleOvalLeftEllipsisSolidIcon : ChatBubbleOvalLeftEllipsisIcon
|
||||
return (
|
||||
<div
|
||||
onClick={() => onCurrentIdChange(item.id)}
|
||||
key={item.id}
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-700',
|
||||
'group flex items-center rounded-md px-2 py-2 text-sm font-medium cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<ItemIcon
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-400 group-hover:text-gray-500',
|
||||
'mr-3 h-5 w-5 flex-shrink-0',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{/* <a className="flex flex-shrink-0 p-4" href="https://langgenius.ai/" target="_blank">
|
||||
<Card><div className="flex flex-row items-center"><ChatBubbleOvalLeftEllipsisSolidIcon className="text-primary-600 h-6 w-6 mr-2" /><span>LangGenius</span></div></Card>
|
||||
</a> */}
|
||||
<div className="flex flex-shrink-0 pr-4 pb-4 pl-4">
|
||||
<div className="text-gray-400 font-normal text-xs">© {copyRight} {(new Date()).getFullYear()}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Sidebar)
|
||||
79
app/components/value-panel/index.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
'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 '@/app/components//welcome/massive-component'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type ITemplateVarPanelProps = {
|
||||
className?: string
|
||||
header: ReactNode
|
||||
children?: ReactNode | null
|
||||
isFold: boolean
|
||||
}
|
||||
|
||||
const TemplateVarPanel: FC<ITemplateVarPanelProps> = ({
|
||||
className,
|
||||
header,
|
||||
children,
|
||||
isFold,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(isFold ? 'border border-indigo-100' : s.boxShodow, className, 'rounded-xl ')}>
|
||||
{/* header */}
|
||||
<div
|
||||
className={cn(isFold && 'rounded-b-xl', 'rounded-t-xl px-6 py-4 bg-indigo-25 text-xs')}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
{/* body */}
|
||||
{!isFold && children && (
|
||||
<div className='rounded-b-xl p-6'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PanelTitle: FC<{ title: string; className?: string }> = ({
|
||||
title,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'flex items-center space-x-1 text-indigo-600')}>
|
||||
<StarIcon />
|
||||
<span className='text-xs'>{title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VarOpBtnGroup: FC<{ className?: string; onConfirm: () => void; onCancel: () => void }> = ({
|
||||
className,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'flex mt-3 space-x-2 mobile:ml-0 tablet:ml-[128px] text-sm')}>
|
||||
<Button
|
||||
className='text-sm'
|
||||
type='primary'
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
<Button
|
||||
className='text-sm'
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TemplateVarPanel)
|
||||
3
app/components/value-panel/style.module.css
Normal file
@ -0,0 +1,3 @@
|
||||
.boxShodow {
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
}
|
||||
339
app/components/welcome/index.tsx
Normal file
@ -0,0 +1,339 @@
|
||||
'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 s from './style.module.css'
|
||||
import { AppInfo, ChatBtn, EditBtn, FootLogo, PromptTemplate } from './massive-component'
|
||||
import type { PromptConfig, SiteInfo } from '@/types/app'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Select from '@/app/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: SiteInfo
|
||||
promptConfig: PromptConfig
|
||||
onStartChat: (inputs: Record<string, any>) => void
|
||||
canEidtInpus: boolean
|
||||
savedInputs: Record<string, any>
|
||||
onInputsChange: (inputs: Record<string, any>) => void
|
||||
plan: string
|
||||
}
|
||||
|
||||
const Welcome: FC<IWelcomeProps> = ({
|
||||
conversationName,
|
||||
hasSetInputs,
|
||||
isPublicVersion,
|
||||
siteInfo,
|
||||
plan,
|
||||
promptConfig,
|
||||
onStartChat,
|
||||
canEidtInpus,
|
||||
savedInputs,
|
||||
onInputsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const hasVar = promptConfig.prompt_variables.length > 0
|
||||
const [isFold, setIsFold] = useState<boolean>(true)
|
||||
const [inputs, setInputs] = useState<Record<string, any>>((() => {
|
||||
if (hasSetInputs)
|
||||
return savedInputs
|
||||
|
||||
const res: Record<string, any> = {}
|
||||
if (promptConfig) {
|
||||
promptConfig.prompt_variables.forEach((item) => {
|
||||
res[item.key] = ''
|
||||
})
|
||||
}
|
||||
return res
|
||||
})())
|
||||
useEffect(() => {
|
||||
if (!savedInputs) {
|
||||
const res: Record<string, any> = {}
|
||||
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 `<span class='text-gray-800 font-bold'>${inputs?.[p1] ? inputs?.[p1] : match}</span>`
|
||||
})
|
||||
return res
|
||||
})()
|
||||
|
||||
const { notify } = Toast
|
||||
const logError = (message: string) => {
|
||||
notify({ type: 'error', message, duration: 3000 })
|
||||
}
|
||||
|
||||
const renderHeader = () => {
|
||||
return (
|
||||
<div className='absolute top-0 left-0 right-0 flex items-center justify-between border-b border-gray-100 mobile:h-12 tablet:h-16 px-8 bg-white'>
|
||||
<div className='text-gray-900'>{conversationName}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderInputs = () => {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{promptConfig.prompt_variables.map(item => (
|
||||
<div className='tablet:flex tablet:!h-9 mobile:space-y-2 tablet:space-y-0 mobile:text-xs tablet:text-sm' key={item.key}>
|
||||
<label className={`flex-shrink-0 flex items-center mobile:text-gray-700 tablet:text-gray-900 mobile:font-medium pc:font-normal ${s.formLabel}`}>{item.name}</label>
|
||||
{item.type === 'select'
|
||||
? (
|
||||
<Select
|
||||
className='w-full'
|
||||
defaultValue={inputs?.[item.key]}
|
||||
onSelect={(i) => { setInputs({ ...inputs, [item.key]: i.value }) }}
|
||||
items={(item.options || []).map(i => ({ name: i, value: i }))}
|
||||
allowSearch={false}
|
||||
bgClassName='bg-gray-50'
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
placeholder={item.name}
|
||||
value={inputs?.[item.key] || ''}
|
||||
onChange={(e) => { 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const canChat = () => {
|
||||
const inputLens = Object.values(inputs).length
|
||||
const promptVariablesLens = promptConfig.prompt_variables.length
|
||||
const emytyInput = inputLens < promptVariablesLens || Object.values(inputs).filter(v => v === '').length > 0
|
||||
if (emytyInput) {
|
||||
logError(t('app.errorMessage.valueOfVarRequired'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleChat = () => {
|
||||
if (!canChat())
|
||||
return
|
||||
|
||||
onStartChat(inputs)
|
||||
}
|
||||
|
||||
const renderNoVarPanel = () => {
|
||||
if (isPublicVersion) {
|
||||
return (
|
||||
<div>
|
||||
<AppInfo siteInfo={siteInfo} />
|
||||
<TemplateVarPanel
|
||||
isFold={false}
|
||||
header={
|
||||
<>
|
||||
<PanelTitle
|
||||
title={t('app.chat.publicPromptConfigTitle')}
|
||||
className='mb-1'
|
||||
/>
|
||||
<PromptTemplate html={highLightPromoptTemplate} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ChatBtn onClick={handleChat} />
|
||||
</TemplateVarPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// private version
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={false}
|
||||
header={
|
||||
<AppInfo siteInfo={siteInfo} />
|
||||
}
|
||||
>
|
||||
<ChatBtn onClick={handleChat} />
|
||||
</TemplateVarPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const renderVarPanel = () => {
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={false}
|
||||
header={
|
||||
<AppInfo siteInfo={siteInfo} />
|
||||
}
|
||||
>
|
||||
{renderInputs()}
|
||||
<ChatBtn
|
||||
className='mt-3 mobile:ml-0 tablet:ml-[128px]'
|
||||
onClick={handleChat}
|
||||
/>
|
||||
</TemplateVarPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const renderVarOpBtnGroup = () => {
|
||||
return (
|
||||
<VarOpBtnGroup
|
||||
onConfirm={() => {
|
||||
if (!canChat())
|
||||
return
|
||||
|
||||
onInputsChange(inputs)
|
||||
setIsFold(true)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setInputs(savedInputs)
|
||||
setIsFold(true)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderHasSetInputsPublic = () => {
|
||||
if (!canEidtInpus) {
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={false}
|
||||
header={
|
||||
<>
|
||||
<PanelTitle
|
||||
title={t('app.chat.publicPromptConfigTitle')}
|
||||
className='mb-1'
|
||||
/>
|
||||
<PromptTemplate html={highLightPromoptTemplate} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={isFold}
|
||||
header={
|
||||
<>
|
||||
<PanelTitle
|
||||
title={t('app.chat.publicPromptConfigTitle')}
|
||||
className='mb-1'
|
||||
/>
|
||||
<PromptTemplate html={highLightPromoptTemplate} />
|
||||
{isFold && (
|
||||
<div className='flex items-center justify-between mt-3 border-t border-indigo-100 pt-4 text-xs text-indigo-600'>
|
||||
<span className='text-gray-700'>{t('app.chat.configStatusDes')}</span>
|
||||
<EditBtn onClick={() => setIsFold(false)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{renderInputs()}
|
||||
{renderVarOpBtnGroup()}
|
||||
</TemplateVarPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const renderHasSetInputsPrivate = () => {
|
||||
if (!canEidtInpus || !hasVar)
|
||||
return null
|
||||
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={isFold}
|
||||
header={
|
||||
<div className='flex items-center justify-between text-indigo-600'>
|
||||
<PanelTitle
|
||||
title={!isFold ? t('app.chat.privatePromptConfigTitle') : t('app.chat.configStatusDes')}
|
||||
/>
|
||||
{isFold && (
|
||||
<EditBtn onClick={() => setIsFold(false)} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{renderInputs()}
|
||||
{renderVarOpBtnGroup()}
|
||||
</TemplateVarPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const renderHasSetInputs = () => {
|
||||
if (!isPublicVersion && !canEidtInpus || !hasVar)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='pt-[88px] mb-5'
|
||||
>
|
||||
{isPublicVersion ? renderHasSetInputsPublic() : renderHasSetInputsPrivate()}
|
||||
</div>)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative mobile:min-h-[48px] tablet:min-h-[64px]'>
|
||||
{hasSetInputs && renderHeader()}
|
||||
<div className='mx-auto pc:w-[794px] max-w-full mobile:w-full px-3.5'>
|
||||
{/* Has't set inputs */}
|
||||
{
|
||||
!hasSetInputs && (
|
||||
<div className='mobile:pt-[72px] tablet:pt-[128px] pc:pt-[200px]'>
|
||||
{hasVar
|
||||
? (
|
||||
renderVarPanel()
|
||||
)
|
||||
: (
|
||||
renderNoVarPanel()
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Has set inputs */}
|
||||
{hasSetInputs && renderHasSetInputs()}
|
||||
|
||||
{/* foot */}
|
||||
{!hasSetInputs && (
|
||||
<div className='mt-4 flex justify-between items-center h-8 text-xs text-gray-400'>
|
||||
|
||||
{siteInfo.privacy_policy
|
||||
? <div>{t('app.chat.privacyPolicyLeft')}
|
||||
<a
|
||||
className='text-gray-500'
|
||||
href={siteInfo.privacy_policy}
|
||||
target='_blank'>{t('app.chat.privacyPolicyMiddle')}</a>
|
||||
{t('app.chat.privacyPolicyRight')}
|
||||
</div>
|
||||
: <div>
|
||||
</div>}
|
||||
{plan === 'basic' && <a className='flex items-center pr-3 space-x-3' href="https://langgenius.ai/" target="_blank">
|
||||
<span className='uppercase'>{t('app.chat.powerBy')}</span>
|
||||
<FootLogo />
|
||||
</a>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Welcome)
|
||||
90
app/components/welcome/massive-component.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PencilIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import s from './style.module.css'
|
||||
import type { SiteInfo } from '@/types/app'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export const AppInfo: FC<{ siteInfo: SiteInfo }> = ({ siteInfo }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div>
|
||||
<div className='flex items-center py-2 text-xl font-medium text-gray-700 rounded-md'>👏 {t('app.common.welcome')} {siteInfo.title}</div>
|
||||
<p className='text-sm text-gray-500'>{siteInfo.description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PromptTemplate: FC<{ html: string }> = ({ html }) => {
|
||||
return (
|
||||
<div
|
||||
className={' box-border text-sm text-gray-700'}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StarIcon = () => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.75 1C2.75 0.723858 2.52614 0.5 2.25 0.5C1.97386 0.5 1.75 0.723858 1.75 1V1.75H1C0.723858 1.75 0.5 1.97386 0.5 2.25C0.5 2.52614 0.723858 2.75 1 2.75H1.75V3.5C1.75 3.77614 1.97386 4 2.25 4C2.52614 4 2.75 3.77614 2.75 3.5V2.75H3.5C3.77614 2.75 4 2.52614 4 2.25C4 1.97386 3.77614 1.75 3.5 1.75H2.75V1Z" fill="#444CE7" />
|
||||
<path d="M2.75 8.5C2.75 8.22386 2.52614 8 2.25 8C1.97386 8 1.75 8.22386 1.75 8.5V9.25H1C0.723858 9.25 0.5 9.47386 0.5 9.75C0.5 10.0261 0.723858 10.25 1 10.25H1.75V11C1.75 11.2761 1.97386 11.5 2.25 11.5C2.52614 11.5 2.75 11.2761 2.75 11V10.25H3.5C3.77614 10.25 4 10.0261 4 9.75C4 9.47386 3.77614 9.25 3.5 9.25H2.75V8.5Z" fill="#444CE7" />
|
||||
<path d="M6.96667 1.32051C6.8924 1.12741 6.70689 1 6.5 1C6.29311 1 6.10759 1.12741 6.03333 1.32051L5.16624 3.57494C5.01604 3.96546 4.96884 4.078 4.90428 4.1688C4.8395 4.2599 4.7599 4.3395 4.6688 4.40428C4.578 4.46884 4.46546 4.51604 4.07494 4.66624L1.82051 5.53333C1.62741 5.60759 1.5 5.79311 1.5 6C1.5 6.20689 1.62741 6.39241 1.82051 6.46667L4.07494 7.33376C4.46546 7.48396 4.578 7.53116 4.6688 7.59572C4.7599 7.6605 4.8395 7.7401 4.90428 7.8312C4.96884 7.922 5.01604 8.03454 5.16624 8.42506L6.03333 10.6795C6.1076 10.8726 6.29311 11 6.5 11C6.70689 11 6.89241 10.8726 6.96667 10.6795L7.83376 8.42506C7.98396 8.03454 8.03116 7.922 8.09572 7.8312C8.1605 7.7401 8.2401 7.6605 8.3312 7.59572C8.422 7.53116 8.53454 7.48396 8.92506 7.33376L11.1795 6.46667C11.3726 6.39241 11.5 6.20689 11.5 6C11.5 5.79311 11.3726 5.60759 11.1795 5.53333L8.92506 4.66624C8.53454 4.51604 8.422 4.46884 8.3312 4.40428C8.2401 4.3395 8.1605 4.2599 8.09572 4.1688C8.03116 4.078 7.98396 3.96546 7.83376 3.57494L6.96667 1.32051Z" fill="#444CE7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ChatBtn: FC<{ onClick: () => void; className?: string }> = ({
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
type='primary'
|
||||
className={cn(className, `space-x-2 flex items-center ${s.customBtn}`)}
|
||||
onClick={onClick}>
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M18 10.5C18 14.366 14.418 17.5 10 17.5C8.58005 17.506 7.17955 17.1698 5.917 16.52L2 17.5L3.338 14.377C2.493 13.267 2 11.934 2 10.5C2 6.634 5.582 3.5 10 3.5C14.418 3.5 18 6.634 18 10.5ZM7 9.5H5V11.5H7V9.5ZM15 9.5H13V11.5H15V9.5ZM9 9.5H11V11.5H9V9.5Z" fill="white" />
|
||||
</svg>
|
||||
{t('app.chat.startChat')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export const EditBtn = ({ className, onClick }: { className?: string; onClick: () => void }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('px-2 flex space-x-1 items-center rounded-md cursor-pointer', className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<PencilIcon className='w-3 h-3' />
|
||||
<span>{t('common.operation.edit')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FootLogo = () => (
|
||||
<svg width="68" height="17" viewBox="0 0 68 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.0001 8.49961C18.0001 13.1388 14.105 16.8996 9.3001 16.8996C8.14286 16.8996 7.03839 16.6815 6.0284 16.2854C5.8351 16.2096 5.73845 16.1717 5.66033 16.1548C5.5839 16.1383 5.52735 16.1322 5.44903 16.1322C5.36896 16.1322 5.28175 16.1462 5.10733 16.1743L1.66803 16.7278C1.30787 16.7857 1.12779 16.8147 0.997576 16.7608C0.883604 16.7136 0.792783 16.6259 0.743899 16.5158C0.688047 16.3901 0.71806 16.2162 0.778087 15.8685L1.3513 12.5478C1.38037 12.3794 1.39491 12.2952 1.3949 12.2179C1.39489 12.1423 1.38862 12.0877 1.37149 12.0139C1.35398 11.9384 1.31473 11.8451 1.23622 11.6585C0.826043 10.6833 0.600098 9.61694 0.600098 8.49961C0.600098 3.86042 4.49522 0.0996094 9.3001 0.0996094C14.105 0.0996094 18.0001 3.86042 18.0001 8.49961Z" fill="#155EEF" />
|
||||
<path opacity="0.72" fillRule="evenodd" clipRule="evenodd" d="M15.5011 14.3919L8.75536 14.3919C8.40761 14.3919 8.08484 14.2113 7.90299 13.9148L4.86874 8.96915C4.82047 8.89048 4.79492 8.79998 4.79492 8.70768V7.1416L8.50747 13.1368C8.6442 13.3576 8.88542 13.4919 9.14511 13.4919H16.298C16.0543 13.8099 15.7879 14.1106 15.5011 14.3919Z" fill="white" />
|
||||
<path d="M17.3614 11.6657C17.7734 10.6887 18.0003 9.61998 18.0003 8.50008C18.0003 8.32551 17.9948 8.15219 17.984 7.98026H11.9909C11.8167 7.98026 11.6551 7.88964 11.5643 7.74105L9.1534 3.79735C9.12615 3.75277 9.07767 3.72559 9.02542 3.72559H5.24077C5.04552 3.72559 4.92563 3.93939 5.02747 4.10598L9.35639 11.1872C9.53806 11.4844 9.86128 11.6657 10.2096 11.6657L17.3614 11.6657Z" fill="white" />
|
||||
<rect x="22.8003" y="13.4912" width="12.5919" height="0.9" fill="#155EEF" />
|
||||
<rect x="41.4927" y="13.4912" width="25.6982" height="0.9" fill="#155EEF" />
|
||||
<path d="M24.3123 10.9124H25.9647V12.1868H22.8003V5.19922H24.3123V10.9124Z" fill="#155EEF" />
|
||||
<path d="M29.3059 6.91642H30.5263V12.1868H29.3059V11.798C29.0827 12.1292 28.7119 12.2948 28.1935 12.2948H27.9667C27.0739 12.2948 26.6275 11.8196 26.6275 10.8692V8.23402C26.6275 7.28362 27.0739 6.80842 27.9667 6.80842H28.1935C28.7119 6.80842 29.0827 6.97402 29.3059 7.30522V6.91642ZM29.0791 11.042V8.06122C29.0791 8.00362 29.0575 7.95322 29.0143 7.91002C28.9783 7.86682 28.9315 7.84522 28.8739 7.84522H28.2799C28.2223 7.84522 28.1719 7.86682 28.1287 7.91002C28.0927 7.95322 28.0747 8.00362 28.0747 8.06122V11.042C28.0747 11.0996 28.0927 11.15 28.1287 11.1932C28.1719 11.2364 28.2223 11.258 28.2799 11.258H28.8739C28.9315 11.258 28.9783 11.2364 29.0143 11.1932C29.0575 11.15 29.0791 11.0996 29.0791 11.042Z" fill="#155EEF" />
|
||||
<path d="M32.9579 12.1868H31.5107V6.91642H32.8283V7.18642C33.0515 6.93442 33.3899 6.80842 33.8435 6.80842H34.0703C34.9631 6.80842 35.4095 7.28362 35.4095 8.23402V12.1868H33.9623V8.06122C33.9623 8.00362 33.9407 7.95322 33.8975 7.91002C33.8615 7.86682 33.8147 7.84522 33.7571 7.84522H33.1631C33.1055 7.84522 33.0551 7.86682 33.0119 7.91002C32.9759 7.95322 32.9579 8.00362 32.9579 8.06122V12.1868Z" fill="#155EEF" />
|
||||
<path d="M40.2927 6.91642V12.5864C40.2927 13.5368 39.8463 14.012 38.9535 14.012H37.8735C36.9807 14.012 36.5343 13.5368 36.5343 12.5864V12.4136H37.8411V12.7592C37.8411 12.8168 37.8591 12.8672 37.8951 12.9104C37.9383 12.9536 37.9887 12.9752 38.0463 12.9752H38.6403C38.6979 12.9752 38.7447 12.9536 38.7807 12.9104C38.8239 12.8672 38.8455 12.8168 38.8455 12.7592V11.6792C38.6223 11.852 38.3271 11.9384 37.9599 11.9384H37.7331C36.8403 11.9384 36.3939 11.4632 36.3939 10.5128V8.23402C36.3939 7.28362 36.8403 6.80842 37.7331 6.80842H37.9599C38.4783 6.80842 38.8491 6.97402 39.0723 7.30522V6.91642H40.2927ZM38.8455 10.6856V8.06122C38.8455 8.00362 38.8239 7.95322 38.7807 7.91002C38.7447 7.86682 38.6979 7.84522 38.6403 7.84522H38.0463C37.9887 7.84522 37.9383 7.86682 37.8951 7.91002C37.8591 7.95322 37.8411 8.00362 37.8411 8.06122V10.6856C37.8411 10.7432 37.8591 10.7936 37.8951 10.8368C37.9383 10.88 37.9887 10.9016 38.0463 10.9016H38.6403C38.6979 10.9016 38.7447 10.88 38.7807 10.8368C38.8239 10.7936 38.8455 10.7432 38.8455 10.6856Z" fill="#155EEF" />
|
||||
<path d="M43.5339 9.71362V8.60122H45.5319V11.1716C45.5319 12.122 45.0855 12.5972 44.1927 12.5972H42.8319C41.9391 12.5972 41.4927 12.122 41.4927 11.1716V6.81922C41.4927 5.86882 41.9391 5.39362 42.8319 5.39362H44.1927C45.0855 5.39362 45.5319 5.86882 45.5319 6.81922V7.47802H44.0955V6.63562C44.0955 6.57802 44.0739 6.52762 44.0307 6.48442C43.9947 6.44122 43.9479 6.41962 43.8903 6.41962H43.2099C43.1523 6.41962 43.1019 6.44122 43.0587 6.48442C43.0227 6.52762 43.0047 6.57802 43.0047 6.63562V11.3444C43.0047 11.402 43.0227 11.4524 43.0587 11.4956C43.1019 11.5388 43.1523 11.5604 43.2099 11.5604H43.8903C43.9479 11.5604 43.9947 11.5388 44.0307 11.4956C44.0739 11.4524 44.0955 11.402 44.0955 11.3444V9.71362H43.5339Z" fill="#101828" />
|
||||
<path d="M48.8709 7.11082C49.7637 7.11082 50.2101 7.58602 50.2101 8.53642V10.2428H47.7585V11.3444C47.7585 11.402 47.7765 11.4524 47.8125 11.4956C47.8557 11.5388 47.9061 11.5604 47.9637 11.5604H48.5577C48.6153 11.5604 48.6621 11.5388 48.6981 11.4956C48.7413 11.4524 48.7629 11.402 48.7629 11.3444V10.7612H50.2101V11.1716C50.2101 12.122 49.7637 12.5972 48.8709 12.5972H47.6505C46.7577 12.5972 46.3113 12.122 46.3113 11.1716V8.53642C46.3113 7.58602 46.7577 7.11082 47.6505 7.11082H48.8709ZM47.7585 9.29242H48.7629V8.32042C48.7629 8.26282 48.7413 8.21242 48.6981 8.16922C48.6621 8.12602 48.6153 8.10442 48.5577 8.10442H47.9637C47.9061 8.10442 47.8557 8.12602 47.8125 8.16922C47.7765 8.21242 47.7585 8.26282 47.7585 8.32042V9.29242Z" fill="#101828" />
|
||||
<path d="M52.4519 12.4892H51.0047V7.21882H52.3223V7.48882C52.5455 7.23682 52.8839 7.11082 53.3375 7.11082H53.5643C54.4571 7.11082 54.9035 7.58602 54.9035 8.53642V12.4892H53.4563V8.36362C53.4563 8.30602 53.4347 8.25562 53.3915 8.21242C53.3555 8.16922 53.3087 8.14762 53.2511 8.14762H52.6571C52.5995 8.14762 52.5491 8.16922 52.5059 8.21242C52.4699 8.25562 52.4519 8.30602 52.4519 8.36362V12.4892Z" fill="#101828" />
|
||||
<path d="M57.3567 6.56002C57.2055 6.71122 57.0219 6.78682 56.8059 6.78682C56.5899 6.78682 56.4027 6.71122 56.2443 6.56002C56.0859 6.40162 56.0067 6.21442 56.0067 5.99842C56.0067 5.77522 56.0859 5.58802 56.2443 5.43682C56.4027 5.27842 56.5899 5.19922 56.8059 5.19922C57.0219 5.19922 57.2055 5.27842 57.3567 5.43682C57.5151 5.58802 57.5943 5.77522 57.5943 5.99842C57.5943 6.22162 57.5151 6.40882 57.3567 6.56002ZM56.0823 12.4892V7.21882H57.5295V12.4892H56.0823Z" fill="#101828" />
|
||||
<path d="M60.9973 7.21882H62.4445V12.4892H61.1269V12.2192C60.9037 12.4712 60.5653 12.5972 60.1117 12.5972H59.8849C58.9921 12.5972 58.5457 12.122 58.5457 11.1716V7.21882H59.9929V11.3444C59.9929 11.402 60.0109 11.4524 60.0469 11.4956C60.0901 11.5388 60.1405 11.5604 60.1981 11.5604H60.7921C60.8497 11.5604 60.8965 11.5388 60.9325 11.4956C60.9757 11.4524 60.9973 11.402 60.9973 11.3444V7.21882Z" fill="#101828" />
|
||||
<path d="M65.9129 9.18442C66.8057 9.18442 67.2521 9.65962 67.2521 10.61V11.1716C67.2521 12.122 66.8057 12.5972 65.9129 12.5972H64.7141C63.8213 12.5972 63.3749 12.122 63.3749 11.1716V10.8368H64.7141V11.312C64.7141 11.3696 64.7321 11.42 64.7681 11.4632C64.8113 11.5064 64.8617 11.528 64.9193 11.528H65.8049C65.8625 11.528 65.9093 11.5064 65.9453 11.4632C65.9885 11.42 66.0101 11.3696 66.0101 11.312V10.664C66.0101 10.6064 65.9885 10.556 65.9453 10.5128C65.9093 10.4696 65.8625 10.448 65.8049 10.448H64.6925C63.7997 10.448 63.3533 9.97282 63.3533 9.02242V8.53642C63.3533 7.58602 63.7997 7.11082 64.6925 7.11082H65.8265C66.7193 7.11082 67.1657 7.58602 67.1657 8.53642V8.73082H65.8265V8.39602C65.8265 8.33842 65.8049 8.28802 65.7617 8.24482C65.7257 8.20162 65.6789 8.18002 65.6213 8.18002H64.8005C64.7429 8.18002 64.6925 8.20162 64.6493 8.24482C64.6133 8.28802 64.5953 8.33842 64.5953 8.39602V8.96842C64.5953 9.02602 64.6133 9.07642 64.6493 9.11962C64.6925 9.16282 64.7429 9.18442 64.8005 9.18442H65.9129Z" fill="#101828" />
|
||||
</svg>
|
||||
)
|
||||
22
app/components/welcome/style.module.css
Normal file
@ -0,0 +1,22 @@
|
||||
.boxShodow {
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
}
|
||||
|
||||
.bgGrayColor {
|
||||
background-color: #F9FAFB;
|
||||
}
|
||||
|
||||
.headerBg {
|
||||
height: 3.5rem;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
width: 120px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.customBtn {
|
||||
width: 136px;
|
||||
}
|
||||
25
app/layout.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { getLocaleOnServer } from '@/i18n/server'
|
||||
|
||||
import './styles/globals.css'
|
||||
import './styles/markdown.scss'
|
||||
|
||||
const LocaleLayout = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const locale = getLocaleOnServer()
|
||||
return (
|
||||
<html lang={locale ?? 'en'} className="h-full">
|
||||
<body className="h-full">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="w-screen h-screen min-w-[300px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export default LocaleLayout
|
||||
15
app/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import type { IMainProps } from '@/app/components'
|
||||
import Main from '@/app/components'
|
||||
|
||||
const App: FC<IMainProps> = ({
|
||||
params,
|
||||
}: any) => {
|
||||
return (
|
||||
<Main params={params} />
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(App)
|
||||
128
app/styles/globals.css
Normal file
@ -0,0 +1,128 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--max-width: 1100px;
|
||||
--border-radius: 12px;
|
||||
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
|
||||
"Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
|
||||
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
|
||||
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
|
||||
--primary-glow: conic-gradient(from 180deg at 50% 50%,
|
||||
#16abff33 0deg,
|
||||
#0885ff33 55deg,
|
||||
#54d6ff33 120deg,
|
||||
#0071ff33 160deg,
|
||||
transparent 360deg);
|
||||
--secondary-glow: radial-gradient(rgba(255, 255, 255, 1),
|
||||
rgba(255, 255, 255, 0));
|
||||
|
||||
--tile-start-rgb: 239, 245, 249;
|
||||
--tile-end-rgb: 228, 232, 233;
|
||||
--tile-border: conic-gradient(#00000080,
|
||||
#00000040,
|
||||
#00000030,
|
||||
#00000020,
|
||||
#00000010,
|
||||
#00000010,
|
||||
#00000080);
|
||||
|
||||
--callout-rgb: 238, 240, 241;
|
||||
--callout-border-rgb: 172, 175, 176;
|
||||
--card-rgb: 180, 185, 188;
|
||||
--card-border-rgb: 131, 134, 135;
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
|
||||
--primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
|
||||
--secondary-glow: linear-gradient(to bottom right,
|
||||
rgba(1, 65, 255, 0),
|
||||
rgba(1, 65, 255, 0),
|
||||
rgba(1, 65, 255, 0.3));
|
||||
|
||||
--tile-start-rgb: 2, 13, 46;
|
||||
--tile-end-rgb: 2, 5, 19;
|
||||
--tile-border: conic-gradient(#ffffff80,
|
||||
#ffffff40,
|
||||
#ffffff30,
|
||||
#ffffff20,
|
||||
#ffffff10,
|
||||
#ffffff10,
|
||||
#ffffff80);
|
||||
|
||||
--callout-rgb: 20, 20, 20;
|
||||
--callout-border-rgb: 108, 108, 108;
|
||||
--card-rgb: 100, 100, 100;
|
||||
--card-border-rgb: 200, 200, 200;
|
||||
}
|
||||
} */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
/* background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb)); */
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
} */
|
||||
|
||||
/* CSS Utils */
|
||||
.h1 {
|
||||
padding-bottom: 1.5rem;
|
||||
line-height: 1.5;
|
||||
font-size: 1.125rem;
|
||||
color: #111928;
|
||||
}
|
||||
|
||||
.h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #111928;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply text-blue-600 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(91.58deg, #2250F2 -29.55%, #0EBCF3 75.22%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
1041
app/styles/markdown.scss
Normal file
25
config/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export const APP_ID = ''
|
||||
export const API_KEY = ''
|
||||
export const APP_INFO = {
|
||||
"app_id": APP_ID,
|
||||
"site": {
|
||||
"title": "Chat APP",
|
||||
"description": null,
|
||||
"copyright": null,
|
||||
"privacy_policy": null,
|
||||
"default_language": "zh-Hans",
|
||||
"prompt_public": true
|
||||
},
|
||||
"prompt_config": {
|
||||
"introduction": "Chat APP",
|
||||
"prompt_template": "{{a}}", "prompt_variables": [{ "key": "a", "name": "a", "type": "string", "max_length": 48 }], "completion_params": { "max_token": 256, "temperature": 1, "top_p": 1, "presence_penalty": 0, "frequency_penalty": 0 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const API_PREFIX = '/api';
|
||||
|
||||
export const LOCALE_COOKIE_NAME = 'locale'
|
||||
|
||||
export const DEFAULT_VALUE_MAX_LEN = 48
|
||||
27
hooks/use-breakpoints.ts
Normal file
@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
|
||||
export enum MediaType {
|
||||
mobile = 'mobile',
|
||||
tablet = 'tablet',
|
||||
pc = 'pc',
|
||||
}
|
||||
|
||||
const useBreakpoints = () => {
|
||||
const [width, setWidth] = React.useState(globalThis.innerWidth);
|
||||
const media = (() => {
|
||||
if (width <= 640) return MediaType.mobile;
|
||||
if (width <= 768) return MediaType.tablet;
|
||||
return MediaType.pc;
|
||||
})();
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleWindowResize = () => setWidth(window.innerWidth);
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
return () => window.removeEventListener("resize", handleWindowResize);
|
||||
}, []);
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
export default useBreakpoints
|
||||
66
hooks/use-conversation.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { useState } from 'react'
|
||||
import type { ConversationItem } from '@/types/app'
|
||||
import produce from 'immer'
|
||||
|
||||
const storageConversationIdKey = 'conversationIdInfo'
|
||||
|
||||
type ConversationInfoType = Omit<ConversationItem, 'inputs' | 'id'>
|
||||
function useConversation() {
|
||||
const [conversationList, setConversationList] = useState<ConversationItem[]>([])
|
||||
const [currConversationId, doSetCurrConversationId] = useState<string>('-1')
|
||||
// when set conversation id, we do not have set appId
|
||||
const setCurrConversationId = (id: string, appId: string, isSetToLocalStroge = true, newConversationName = '') => {
|
||||
doSetCurrConversationId(id)
|
||||
if (isSetToLocalStroge && id !== '-1') {
|
||||
// conversationIdInfo: {[appId1]: conversationId1, [appId2]: conversationId2}
|
||||
const conversationIdInfo = globalThis.localStorage?.getItem(storageConversationIdKey) ? JSON.parse(globalThis.localStorage?.getItem(storageConversationIdKey) || '') : {}
|
||||
conversationIdInfo[appId] = id
|
||||
globalThis.localStorage?.setItem(storageConversationIdKey, JSON.stringify(conversationIdInfo))
|
||||
}
|
||||
}
|
||||
|
||||
const getConversationIdFromStorage = (appId: string) => {
|
||||
const conversationIdInfo = globalThis.localStorage?.getItem(storageConversationIdKey) ? JSON.parse(globalThis.localStorage?.getItem(storageConversationIdKey) || '') : {}
|
||||
const id = conversationIdInfo[appId]
|
||||
return id
|
||||
}
|
||||
|
||||
const isNewConversation = currConversationId === '-1'
|
||||
// input can be updated by user
|
||||
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any> | null>(null)
|
||||
const resetNewConversationInputs = () => {
|
||||
if (!newConversationInputs) return
|
||||
setNewConversationInputs(produce(newConversationInputs, draft => {
|
||||
Object.keys(draft).forEach(key => {
|
||||
draft[key] = ''
|
||||
})
|
||||
}))
|
||||
}
|
||||
const [existConversationInputs, setExistConversationInputs] = useState<Record<string, any> | null>(null)
|
||||
const currInputs = isNewConversation ? newConversationInputs : existConversationInputs
|
||||
const setCurrInputs = isNewConversation ? setNewConversationInputs : setExistConversationInputs
|
||||
|
||||
// info is muted
|
||||
const [newConversationInfo, setNewConversationInfo] = useState<ConversationInfoType | null>(null)
|
||||
const [existConversationInfo, setExistConversationInfo] = useState<ConversationInfoType | null>(null)
|
||||
const currConversationInfo = isNewConversation ? newConversationInfo : existConversationInfo
|
||||
|
||||
return {
|
||||
conversationList,
|
||||
setConversationList,
|
||||
currConversationId,
|
||||
setCurrConversationId,
|
||||
getConversationIdFromStorage,
|
||||
isNewConversation,
|
||||
currInputs,
|
||||
newConversationInputs,
|
||||
existConversationInputs,
|
||||
resetNewConversationInputs,
|
||||
setCurrInputs,
|
||||
currConversationInfo,
|
||||
setNewConversationInfo,
|
||||
setExistConversationInfo
|
||||
}
|
||||
}
|
||||
|
||||
export default useConversation;
|
||||
18
i18n/client.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import Cookies from 'js-cookie'
|
||||
import type { Locale } from '.'
|
||||
import { i18n } from '.'
|
||||
import { LOCALE_COOKIE_NAME } from '@/config'
|
||||
import { changeLanguage } from '@/i18n/i18next-config'
|
||||
|
||||
// same logic as server
|
||||
export const getLocaleOnClient = (): Locale => {
|
||||
return Cookies.get(LOCALE_COOKIE_NAME) as Locale || i18n.defaultLocale
|
||||
}
|
||||
|
||||
export const setLocaleOnClient = (locale: Locale, notReload?: boolean) => {
|
||||
Cookies.set(LOCALE_COOKIE_NAME, locale)
|
||||
changeLanguage(locale)
|
||||
if (!notReload) {
|
||||
location.reload()
|
||||
}
|
||||
}
|
||||
38
i18n/i18next-config.ts
Normal file
@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import commonEn from './lang/common.en'
|
||||
import commonZh from './lang/common.zh'
|
||||
import appEn from './lang/app.en'
|
||||
import appZh from './lang/app.zh'
|
||||
import { Locale } from '.'
|
||||
|
||||
const resources = {
|
||||
'en': {
|
||||
translation: {
|
||||
common: commonEn,
|
||||
app: appEn,
|
||||
},
|
||||
},
|
||||
'zh-Hans': {
|
||||
translation: {
|
||||
common: commonZh,
|
||||
app: appZh,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
i18n.use(initReactI18next)
|
||||
// init i18next
|
||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init({
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
// debug: true,
|
||||
resources,
|
||||
})
|
||||
|
||||
export const changeLanguage = (lan: Locale) => {
|
||||
i18n.changeLanguage(lan)
|
||||
}
|
||||
export default i18n
|
||||
26
i18n/i18next-serverside-config.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { createInstance } from 'i18next'
|
||||
import resourcesToBackend from 'i18next-resources-to-backend'
|
||||
import { initReactI18next } from 'react-i18next/initReactI18next'
|
||||
import { Locale } from '.'
|
||||
|
||||
// https://locize.com/blog/next-13-app-dir-i18n/
|
||||
const initI18next = async (lng: Locale, ns: string) => {
|
||||
const i18nInstance = createInstance()
|
||||
await i18nInstance
|
||||
.use(initReactI18next)
|
||||
.use(resourcesToBackend((language: string, namespace: string) => import(`./lang/${namespace}.${language}.ts`)))
|
||||
.init({
|
||||
lng: lng === 'zh-Hans' ? 'zh' : lng,
|
||||
ns,
|
||||
fallbackLng: 'en',
|
||||
})
|
||||
return i18nInstance
|
||||
}
|
||||
|
||||
export async function useTranslation(lng: Locale, ns = '', options: Record<string, any> = {}) {
|
||||
const i18nextInstance = await initI18next(lng, ns)
|
||||
return {
|
||||
t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix),
|
||||
i18n: i18nextInstance
|
||||
}
|
||||
}
|
||||
6
i18n/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const i18n = {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'zh-Hans'],
|
||||
} as const
|
||||
|
||||
export type Locale = typeof i18n['locales'][number]
|
||||
33
i18n/lang/app.en.ts
Normal file
@ -0,0 +1,33 @@
|
||||
const translation = {
|
||||
common: {
|
||||
welcome: "Welcome to use",
|
||||
appUnavailable: "App is unavailable",
|
||||
appUnkonwError: "App is unavailable"
|
||||
},
|
||||
chat: {
|
||||
newChat: "New chat",
|
||||
newChatDefaultName: "New conversation",
|
||||
openingStatementTitle: "Opening statement",
|
||||
powerBy: "Powered by",
|
||||
prompt: "Prompt",
|
||||
privatePromptConfigTitle: "Conversation settings",
|
||||
publicPromptConfigTitle: "Initial Prompt",
|
||||
configStatusDes: "Before start, you can modify conversation settings",
|
||||
configDisabled:
|
||||
"Previous session settings have been used for this session.",
|
||||
startChat: "Start Chat",
|
||||
privacyPolicyLeft:
|
||||
"Please read the ",
|
||||
privacyPolicyMiddle:
|
||||
"privacy policy",
|
||||
privacyPolicyRight:
|
||||
" provided by the app developer.",
|
||||
},
|
||||
errorMessage: {
|
||||
valueOfVarRequired: "Variables value can not be empty",
|
||||
waitForResponse:
|
||||
"Please wait for the response to the previous message to complete.",
|
||||
},
|
||||
};
|
||||
|
||||
export default translation;
|
||||
28
i18n/lang/app.zh.ts
Normal file
@ -0,0 +1,28 @@
|
||||
const translation = {
|
||||
common: {
|
||||
welcome: "欢迎使用",
|
||||
appUnavailable: "应用不可用",
|
||||
appUnkonwError: "应用不可用",
|
||||
},
|
||||
chat: {
|
||||
newChat: "新对话",
|
||||
newChatDefaultName: "新的对话",
|
||||
openingStatementTitle: "对话开场白",
|
||||
powerBy: "Powered by",
|
||||
prompt: "提示词",
|
||||
privatePromptConfigTitle: "对话设置",
|
||||
publicPromptConfigTitle: "对话前提示词",
|
||||
configStatusDes: "开始前,您可以修改对话设置",
|
||||
configDisabled: "此次会话已使用上次会话表单",
|
||||
startChat: "开始对话",
|
||||
privacyPolicyLeft: "请阅读由该应用开发者提供的",
|
||||
privacyPolicyMiddle: "隐私政策",
|
||||
privacyPolicyRight: "。",
|
||||
},
|
||||
errorMessage: {
|
||||
valueOfVarRequired: "变量值必填",
|
||||
waitForResponse: "请等待上条信息响应完成",
|
||||
},
|
||||
};
|
||||
|
||||
export default translation;
|
||||
22
i18n/lang/common.en.ts
Normal file
@ -0,0 +1,22 @@
|
||||
const translation = {
|
||||
api: {
|
||||
success: 'Success',
|
||||
saved: 'Saved',
|
||||
create: 'Created',
|
||||
},
|
||||
operation: {
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
clear: 'Clear',
|
||||
save: 'Save',
|
||||
edit: 'Edit',
|
||||
refresh: 'Restart',
|
||||
search: 'Search',
|
||||
send: 'Send',
|
||||
lineBreak: 'Line break',
|
||||
like: 'like',
|
||||
dislike: 'dislike',
|
||||
}
|
||||
}
|
||||
|
||||
export default translation
|
||||
22
i18n/lang/common.zh.ts
Normal file
@ -0,0 +1,22 @@
|
||||
const translation = {
|
||||
api: {
|
||||
success: '成功',
|
||||
saved: '已保存',
|
||||
create: '已创建',
|
||||
},
|
||||
operation: {
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
clear: '清空',
|
||||
save: '保存',
|
||||
edit: '编辑',
|
||||
refresh: '重新开始',
|
||||
search: '搜索',
|
||||
send: '发送',
|
||||
lineBreak: '换行',
|
||||
like: '赞同',
|
||||
dislike: '反对',
|
||||
}
|
||||
}
|
||||
|
||||
export default translation
|
||||
29
i18n/server.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import 'server-only'
|
||||
|
||||
import { cookies, headers } from 'next/headers'
|
||||
import Negotiator from 'negotiator'
|
||||
import { match } from '@formatjs/intl-localematcher'
|
||||
import type { Locale } from '.'
|
||||
import { i18n } from '.'
|
||||
|
||||
export const getLocaleOnServer = (): Locale => {
|
||||
// @ts-expect-error locales are readonly
|
||||
const locales: string[] = i18n.locales
|
||||
|
||||
let languages: string[] | undefined
|
||||
// get locale from cookie
|
||||
const localeCookie = cookies().get('locale')
|
||||
languages = localeCookie?.value ? [localeCookie.value] : []
|
||||
|
||||
if (!languages.length) {
|
||||
// Negotiator expects plain object so we need to transform headers
|
||||
const negotiatorHeaders: Record<string, string> = {}
|
||||
headers().forEach((value, key) => (negotiatorHeaders[key] = value))
|
||||
// Use negotiator and intl-localematcher to get best locale
|
||||
languages = new Negotiator({ headers: negotiatorHeaders }).languages()
|
||||
}
|
||||
|
||||
// match locale
|
||||
const matchedLocale = match(languages, locales, i18n.defaultLocale) as Locale
|
||||
return matchedLocale
|
||||
}
|
||||
21
next.config.js
Normal file
@ -0,0 +1,21 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
productionBrowserSourceMaps: false, // enable browser source map generation during the production build
|
||||
// Configure pageExtensions to include md and mdx
|
||||
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
|
||||
experimental: {
|
||||
appDir: true,
|
||||
},
|
||||
// fix all before production. Now it slow the develop speed.
|
||||
eslint: {
|
||||
// Warning: This allows production builds to successfully complete even if
|
||||
// your project has ESLint errors.
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
// https://nextjs.org/docs/api-reference/next.config.js/ignoring-typescript-errors
|
||||
ignoreBuildErrors: true,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
68
package.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "langgenius-gateway-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"fix": "next lint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.2.32",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@heroicons/react": "^2.0.16",
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "^2.3.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/node": "18.15.0",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/react-syntax-highlighter": "^15.5.6",
|
||||
"ahooks": "^3.7.5",
|
||||
"axios": "^1.3.5",
|
||||
"classnames": "^2.3.2",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"eventsource-parser": "^1.0.0",
|
||||
"i18next": "^22.4.13",
|
||||
"i18next-resources-to-backend": "^1.1.3",
|
||||
"immer": "^9.0.19",
|
||||
"js-cookie": "^3.0.1",
|
||||
"negotiator": "^0.6.3",
|
||||
"next": "13.2.4",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.2",
|
||||
"react-headless-pagination": "^1.1.4",
|
||||
"react-i18next": "^12.2.0",
|
||||
"react-markdown": "^8.0.6",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-tooltip": "5.8.3",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"remark-breaks": "^3.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"sass": "^1.61.0",
|
||||
"scheduler": "^0.23.0",
|
||||
"server-only": "^0.0.1",
|
||||
"swr": "^2.1.0",
|
||||
"typescript": "4.9.5",
|
||||
"use-context-selector": "^1.4.1",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^0.36.0",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"@types/negotiator": "^0.6.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"miragejs": "^0.1.47",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.7"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
223
service/base.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { API_PREFIX } from '@/config'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
const TIME_OUT = 100000
|
||||
|
||||
const ContentType = {
|
||||
json: 'application/json',
|
||||
stream: 'text/event-stream',
|
||||
form: 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
download: 'application/octet-stream', // for download
|
||||
}
|
||||
|
||||
const baseOptions = {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
credentials: 'include', // always send cookies、HTTP Basic authentication.
|
||||
headers: new Headers({
|
||||
'Content-Type': ContentType.json,
|
||||
}),
|
||||
redirect: 'follow',
|
||||
}
|
||||
|
||||
export type IOnDataMoreInfo = {
|
||||
conversationId: string | undefined
|
||||
messageId: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export type IOnData = (message: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => void
|
||||
export type IOnCompleted = () => void
|
||||
export type IOnError = (msg: string) => void
|
||||
|
||||
type IOtherOptions = {
|
||||
needAllResponseContent?: boolean
|
||||
onData?: IOnData // for stream
|
||||
onError?: IOnError
|
||||
onCompleted?: IOnCompleted // for stream
|
||||
}
|
||||
|
||||
function unicodeToChar(text: string) {
|
||||
return text.replace(/\\u[0-9a-f]{4}/g, (_match, p1) => {
|
||||
return String.fromCharCode(parseInt(p1, 16))
|
||||
})
|
||||
}
|
||||
|
||||
const handleStream = (response: any, onData: IOnData, onCompleted?: IOnCompleted) => {
|
||||
if (!response.ok)
|
||||
throw new Error('Network response was not ok')
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
let bufferObj: any
|
||||
let isFirstMessage = true
|
||||
function read() {
|
||||
reader.read().then((result: any) => {
|
||||
if (result.done) {
|
||||
onCompleted && onCompleted()
|
||||
return
|
||||
}
|
||||
buffer += decoder.decode(result.value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
try {
|
||||
lines.forEach((message) => {
|
||||
if (!message) return
|
||||
bufferObj = JSON.parse(message) // remove data: and parse as json
|
||||
onData(unicodeToChar(bufferObj.answer), isFirstMessage, {
|
||||
conversationId: bufferObj.conversation_id,
|
||||
messageId: bufferObj.id,
|
||||
})
|
||||
isFirstMessage = false
|
||||
})
|
||||
buffer = lines[lines.length - 1]
|
||||
} catch (e) {
|
||||
onData('', false, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
errorMessage: e + ''
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
read()
|
||||
})
|
||||
}
|
||||
read()
|
||||
}
|
||||
|
||||
const baseFetch = (url: string, fetchOptions: any, { needAllResponseContent }: IOtherOptions) => {
|
||||
const options = Object.assign({}, baseOptions, fetchOptions)
|
||||
|
||||
let urlPrefix = API_PREFIX
|
||||
|
||||
let urlWithPrefix = `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}`
|
||||
|
||||
const { method, params, body } = options
|
||||
// handle query
|
||||
if (method === 'GET' && params) {
|
||||
const paramsArray: string[] = []
|
||||
Object.keys(params).forEach(key =>
|
||||
paramsArray.push(`${key}=${encodeURIComponent(params[key])}`),
|
||||
)
|
||||
if (urlWithPrefix.search(/\?/) === -1)
|
||||
urlWithPrefix += `?${paramsArray.join('&')}`
|
||||
|
||||
else
|
||||
urlWithPrefix += `&${paramsArray.join('&')}`
|
||||
|
||||
delete options.params
|
||||
}
|
||||
|
||||
if (body)
|
||||
options.body = JSON.stringify(body)
|
||||
|
||||
// Handle timeout
|
||||
return Promise.race([
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('request timeout'))
|
||||
}, TIME_OUT)
|
||||
}),
|
||||
new Promise((resolve, reject) => {
|
||||
globalThis.fetch(urlWithPrefix, options)
|
||||
.then((res: any) => {
|
||||
const resClone = res.clone()
|
||||
// Error handler
|
||||
if (!/^(2|3)\d{2}$/.test(res.status)) {
|
||||
const bodyJson = res.json()
|
||||
switch (res.status) {
|
||||
case 401: {
|
||||
Toast.notify({ type: 'error', message: 'Invalid token' })
|
||||
return
|
||||
|
||||
}
|
||||
default:
|
||||
// eslint-disable-next-line no-new
|
||||
new Promise(() => {
|
||||
bodyJson.then((data: any) => {
|
||||
Toast.notify({ type: 'error', message: data.message })
|
||||
})
|
||||
})
|
||||
}
|
||||
return Promise.reject(resClone)
|
||||
}
|
||||
|
||||
// handle delete api. Delete api not return content.
|
||||
if (res.status === 204) {
|
||||
resolve({ result: "success" })
|
||||
return
|
||||
}
|
||||
|
||||
// return data
|
||||
const data = options.headers.get('Content-type') === ContentType.download ? res.blob() : res.json()
|
||||
|
||||
resolve(needAllResponseContent ? resClone : data)
|
||||
})
|
||||
.catch((err) => {
|
||||
Toast.notify({ type: 'error', message: err })
|
||||
reject(err)
|
||||
})
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
export const ssePost = (url: string, fetchOptions: any, {
|
||||
onData, onCompleted, onError }: IOtherOptions) => {
|
||||
const options = Object.assign({}, baseOptions, {
|
||||
method: 'POST',
|
||||
}, fetchOptions)
|
||||
|
||||
const urlPrefix = API_PREFIX
|
||||
const urlWithPrefix = `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}`
|
||||
|
||||
const { body } = options
|
||||
if (body)
|
||||
options.body = JSON.stringify(body)
|
||||
|
||||
globalThis.fetch(urlWithPrefix, options)
|
||||
.then((res: any) => {
|
||||
if (!/^(2|3)\d{2}$/.test(res.status)) {
|
||||
// eslint-disable-next-line no-new
|
||||
new Promise(() => {
|
||||
res.json().then((data: any) => {
|
||||
Toast.notify({ type: 'error', message: data.message || 'Server Error' })
|
||||
})
|
||||
})
|
||||
onError?.('Server Error')
|
||||
return
|
||||
}
|
||||
return handleStream(res, (str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
|
||||
if (moreInfo.errorMessage) {
|
||||
Toast.notify({ type: 'error', message: moreInfo.errorMessage })
|
||||
return
|
||||
}
|
||||
onData?.(str, isFirstMessage, moreInfo)
|
||||
}, () => {
|
||||
onCompleted?.()
|
||||
})
|
||||
}).catch((e) => {
|
||||
Toast.notify({ type: 'error', message: e })
|
||||
onError?.(e)
|
||||
})
|
||||
}
|
||||
|
||||
export const request = (url: string, options = {}, otherOptions?: IOtherOptions) => {
|
||||
return baseFetch(url, options, otherOptions || {})
|
||||
}
|
||||
|
||||
export const get = (url: string, options = {}, otherOptions?: IOtherOptions) => {
|
||||
return request(url, Object.assign({}, options, { method: 'GET' }), otherOptions)
|
||||
}
|
||||
|
||||
export const post = (url: string, options = {}, otherOptions?: IOtherOptions) => {
|
||||
return request(url, Object.assign({}, options, { method: 'POST' }), otherOptions)
|
||||
}
|
||||
|
||||
export const put = (url: string, options = {}, otherOptions?: IOtherOptions) => {
|
||||
return request(url, Object.assign({}, options, { method: 'PUT' }), otherOptions)
|
||||
}
|
||||
|
||||
export const del = (url: string, options = {}, otherOptions?: IOtherOptions) => {
|
||||
return request(url, Object.assign({}, options, { method: 'DELETE' }), otherOptions)
|
||||
}
|
||||
37
service/index.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { IOnCompleted, IOnData, IOnError } from './base'
|
||||
import { get, post, ssePost } from './base'
|
||||
import type { Feedbacktype } from '@/types/app'
|
||||
|
||||
export const sendChatMessage = async (body: Record<string, any>, { onData, onCompleted, onError }: {
|
||||
onData: IOnData
|
||||
onCompleted: IOnCompleted
|
||||
onError: IOnError
|
||||
}) => {
|
||||
return ssePost('chat-messages', {
|
||||
body: {
|
||||
...body,
|
||||
response_mode: 'streaming',
|
||||
},
|
||||
}, { onData, onCompleted, onError })
|
||||
}
|
||||
|
||||
export const fetchAppInfo = async () => {
|
||||
return get('site')
|
||||
}
|
||||
|
||||
export const fetchConversations = async () => {
|
||||
return get('conversations', { params: { limit: 20, first_id: '' } })
|
||||
}
|
||||
|
||||
export const fetchChatList = async (conversationId: string) => {
|
||||
return get('messages', { params: { conversation_id: conversationId, limit: 20, last_id: '' } })
|
||||
}
|
||||
|
||||
// init value. wait for server update
|
||||
export const fetchAppParams = async () => {
|
||||
return get('parameters')
|
||||
}
|
||||
|
||||
export const updateFeedback = async ({ url, body }: { url: string; body: Feedbacktype }) => {
|
||||
return post(url, { body })
|
||||
}
|
||||
66
tailwind.config.js
Normal file
@ -0,0 +1,66 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./app/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
typography: require('./typography'),
|
||||
extend: {
|
||||
colors: {
|
||||
gray: {
|
||||
50: '#F9FAFB',
|
||||
100: '#F3F4F6',
|
||||
200: '#E5E7EB',
|
||||
300: '#D1D5DB',
|
||||
400: '#9CA3AF',
|
||||
500: '#6B7280',
|
||||
700: '#374151',
|
||||
800: '#1F2A37',
|
||||
900: '#111928',
|
||||
},
|
||||
primary: {
|
||||
50: '#EBF5FF',
|
||||
100: '#E1EFFE',
|
||||
200: '#C3DDFD',
|
||||
300: '#A4CAFE',
|
||||
600: '#1C64F2',
|
||||
700: '#1A56DB',
|
||||
},
|
||||
blue: {
|
||||
500: '#E1EFFE',
|
||||
},
|
||||
green: {
|
||||
50: '#F3FAF7',
|
||||
100: '#DEF7EC',
|
||||
800: '#03543F',
|
||||
|
||||
},
|
||||
yellow: {
|
||||
100: '#FDF6B2',
|
||||
800: '#723B13',
|
||||
},
|
||||
purple: {
|
||||
50: '#F6F5FF',
|
||||
},
|
||||
indigo: {
|
||||
25: '#F5F8FF',
|
||||
100: '#E0EAFF',
|
||||
600: '#444CE7'
|
||||
}
|
||||
},
|
||||
screens: {
|
||||
'mobile': '100px',
|
||||
// => @media (min-width: 100px) { ... }
|
||||
'tablet': '640px', // 391
|
||||
// => @media (min-width: 600px) { ... }
|
||||
'pc': '769px',
|
||||
// => @media (min-width: 769px) { ... }
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'),
|
||||
require('@tailwindcss/line-clamp'),
|
||||
],
|
||||
}
|
||||
42
tsconfig.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2015",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"app/components/develop/Prose.jsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
75
types/app.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { Locale } from '@/i18n'
|
||||
|
||||
export type PromptVariable = {
|
||||
key: string,
|
||||
name: string,
|
||||
type: "string" | "number" | "select",
|
||||
default?: string | number,
|
||||
options?: string[]
|
||||
max_length: number
|
||||
}
|
||||
|
||||
export type PromptConfig = {
|
||||
prompt_template: string,
|
||||
prompt_variables: PromptVariable[],
|
||||
}
|
||||
|
||||
export const MessageRatings = ['like', 'dislike', null] as const
|
||||
export type MessageRating = typeof MessageRatings[number]
|
||||
|
||||
export type Feedbacktype = {
|
||||
rating: MessageRating
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
export type MessageMore = {
|
||||
time: string
|
||||
tokens: number
|
||||
latency: number | string
|
||||
}
|
||||
|
||||
export type IChatItem = {
|
||||
id: string
|
||||
content: string
|
||||
/**
|
||||
* 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
|
||||
isIntroduction?: boolean
|
||||
useCurrentUserAvatar?: boolean
|
||||
isOpeningStatement?: boolean
|
||||
}
|
||||
|
||||
|
||||
export type ResponseHolder = {}
|
||||
|
||||
export type ConversationItem = {
|
||||
id: string
|
||||
name: string
|
||||
inputs: Record<string, any> | null
|
||||
introduction: string,
|
||||
}
|
||||
|
||||
export type SiteInfo = {
|
||||
title: string
|
||||
description: string
|
||||
default_language: Locale
|
||||
copyright?: string
|
||||
privacy_policy?: string
|
||||
}
|
||||
357
typography.js
Normal file
@ -0,0 +1,357 @@
|
||||
module.exports = ({ theme }) => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
'--tw-prose-body': theme('colors.zinc.700'),
|
||||
'--tw-prose-headings': theme('colors.zinc.900'),
|
||||
'--tw-prose-links': theme('colors.emerald.500'),
|
||||
'--tw-prose-links-hover': theme('colors.emerald.600'),
|
||||
'--tw-prose-links-underline': theme('colors.emerald.500 / 0.3'),
|
||||
'--tw-prose-bold': theme('colors.zinc.900'),
|
||||
'--tw-prose-counters': theme('colors.zinc.500'),
|
||||
'--tw-prose-bullets': theme('colors.zinc.300'),
|
||||
'--tw-prose-hr': theme('colors.zinc.900 / 0.05'),
|
||||
'--tw-prose-quotes': theme('colors.zinc.900'),
|
||||
'--tw-prose-quote-borders': theme('colors.zinc.200'),
|
||||
'--tw-prose-captions': theme('colors.zinc.500'),
|
||||
'--tw-prose-code': theme('colors.zinc.900'),
|
||||
'--tw-prose-code-bg': theme('colors.zinc.100'),
|
||||
'--tw-prose-code-ring': theme('colors.zinc.300'),
|
||||
'--tw-prose-th-borders': theme('colors.zinc.300'),
|
||||
'--tw-prose-td-borders': theme('colors.zinc.200'),
|
||||
|
||||
'--tw-prose-invert-body': theme('colors.zinc.400'),
|
||||
'--tw-prose-invert-headings': theme('colors.white'),
|
||||
'--tw-prose-invert-links': theme('colors.emerald.400'),
|
||||
'--tw-prose-invert-links-hover': theme('colors.emerald.500'),
|
||||
'--tw-prose-invert-links-underline': theme('colors.emerald.500 / 0.3'),
|
||||
'--tw-prose-invert-bold': theme('colors.white'),
|
||||
'--tw-prose-invert-counters': theme('colors.zinc.400'),
|
||||
'--tw-prose-invert-bullets': theme('colors.zinc.600'),
|
||||
'--tw-prose-invert-hr': theme('colors.white / 0.05'),
|
||||
'--tw-prose-invert-quotes': theme('colors.zinc.100'),
|
||||
'--tw-prose-invert-quote-borders': theme('colors.zinc.700'),
|
||||
'--tw-prose-invert-captions': theme('colors.zinc.400'),
|
||||
'--tw-prose-invert-code': theme('colors.white'),
|
||||
'--tw-prose-invert-code-bg': theme('colors.zinc.700 / 0.15'),
|
||||
'--tw-prose-invert-code-ring': theme('colors.white / 0.1'),
|
||||
'--tw-prose-invert-th-borders': theme('colors.zinc.600'),
|
||||
'--tw-prose-invert-td-borders': theme('colors.zinc.700'),
|
||||
|
||||
// Base
|
||||
color: 'var(--tw-prose-body)',
|
||||
fontSize: theme('fontSize.sm')[0],
|
||||
lineHeight: theme('lineHeight.7'),
|
||||
|
||||
// Layout
|
||||
'> *': {
|
||||
maxWidth: theme('maxWidth.2xl'),
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
'@screen lg': {
|
||||
maxWidth: theme('maxWidth.3xl'),
|
||||
marginLeft: `calc(50% - min(50%, ${theme('maxWidth.lg')}))`,
|
||||
marginRight: `calc(50% - min(50%, ${theme('maxWidth.lg')}))`,
|
||||
},
|
||||
},
|
||||
|
||||
// Text
|
||||
p: {
|
||||
marginTop: theme('spacing.6'),
|
||||
marginBottom: theme('spacing.6'),
|
||||
},
|
||||
'[class~="lead"]': {
|
||||
fontSize: theme('fontSize.base')[0],
|
||||
...theme('fontSize.base')[1],
|
||||
},
|
||||
|
||||
// Lists
|
||||
ol: {
|
||||
listStyleType: 'decimal',
|
||||
marginTop: theme('spacing.5'),
|
||||
marginBottom: theme('spacing.5'),
|
||||
paddingLeft: '1.625rem',
|
||||
},
|
||||
'ol[type="A"]': {
|
||||
listStyleType: 'upper-alpha',
|
||||
},
|
||||
'ol[type="a"]': {
|
||||
listStyleType: 'lower-alpha',
|
||||
},
|
||||
'ol[type="A" s]': {
|
||||
listStyleType: 'upper-alpha',
|
||||
},
|
||||
'ol[type="a" s]': {
|
||||
listStyleType: 'lower-alpha',
|
||||
},
|
||||
'ol[type="I"]': {
|
||||
listStyleType: 'upper-roman',
|
||||
},
|
||||
'ol[type="i"]': {
|
||||
listStyleType: 'lower-roman',
|
||||
},
|
||||
'ol[type="I" s]': {
|
||||
listStyleType: 'upper-roman',
|
||||
},
|
||||
'ol[type="i" s]': {
|
||||
listStyleType: 'lower-roman',
|
||||
},
|
||||
'ol[type="1"]': {
|
||||
listStyleType: 'decimal',
|
||||
},
|
||||
ul: {
|
||||
listStyleType: 'disc',
|
||||
marginTop: theme('spacing.5'),
|
||||
marginBottom: theme('spacing.5'),
|
||||
paddingLeft: '1.625rem',
|
||||
},
|
||||
li: {
|
||||
marginTop: theme('spacing.2'),
|
||||
marginBottom: theme('spacing.2'),
|
||||
},
|
||||
':is(ol, ul) > li': {
|
||||
paddingLeft: theme('spacing[1.5]'),
|
||||
},
|
||||
'ol > li::marker': {
|
||||
fontWeight: '400',
|
||||
color: 'var(--tw-prose-counters)',
|
||||
},
|
||||
'ul > li::marker': {
|
||||
color: 'var(--tw-prose-bullets)',
|
||||
},
|
||||
'> ul > li p': {
|
||||
marginTop: theme('spacing.3'),
|
||||
marginBottom: theme('spacing.3'),
|
||||
},
|
||||
'> ul > li > *:first-child': {
|
||||
marginTop: theme('spacing.5'),
|
||||
},
|
||||
'> ul > li > *:last-child': {
|
||||
marginBottom: theme('spacing.5'),
|
||||
},
|
||||
'> ol > li > *:first-child': {
|
||||
marginTop: theme('spacing.5'),
|
||||
},
|
||||
'> ol > li > *:last-child': {
|
||||
marginBottom: theme('spacing.5'),
|
||||
},
|
||||
'ul ul, ul ol, ol ul, ol ol': {
|
||||
marginTop: theme('spacing.3'),
|
||||
marginBottom: theme('spacing.3'),
|
||||
},
|
||||
|
||||
// Horizontal rules
|
||||
hr: {
|
||||
borderColor: 'var(--tw-prose-hr)',
|
||||
borderTopWidth: 1,
|
||||
marginTop: theme('spacing.16'),
|
||||
marginBottom: theme('spacing.16'),
|
||||
maxWidth: 'none',
|
||||
marginLeft: `calc(-1 * ${theme('spacing.4')})`,
|
||||
marginRight: `calc(-1 * ${theme('spacing.4')})`,
|
||||
'@screen sm': {
|
||||
marginLeft: `calc(-1 * ${theme('spacing.6')})`,
|
||||
marginRight: `calc(-1 * ${theme('spacing.6')})`,
|
||||
},
|
||||
'@screen lg': {
|
||||
marginLeft: `calc(-1 * ${theme('spacing.8')})`,
|
||||
marginRight: `calc(-1 * ${theme('spacing.8')})`,
|
||||
},
|
||||
},
|
||||
|
||||
// Quotes
|
||||
blockquote: {
|
||||
fontWeight: '500',
|
||||
fontStyle: 'italic',
|
||||
color: 'var(--tw-prose-quotes)',
|
||||
borderLeftWidth: '0.25rem',
|
||||
borderLeftColor: 'var(--tw-prose-quote-borders)',
|
||||
quotes: '"\\201C""\\201D""\\2018""\\2019"',
|
||||
marginTop: theme('spacing.8'),
|
||||
marginBottom: theme('spacing.8'),
|
||||
paddingLeft: theme('spacing.5'),
|
||||
},
|
||||
'blockquote p:first-of-type::before': {
|
||||
content: 'open-quote',
|
||||
},
|
||||
'blockquote p:last-of-type::after': {
|
||||
content: 'close-quote',
|
||||
},
|
||||
|
||||
// Headings
|
||||
h1: {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
fontWeight: '700',
|
||||
fontSize: theme('fontSize.2xl')[0],
|
||||
...theme('fontSize.2xl')[1],
|
||||
marginBottom: theme('spacing.2'),
|
||||
},
|
||||
h2: {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
fontWeight: '600',
|
||||
fontSize: theme('fontSize.lg')[0],
|
||||
...theme('fontSize.lg')[1],
|
||||
marginTop: theme('spacing.16'),
|
||||
marginBottom: theme('spacing.2'),
|
||||
},
|
||||
h3: {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
fontSize: theme('fontSize.base')[0],
|
||||
...theme('fontSize.base')[1],
|
||||
fontWeight: '600',
|
||||
marginTop: theme('spacing.10'),
|
||||
marginBottom: theme('spacing.2'),
|
||||
},
|
||||
|
||||
// Media
|
||||
'img, video, figure': {
|
||||
marginTop: theme('spacing.8'),
|
||||
marginBottom: theme('spacing.8'),
|
||||
},
|
||||
'figure > *': {
|
||||
marginTop: '0',
|
||||
marginBottom: '0',
|
||||
},
|
||||
figcaption: {
|
||||
color: 'var(--tw-prose-captions)',
|
||||
fontSize: theme('fontSize.xs')[0],
|
||||
...theme('fontSize.xs')[1],
|
||||
marginTop: theme('spacing.2'),
|
||||
},
|
||||
|
||||
// Tables
|
||||
table: {
|
||||
width: '100%',
|
||||
tableLayout: 'auto',
|
||||
textAlign: 'left',
|
||||
marginTop: theme('spacing.8'),
|
||||
marginBottom: theme('spacing.8'),
|
||||
lineHeight: theme('lineHeight.6'),
|
||||
},
|
||||
thead: {
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomColor: 'var(--tw-prose-th-borders)',
|
||||
},
|
||||
'thead th': {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
fontWeight: '600',
|
||||
verticalAlign: 'bottom',
|
||||
paddingRight: theme('spacing.2'),
|
||||
paddingBottom: theme('spacing.2'),
|
||||
paddingLeft: theme('spacing.2'),
|
||||
},
|
||||
'thead th:first-child': {
|
||||
paddingLeft: '0',
|
||||
},
|
||||
'thead th:last-child': {
|
||||
paddingRight: '0',
|
||||
},
|
||||
'tbody tr': {
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomColor: 'var(--tw-prose-td-borders)',
|
||||
},
|
||||
'tbody tr:last-child': {
|
||||
borderBottomWidth: '0',
|
||||
},
|
||||
'tbody td': {
|
||||
verticalAlign: 'baseline',
|
||||
},
|
||||
tfoot: {
|
||||
borderTopWidth: '1px',
|
||||
borderTopColor: 'var(--tw-prose-th-borders)',
|
||||
},
|
||||
'tfoot td': {
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
':is(tbody, tfoot) td': {
|
||||
paddingTop: theme('spacing.2'),
|
||||
paddingRight: theme('spacing.2'),
|
||||
paddingBottom: theme('spacing.2'),
|
||||
paddingLeft: theme('spacing.2'),
|
||||
},
|
||||
':is(tbody, tfoot) td:first-child': {
|
||||
paddingLeft: '0',
|
||||
},
|
||||
':is(tbody, tfoot) td:last-child': {
|
||||
paddingRight: '0',
|
||||
},
|
||||
|
||||
// Inline elements
|
||||
a: {
|
||||
color: 'var(--tw-prose-links)',
|
||||
textDecoration: 'underline transparent',
|
||||
fontWeight: '500',
|
||||
transitionProperty: 'color, text-decoration-color',
|
||||
transitionDuration: theme('transitionDuration.DEFAULT'),
|
||||
transitionTimingFunction: theme('transitionTimingFunction.DEFAULT'),
|
||||
'&:hover': {
|
||||
color: 'var(--tw-prose-links-hover)',
|
||||
textDecorationColor: 'var(--tw-prose-links-underline)',
|
||||
},
|
||||
},
|
||||
':is(h1, h2, h3) a': {
|
||||
fontWeight: 'inherit',
|
||||
},
|
||||
strong: {
|
||||
color: 'var(--tw-prose-bold)',
|
||||
fontWeight: '600',
|
||||
},
|
||||
':is(a, blockquote, thead th) strong': {
|
||||
color: 'inherit',
|
||||
},
|
||||
code: {
|
||||
color: 'var(--tw-prose-code)',
|
||||
borderRadius: theme('borderRadius.lg'),
|
||||
paddingTop: theme('padding.1'),
|
||||
paddingRight: theme('padding[1.5]'),
|
||||
paddingBottom: theme('padding.1'),
|
||||
paddingLeft: theme('padding[1.5]'),
|
||||
boxShadow: 'inset 0 0 0 1px var(--tw-prose-code-ring)',
|
||||
backgroundColor: 'var(--tw-prose-code-bg)',
|
||||
fontSize: theme('fontSize.2xs'),
|
||||
},
|
||||
':is(a, h1, h2, h3, blockquote, thead th) code': {
|
||||
color: 'inherit',
|
||||
},
|
||||
'h2 code': {
|
||||
fontSize: theme('fontSize.base')[0],
|
||||
fontWeight: 'inherit',
|
||||
},
|
||||
'h3 code': {
|
||||
fontSize: theme('fontSize.sm')[0],
|
||||
fontWeight: 'inherit',
|
||||
},
|
||||
|
||||
// Overrides
|
||||
':is(h1, h2, h3) + *': {
|
||||
marginTop: '0',
|
||||
},
|
||||
'> :first-child': {
|
||||
marginTop: '0 !important',
|
||||
},
|
||||
'> :last-child': {
|
||||
marginBottom: '0 !important',
|
||||
},
|
||||
},
|
||||
},
|
||||
invert: {
|
||||
css: {
|
||||
'--tw-prose-body': 'var(--tw-prose-invert-body)',
|
||||
'--tw-prose-headings': 'var(--tw-prose-invert-headings)',
|
||||
'--tw-prose-links': 'var(--tw-prose-invert-links)',
|
||||
'--tw-prose-links-hover': 'var(--tw-prose-invert-links-hover)',
|
||||
'--tw-prose-links-underline': 'var(--tw-prose-invert-links-underline)',
|
||||
'--tw-prose-bold': 'var(--tw-prose-invert-bold)',
|
||||
'--tw-prose-counters': 'var(--tw-prose-invert-counters)',
|
||||
'--tw-prose-bullets': 'var(--tw-prose-invert-bullets)',
|
||||
'--tw-prose-hr': 'var(--tw-prose-invert-hr)',
|
||||
'--tw-prose-quotes': 'var(--tw-prose-invert-quotes)',
|
||||
'--tw-prose-quote-borders': 'var(--tw-prose-invert-quote-borders)',
|
||||
'--tw-prose-captions': 'var(--tw-prose-invert-captions)',
|
||||
'--tw-prose-code': 'var(--tw-prose-invert-code)',
|
||||
'--tw-prose-code-bg': 'var(--tw-prose-invert-code-bg)',
|
||||
'--tw-prose-code-ring': 'var(--tw-prose-invert-code-ring)',
|
||||
'--tw-prose-th-borders': 'var(--tw-prose-invert-th-borders)',
|
||||
'--tw-prose-td-borders': 'var(--tw-prose-invert-td-borders)',
|
||||
},
|
||||
},
|
||||
})
|
||||
12
utils/prompt.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { PromptVariable } from '@/types/app'
|
||||
|
||||
export function replaceVarWithValues(str: string, promptVariables: PromptVariable[], inputs: Record<string, any>) {
|
||||
return str.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
|
||||
const name = inputs[key]
|
||||
if (name)
|
||||
return name
|
||||
|
||||
const valueObj: PromptVariable | undefined = promptVariables.find(v => v.key === key)
|
||||
return valueObj ? `{{${valueObj.key}}}` : match
|
||||
})
|
||||
}
|
||||
6
utils/string.ts
Normal file
@ -0,0 +1,6 @@
|
||||
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'
|
||||
export function randomString(length: number) {
|
||||
let result = ''
|
||||
for (let i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)]
|
||||
return result
|
||||
}
|
||||