Initial commit
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
|
||||
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
# APP ID
|
||||
NEXT_PUBLIC_APP_ID=
|
||||
# APP API key
|
||||
NEXT_PUBLIC_APP_KEY=
|
||||
# API url prefix
|
||||
NEXT_PUBLIC_API_URL=
|
||||
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"
|
||||
}
|
||||
}
|
||||
54
.gitignore
vendored
Normal file
@ -0,0 +1,54 @@
|
||||
# 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
|
||||
.vscode
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# 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
|
||||
|
||||
# info
|
||||
keys.ts
|
||||
12
Dockerfile
Normal file
@ -0,0 +1,12 @@
|
||||
FROM --platform=linux/amd64 node:19-bullseye-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn install
|
||||
RUN yarn build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["yarn","start"]
|
||||
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Mars
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
60
README.md
Normal file
@ -0,0 +1,60 @@
|
||||
中文 | [English](./README.md)
|
||||
|
||||
<p align="center">
|
||||
<img src="https://assets.dify.ai/images/dify_logo_dark.png" width="288"/>
|
||||
<p>
|
||||
<br>
|
||||
# 定制网页端聊天应用
|
||||
基于Dify [`Dify`](https://github.com/langgenius/dify) 以及 [`webapp-conversation`](https://github.com/langgenius/webapp-conversation) 二次开发。
|
||||
|
||||
## Config App
|
||||
Create a file named `.env.local` in the current directory and copy the contents from `.env.example`. Setting the following content:
|
||||
```
|
||||
# APP ID: This is the unique identifier for your app. You can find it in the app's detail page URL.
|
||||
# For example, in the URL `https://cloud.dify.ai/app/xxx/workflow`, the value `xxx` is your APP ID.
|
||||
NEXT_PUBLIC_APP_ID=
|
||||
|
||||
# APP API Key: This is the key used to authenticate your app's API requests.
|
||||
# You can generate it on the app's "API Access" page by clicking the "API Key" button in the top-right corner.
|
||||
NEXT_PUBLIC_APP_KEY=
|
||||
|
||||
# APP URL: This is the API's base URL. If you're using the Dify cloud service, set it to: https://api.dify.ai/v1.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
```
|
||||
|
||||
Config more in `config/index.ts` file:
|
||||
```js
|
||||
export const APP_INFO: AppInfo = {
|
||||
title: 'Chat APP',
|
||||
description: '',
|
||||
copyright: '',
|
||||
privacy_policy: '',
|
||||
default_language: 'zh-Hans'
|
||||
}
|
||||
|
||||
export const isShowPrompt = true
|
||||
export const promptTemplate = ''
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
80
README_EN.md
Normal file
@ -0,0 +1,80 @@
|
||||
# Conversation Web App Template
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Config App
|
||||
Create a file named `.env.local` in the current directory and copy the contents from `.env.example`. Setting the following content:
|
||||
```
|
||||
# APP ID: This is the unique identifier for your app. You can find it in the app's detail page URL.
|
||||
# For example, in the URL `https://cloud.dify.ai/app/xxx/workflow`, the value `xxx` is your APP ID.
|
||||
NEXT_PUBLIC_APP_ID=
|
||||
|
||||
# APP API Key: This is the key used to authenticate your app's API requests.
|
||||
# You can generate it on the app's "API Access" page by clicking the "API Key" button in the top-right corner.
|
||||
NEXT_PUBLIC_APP_KEY=
|
||||
|
||||
# APP URL: This is the API's base URL. If you're using the Dify cloud service, set it to: https://api.dify.ai/v1.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
```
|
||||
|
||||
Config more in `config/index.ts` file:
|
||||
```js
|
||||
export const APP_INFO: AppInfo = {
|
||||
title: 'Chat APP',
|
||||
description: '',
|
||||
copyright: '',
|
||||
privacy_policy: '',
|
||||
default_language: 'zh-Hans'
|
||||
}
|
||||
|
||||
export const isShowPrompt = true
|
||||
export const promptTemplate = ''
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## Using Docker
|
||||
|
||||
```
|
||||
docker build . -t <DOCKER_HUB_REPO>/webapp-conversation:latest
|
||||
# now you can access it in port 3000
|
||||
docker run -p 3000:3000 <DOCKER_HUB_REPO>/webapp-conversation:latest
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
## 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
|
||||
|
||||
> ⚠️ If you are using [Vercel Hobby](https://vercel.com/pricing), your message will be truncated due to the limitation of vercel.
|
||||
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
17
app/api/audio-to-text/route.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const { user } = getInfo(request)
|
||||
formData.append('user', user)
|
||||
const { data } = await client.aduioToText(formData)
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
catch (e: any) {
|
||||
return NextResponse.json(e.message)
|
||||
}
|
||||
}
|
||||
12
app/api/chat-messages/[taskId]/stop/route.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function POST(request: NextRequest, { params }: {
|
||||
params: { taskId: string }
|
||||
}) {
|
||||
const { taskId } = await params
|
||||
const { user } = getInfo(request)
|
||||
const { data } = await client.stopChat(taskId, user)
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
16
app/api/chat-messages/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const {
|
||||
inputs,
|
||||
query,
|
||||
files,
|
||||
conversation_id: conversationId,
|
||||
response_mode: responseMode,
|
||||
} = body
|
||||
const { user } = getInfo(request)
|
||||
const res = await client.createChatMessage(inputs, query, user, responseMode, conversationId, files)
|
||||
return new Response(res.data as any)
|
||||
}
|
||||
19
app/api/conversations/[conversationId]/name/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function POST(request: NextRequest, { params }: {
|
||||
params: { conversationId: string }
|
||||
}) {
|
||||
const body = await request.json()
|
||||
const {
|
||||
auto_generate,
|
||||
name,
|
||||
} = body
|
||||
const { conversationId } = await params
|
||||
const { user } = getInfo(request)
|
||||
|
||||
// auto generate name
|
||||
const { data } = await client.renameConversation(conversationId, name, user, auto_generate)
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
13
app/api/conversations/[conversationId]/route.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: {
|
||||
params: { conversationId: string }
|
||||
}) {
|
||||
const { conversationId } = await params
|
||||
const { user } = await getInfo(request)
|
||||
|
||||
const { data } = await client.deleteConversation(conversationId, user)
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
19
app/api/conversations/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo, setSession } from '@/app/api/utils/common'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { sessionId, user } = getInfo(request)
|
||||
try {
|
||||
const { data }: any = await client.getConversations(user)
|
||||
return NextResponse.json(data, {
|
||||
headers: setSession(sessionId),
|
||||
})
|
||||
}
|
||||
catch (error: any) {
|
||||
return NextResponse.json({
|
||||
data: [],
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
27
app/api/file-upload/remote/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import axios from 'axios';
|
||||
import FormData from 'form-data'
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { user } = getInfo(request)
|
||||
const { url } = await request.json()
|
||||
const filename = url.split('/').pop() || '';
|
||||
const response = await axios.get(url, { responseType: 'arraybuffer' });
|
||||
if (response.status !== 200) {
|
||||
return NextResponse.json({ error: 'Failed to fetch the file' });
|
||||
}
|
||||
const buffer = Buffer.from(response.data);
|
||||
const formData = new FormData() as any;
|
||||
formData.append('file', buffer, { filename });
|
||||
formData.append('user', user)
|
||||
const { data } = await client.fileUpload(formData)
|
||||
// 返回缺少 url 字段
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
catch (e: any) {
|
||||
return NextResponse.json(e.message)
|
||||
}
|
||||
}
|
||||
17
app/api/file-upload/route.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const { user } = getInfo(request)
|
||||
formData.append('user', user)
|
||||
const { data } = await client.fileUpload(formData)
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
catch (e: any) {
|
||||
return NextResponse.json(e.message)
|
||||
}
|
||||
}
|
||||
35
app/api/info/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo, setSession } from '@/app/api/utils/common'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { sessionId, user } = getInfo(request)
|
||||
try {
|
||||
const { data } = await client.getInfo(user)
|
||||
// 接口未返回 建议从配置文件或者环境变量获取
|
||||
data.app_id = 'app_id'
|
||||
data.site = {
|
||||
"title": "卓远智能问答",
|
||||
"chat_color_theme": null,
|
||||
"chat_color_theme_inverted": false,
|
||||
"icon_type": "image",
|
||||
"icon": "48159ee8",
|
||||
"icon_background": "#E4FBCC",
|
||||
"icon_url": "/files/aaa.png",
|
||||
"description": "Zhuoyuan AI Helper",
|
||||
"copyright": null,
|
||||
"privacy_policy": null,
|
||||
"custom_disclaimer": "",
|
||||
"default_language": "zh-Hans",
|
||||
"prompt_public": false,
|
||||
"show_workflow_steps": true,
|
||||
"use_icon_as_answer_icon": false
|
||||
}
|
||||
return NextResponse.json(data as object, {
|
||||
headers: setSession(sessionId),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
return NextResponse.json([])
|
||||
}
|
||||
}
|
||||
16
app/api/messages/[messageId]/feedbacks/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function POST(request: NextRequest, { params }: {
|
||||
params: { messageId: string }
|
||||
}) {
|
||||
const body = await request.json()
|
||||
const {
|
||||
rating,
|
||||
} = body
|
||||
const { messageId } = await params
|
||||
const { user } = getInfo(request)
|
||||
const { data } = await client.messageFeedback(messageId, rating, user)
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
10
app/api/messages/[messageId]/suggested/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function GET(request: NextRequest, params: { messageId: string }) {
|
||||
const { messageId } = await params
|
||||
const { user } = getInfo(request)
|
||||
const { data }: any = await client.getSuggested(messageId, user,)
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
13
app/api/messages/route.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo, setSession } from '@/app/api/utils/common'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { sessionId, user } = getInfo(request)
|
||||
const { searchParams } = new URL(request.url)
|
||||
const conversationId = searchParams.get('conversation_id')
|
||||
const { data }: any = await client.getConversationMessages(user, conversationId as string)
|
||||
return NextResponse.json(data, {
|
||||
headers: setSession(sessionId),
|
||||
})
|
||||
}
|
||||
16
app/api/meta/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo, setSession } from '@/app/api/utils/common'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { sessionId } = getInfo(request)
|
||||
try {
|
||||
const { data } = await client.getMeta()
|
||||
return NextResponse.json(data as object, {
|
||||
headers: setSession(sessionId),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
return NextResponse.json([])
|
||||
}
|
||||
}
|
||||
16
app/api/parameters/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo, setSession } from '@/app/api/utils/common'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { sessionId, user } = getInfo(request)
|
||||
try {
|
||||
const { data } = await client.getApplicationParameters(user)
|
||||
return NextResponse.json(data as object, {
|
||||
headers: setSession(sessionId),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
return NextResponse.json([])
|
||||
}
|
||||
}
|
||||
20
app/api/utils/common.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { ChatClient } from 'dify-client-plus'
|
||||
import { v4 } from 'uuid'
|
||||
import { API_KEY, API_URL } from '@/config'
|
||||
|
||||
export const getInfo = (request: NextRequest) => {
|
||||
const username = request.cookies.get('username')?.value || 'no-user'
|
||||
const sessionId = request.cookies.get('session_id')?.value || v4()
|
||||
const user = `user_${username}:${sessionId}`
|
||||
return {
|
||||
sessionId,
|
||||
user,
|
||||
}
|
||||
}
|
||||
|
||||
export const setSession = (sessionId: string) => {
|
||||
return { 'Set-Cookie': `session_id=${sessionId}` }
|
||||
}
|
||||
|
||||
export const client = new ChatClient(API_KEY, API_URL || undefined)
|
||||
30
app/components/app-unavailable.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type IAppUnavailableProps = {
|
||||
isUnknownReason: boolean
|
||||
errMessage?: string
|
||||
}
|
||||
|
||||
const AppUnavailable: FC<IAppUnavailableProps> = ({
|
||||
isUnknownReason,
|
||||
errMessage,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
let message = errMessage
|
||||
if (!errMessage)
|
||||
message = (isUnknownReason ? 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 || isUnknownReason) ? 500 : 404}</h1>
|
||||
<div className='text-sm'>{message}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AppUnavailable)
|
||||
54
app/components/app/store.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { create } from 'zustand'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import type { IChatItem } from '@/app/components/chat/type'
|
||||
|
||||
interface State {
|
||||
appDetail?: App & Partial<AppSSO>
|
||||
appSidebarExpand: string
|
||||
currentLogItem?: IChatItem
|
||||
currentLogModalActiveTab: string
|
||||
showPromptLogModal: boolean
|
||||
showAgentLogModal: boolean
|
||||
showMessageLogModal: boolean
|
||||
showAppConfigureFeaturesModal: boolean
|
||||
}
|
||||
|
||||
interface Action {
|
||||
setAppDetail: (appDetail?: App & Partial<AppSSO>) => void
|
||||
setAppSiderbarExpand: (state: string) => void
|
||||
setCurrentLogItem: (item?: IChatItem) => void
|
||||
setCurrentLogModalActiveTab: (tab: string) => void
|
||||
setShowPromptLogModal: (showPromptLogModal: boolean) => void
|
||||
setShowAgentLogModal: (showAgentLogModal: boolean) => void
|
||||
setShowMessageLogModal: (showMessageLogModal: boolean) => void
|
||||
setShowAppConfigureFeaturesModal: (showAppConfigureFeaturesModal: boolean) => void
|
||||
}
|
||||
|
||||
export const useStore = create<State & Action>(set => ({
|
||||
appDetail: undefined,
|
||||
setAppDetail: appDetail => set(() => ({ appDetail })),
|
||||
appSidebarExpand: '',
|
||||
setAppSiderbarExpand: appSidebarExpand => set(() => ({ appSidebarExpand })),
|
||||
currentLogItem: undefined,
|
||||
currentLogModalActiveTab: 'DETAIL',
|
||||
setCurrentLogItem: currentLogItem => set(() => ({ currentLogItem })),
|
||||
setCurrentLogModalActiveTab: currentLogModalActiveTab => set(() => ({ currentLogModalActiveTab })),
|
||||
showPromptLogModal: false,
|
||||
setShowPromptLogModal: showPromptLogModal => set(() => ({ showPromptLogModal })),
|
||||
showAgentLogModal: false,
|
||||
setShowAgentLogModal: showAgentLogModal => set(() => ({ showAgentLogModal })),
|
||||
showMessageLogModal: false,
|
||||
setShowMessageLogModal: showMessageLogModal => set(() => {
|
||||
if (showMessageLogModal) {
|
||||
return { showMessageLogModal }
|
||||
}
|
||||
else {
|
||||
return {
|
||||
showMessageLogModal,
|
||||
currentLogModalActiveTab: 'DETAIL',
|
||||
}
|
||||
}
|
||||
}),
|
||||
showAppConfigureFeaturesModal: false,
|
||||
setShowAppConfigureFeaturesModal: showAppConfigureFeaturesModal => set(() => ({ showAppConfigureFeaturesModal })),
|
||||
}))
|
||||
41
app/components/base/action-button/index.css
Normal file
@ -0,0 +1,41 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.action-btn {
|
||||
@apply inline-flex justify-center items-center cursor-pointer text-text-tertiary hover:text-text-secondary hover:bg-state-base-hover
|
||||
}
|
||||
|
||||
.action-btn-disabled {
|
||||
@apply cursor-not-allowed
|
||||
}
|
||||
|
||||
.action-btn-xl {
|
||||
@apply p-2 w-9 h-9 rounded-lg
|
||||
}
|
||||
|
||||
.action-btn-l {
|
||||
@apply p-1.5 w-8 h-8 rounded-lg
|
||||
}
|
||||
|
||||
/* m is for the regular button */
|
||||
.action-btn-m {
|
||||
@apply p-0.5 w-6 h-6 rounded-lg
|
||||
}
|
||||
|
||||
.action-btn-xs {
|
||||
@apply p-0 w-4 h-4 rounded
|
||||
}
|
||||
|
||||
.action-btn.action-btn-active {
|
||||
@apply text-text-accent bg-state-accent-active hover:bg-state-accent-active-alt
|
||||
}
|
||||
|
||||
.action-btn.action-btn-disabled {
|
||||
@apply text-text-disabled
|
||||
}
|
||||
|
||||
.action-btn.action-btn-destructive {
|
||||
@apply text-text-destructive bg-state-destructive-hover
|
||||
}
|
||||
|
||||
}
|
||||
70
app/components/base/action-button/index.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import React from 'react'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
enum ActionButtonState {
|
||||
Destructive = 'destructive',
|
||||
Active = 'active',
|
||||
Disabled = 'disabled',
|
||||
Default = '',
|
||||
}
|
||||
|
||||
const actionButtonVariants = cva(
|
||||
'action-btn',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'action-btn-xs',
|
||||
m: 'action-btn-m',
|
||||
l: 'action-btn-l',
|
||||
xl: 'action-btn-xl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'm',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ActionButtonProps = {
|
||||
size?: 'xs' | 's' | 'm' | 'l' | 'xl'
|
||||
state?: ActionButtonState
|
||||
styleCss?: CSSProperties
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof actionButtonVariants>
|
||||
|
||||
function getActionButtonState(state: ActionButtonState) {
|
||||
switch (state) {
|
||||
case ActionButtonState.Destructive:
|
||||
return 'action-btn-destructive'
|
||||
case ActionButtonState.Active:
|
||||
return 'action-btn-active'
|
||||
case ActionButtonState.Disabled:
|
||||
return 'action-btn-disabled'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||
({ className, size, state = ActionButtonState.Default, styleCss, children, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
actionButtonVariants({ className, size }),
|
||||
getActionButtonState(state),
|
||||
)}
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
ActionButton.displayName = 'ActionButton'
|
||||
|
||||
export default ActionButton
|
||||
export { ActionButton, ActionButtonState, actionButtonVariants }
|
||||
137
app/components/base/agent-log-modal/detail.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { flatten, uniq } from 'lodash-es'
|
||||
import ResultPanel from './result'
|
||||
import TracingPanel from './tracing'
|
||||
import cn from '@/utils/classnames'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
// import { fetchAgentLogDetail } from '@/service/log'
|
||||
// TODO
|
||||
const fetchAgentLogDetail = () => {
|
||||
console.log('TODO MARS')
|
||||
}
|
||||
|
||||
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { IChatItem } from '@/app/components/chat/type'
|
||||
|
||||
export type AgentLogDetailProps = {
|
||||
activeTab?: 'DETAIL' | 'TRACING'
|
||||
conversationID: string
|
||||
log: IChatItem
|
||||
messageID: string
|
||||
}
|
||||
|
||||
const AgentLogDetail: FC<AgentLogDetailProps> = ({
|
||||
activeTab = 'DETAIL',
|
||||
conversationID,
|
||||
messageID,
|
||||
log,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [currentTab, setCurrentTab] = useState<string>(activeTab)
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [runDetail, setRunDetail] = useState<AgentLogDetailResponse>()
|
||||
const [list, setList] = useState<AgentIteration[]>([])
|
||||
|
||||
const tools = useMemo(() => {
|
||||
const res = uniq(flatten(runDetail?.iterations.map((iteration: any) => {
|
||||
return iteration.tool_calls.map((tool: any) => tool.tool_name).filter(Boolean)
|
||||
})).filter(Boolean))
|
||||
return res
|
||||
}, [runDetail])
|
||||
|
||||
const getLogDetail = useCallback(async (appID: string, conversationID: string, messageID: string) => {
|
||||
try {
|
||||
const res = await fetchAgentLogDetail({
|
||||
appID,
|
||||
params: {
|
||||
conversation_id: conversationID,
|
||||
message_id: messageID,
|
||||
},
|
||||
})
|
||||
setRunDetail(res)
|
||||
setList(res.iterations)
|
||||
}
|
||||
catch (err) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: `${err}`,
|
||||
})
|
||||
}
|
||||
}, [notify])
|
||||
|
||||
const getData = async (appID: string, conversationID: string, messageID: string) => {
|
||||
setLoading(true)
|
||||
await getLogDetail(appID, conversationID, messageID)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const switchTab = async (tab: string) => {
|
||||
setCurrentTab(tab)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// fetch data
|
||||
if (appDetail)
|
||||
getData(appDetail.id, conversationID, messageID)
|
||||
}, [appDetail, conversationID, messageID])
|
||||
|
||||
return (
|
||||
<div className='grow relative flex flex-col'>
|
||||
{/* tab */}
|
||||
<div className='shrink-0 flex items-center px-4 border-b-[0.5px] border-divider-regular'>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-6 py-3 border-b-2 border-transparent text-[13px] font-semibold leading-[18px] text-text-tertiary cursor-pointer',
|
||||
currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-text-secondary',
|
||||
)}
|
||||
onClick={() => switchTab('DETAIL')}
|
||||
>{t('runLog.detail')}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-6 py-3 border-b-2 border-transparent text-[13px] font-semibold leading-[18px] text-text-tertiary cursor-pointer',
|
||||
currentTab === 'TRACING' && '!border-[rgb(21,94,239)] text-text-secondary',
|
||||
)}
|
||||
onClick={() => switchTab('TRACING')}
|
||||
>{t('runLog.tracing')}</div>
|
||||
</div>
|
||||
{/* panel detail */}
|
||||
<div className={cn('grow bg-components-panel-bg h-0 overflow-y-auto rounded-b-2xl', currentTab !== 'DETAIL' && '!bg-background-section')}>
|
||||
{loading && (
|
||||
<div className='flex h-full items-center justify-center bg-components-panel-bg'>
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
{!loading && currentTab === 'DETAIL' && runDetail && (
|
||||
<ResultPanel
|
||||
inputs={log.input}
|
||||
outputs={log.content}
|
||||
status={runDetail.meta.status}
|
||||
error={runDetail.meta.error}
|
||||
elapsed_time={runDetail.meta.elapsed_time}
|
||||
total_tokens={runDetail.meta.total_tokens}
|
||||
created_at={runDetail.meta.start_time}
|
||||
created_by={runDetail.meta.executor}
|
||||
agentMode={runDetail.meta.agent_mode}
|
||||
tools={tools}
|
||||
iterations={runDetail.iterations.length}
|
||||
/>
|
||||
)}
|
||||
{!loading && currentTab === 'TRACING' && (
|
||||
<TracingPanel
|
||||
list={list}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgentLogDetail
|
||||
61
app/components/base/agent-log-modal/index.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import AgentLogDetail from './detail'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { IChatItem } from '@/app/components/chat/type'
|
||||
|
||||
type AgentLogModalProps = {
|
||||
currentLogItem?: IChatItem
|
||||
width: number
|
||||
onCancel: () => void
|
||||
}
|
||||
const AgentLogModal: FC<AgentLogModalProps> = ({
|
||||
currentLogItem,
|
||||
width,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useClickAway(() => {
|
||||
if (mounted)
|
||||
onCancel()
|
||||
}, ref)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!currentLogItem || !currentLogItem.conversationId)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative flex flex-col py-3 bg-components-panel-bg border-[0.5px] border-components-panel-border rounded-xl shadow-xl z-10')}
|
||||
style={{
|
||||
width: 480,
|
||||
position: 'fixed',
|
||||
top: 56 + 8,
|
||||
left: 8 + (width - 480),
|
||||
bottom: 16,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<h1 className='shrink-0 px-4 py-1 text-md font-semibold text-text-primary'>{t('appLog.runDetail.workflowTitle')}</h1>
|
||||
<span className='absolute right-3 top-4 p-1 cursor-pointer z-20' onClick={onCancel}>
|
||||
<RiCloseLine className='w-4 h-4 text-text-tertiary' />
|
||||
</span>
|
||||
<AgentLogDetail
|
||||
conversationID={currentLogItem.conversationId}
|
||||
messageID={currentLogItem.id}
|
||||
log={currentLogItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgentLogModal
|
||||
51
app/components/base/agent-log-modal/iteration.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { FC } from 'react'
|
||||
import ToolCall from './tool-call'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import type { AgentIteration } from '@/models/log'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
isFinal: boolean
|
||||
index: number
|
||||
iterationInfo: AgentIteration
|
||||
}
|
||||
|
||||
const Iteration: FC<Props> = ({ iterationInfo, isFinal, index }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn('px-4 py-2')}>
|
||||
<div className='flex items-center'>
|
||||
{isFinal && (
|
||||
<div className='shrink-0 mr-3 text-text-tertiary text-xs leading-[18px] font-semibold'>{t('appLog.agentLogDetail.finalProcessing')}</div>
|
||||
)}
|
||||
{!isFinal && (
|
||||
<div className='shrink-0 mr-3 text-text-tertiary text-xs leading-[18px] font-semibold'>{`${t('appLog.agentLogDetail.iteration').toUpperCase()} ${index}`}</div>
|
||||
)}
|
||||
<Divider bgStyle='gradient' className='grow h-[1px] mx-0'/>
|
||||
</div>
|
||||
<ToolCall
|
||||
isLLM
|
||||
isFinal={isFinal}
|
||||
tokens={iterationInfo.tokens}
|
||||
observation={iterationInfo.tool_raw.outputs}
|
||||
finalAnswer={iterationInfo.thought}
|
||||
toolCall={{
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
}}
|
||||
/>
|
||||
{iterationInfo.tool_calls.map((toolCall, index) => (
|
||||
<ToolCall
|
||||
isLLM={false}
|
||||
key={index}
|
||||
toolCall={toolCall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Iteration
|
||||
127
app/components/base/agent-log-modal/result.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import StatusPanel from '@/app/components/workflow/run/status'
|
||||
// TODO mars
|
||||
// import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
// import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
// TODO MARS
|
||||
type ResultPanelProps = {
|
||||
status: string
|
||||
elapsed_time?: number
|
||||
total_tokens?: number
|
||||
error?: string
|
||||
inputs?: any
|
||||
outputs?: any
|
||||
created_by?: string
|
||||
created_at: string
|
||||
agentMode?: string
|
||||
tools?: string[]
|
||||
iterations?: number
|
||||
}
|
||||
|
||||
const ResultPanel: FC<ResultPanelProps> = ({
|
||||
elapsed_time,
|
||||
total_tokens,
|
||||
error,
|
||||
inputs,
|
||||
outputs,
|
||||
created_by,
|
||||
created_at,
|
||||
agentMode,
|
||||
tools,
|
||||
iterations,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
|
||||
return (
|
||||
<div className='bg-components-panel-bg py-2'>
|
||||
<div className='px-4 py-2'>
|
||||
<StatusPanel
|
||||
status='succeeded'
|
||||
time={elapsed_time}
|
||||
tokens={total_tokens}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
<div className='px-4 py-2 flex flex-col gap-2'>
|
||||
{/* <CodeEditor
|
||||
readOnly
|
||||
title={<div>INPUT</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={inputs}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>OUTPUT</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={outputs}
|
||||
isJSONStringifyBeauty
|
||||
/> */}
|
||||
</div>
|
||||
<div className='px-4 py-2'>
|
||||
<div className='h-[0.5px] bg-divider-regular opacity-5' />
|
||||
</div>
|
||||
<div className='px-4 py-2'>
|
||||
<div className='relative'>
|
||||
<div className='h-6 leading-6 text-text-tertiary text-xs font-medium'>{t('runLog.meta.title')}</div>
|
||||
<div className='py-1'>
|
||||
<div className='flex'>
|
||||
<div className='shrink-0 w-[104px] px-2 py-[5px] text-text-tertiary text-xs leading-[18px] truncate'>{t('runLog.meta.status')}</div>
|
||||
<div className='grow px-2 py-[5px] text-text-primary text-xs leading-[18px]'>
|
||||
<span>SUCCESS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='shrink-0 w-[104px] px-2 py-[5px] text-text-tertiary text-xs leading-[18px] truncate'>{t('runLog.meta.executor')}</div>
|
||||
<div className='grow px-2 py-[5px] text-text-primary text-xs leading-[18px]'>
|
||||
<span>{created_by || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='shrink-0 w-[104px] px-2 py-[5px] text-text-tertiary text-xs leading-[18px] truncate'>{t('runLog.meta.startTime')}</div>
|
||||
<div className='grow px-2 py-[5px] text-text-primary text-xs leading-[18px]'>
|
||||
<span>{formatTime(Date.parse(created_at) / 1000, t('appLog.dateTimeFormat') as string)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='shrink-0 w-[104px] px-2 py-[5px] text-text-tertiary text-xs leading-[18px] truncate'>{t('runLog.meta.time')}</div>
|
||||
<div className='grow px-2 py-[5px] text-text-primary text-xs leading-[18px]'>
|
||||
<span>{`${elapsed_time?.toFixed(3)}s`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='shrink-0 w-[104px] px-2 py-[5px] text-text-tertiary text-xs leading-[18px] truncate'>{t('runLog.meta.tokens')}</div>
|
||||
<div className='grow px-2 py-[5px] text-text-primary text-xs leading-[18px]'>
|
||||
<span>{`${total_tokens || 0} Tokens`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='shrink-0 w-[104px] px-2 py-[5px] text-text-tertiary text-xs leading-[18px] truncate'>{t('appLog.agentLogDetail.agentMode')}</div>
|
||||
<div className='grow px-2 py-[5px] text-text-primary text-xs leading-[18px]'>
|
||||
<span>{agentMode === 'function_call' ? t('appDebug.agent.agentModeType.functionCall') : t('appDebug.agent.agentModeType.ReACT')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='shrink-0 w-[104px] px-2 py-[5px] text-text-tertiary text-xs leading-[18px] truncate'>{t('appLog.agentLogDetail.toolUsed')}</div>
|
||||
<div className='grow px-2 py-[5px] text-text-primary text-xs leading-[18px]'>
|
||||
<span>{tools?.length ? tools?.join(', ') : 'Null'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='shrink-0 w-[104px] px-2 py-[5px] text-text-tertiary text-xs leading-[18px] truncate'>{t('appLog.agentLogDetail.iterations')}</div>
|
||||
<div className='grow px-2 py-[5px] text-text-primary text-xs leading-[18px]'>
|
||||
<span>{iterations}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultPanel
|
||||
143
app/components/base/agent-log-modal/tool-call.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
RiCheckboxCircleLine,
|
||||
RiErrorWarningLine,
|
||||
} from '@remixicon/react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import cn from '@/utils/classnames'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
// TODO MARS CodeEditor
|
||||
// import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
// import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import type { ToolCall } from '@/models/log'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import I18n from '@/context/i18n'
|
||||
// TODO MARS
|
||||
type Props = {
|
||||
toolCall: ToolCall
|
||||
isLLM: boolean
|
||||
isFinal?: boolean
|
||||
tokens?: number
|
||||
observation?: any
|
||||
finalAnswer?: any
|
||||
}
|
||||
|
||||
const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, observation, finalAnswer }) => {
|
||||
const [collapseState, setCollapseState] = useState<boolean>(true)
|
||||
const { locale } = useContext(I18n)
|
||||
const toolName = isLLM ? 'LLM' : (toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')])
|
||||
|
||||
const getTime = (time: number) => {
|
||||
if (time < 1)
|
||||
return `${(time * 1000).toFixed(3)} ms`
|
||||
if (time > 60)
|
||||
return `${Number.parseInt(Math.round(time / 60).toString())} m ${(time % 60).toFixed(3)} s`
|
||||
return `${time.toFixed(3)} s`
|
||||
}
|
||||
|
||||
const getTokenCount = (tokens: number) => {
|
||||
if (tokens < 1000)
|
||||
return tokens
|
||||
if (tokens >= 1000 && tokens < 1000000)
|
||||
return `${Number.parseFloat((tokens / 1000).toFixed(3))}K`
|
||||
if (tokens >= 1000000)
|
||||
return `${Number.parseFloat((tokens / 1000000).toFixed(3))}M`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('py-1')}>
|
||||
<div className={cn('group transition-all bg-background-default border border-components-panel-border rounded-2xl shadow-xs hover:shadow-md')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center py-3 pl-[6px] pr-3 cursor-pointer',
|
||||
!collapseState && '!pb-2',
|
||||
)}
|
||||
onClick={() => setCollapseState(!collapseState)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'shrink-0 w-3 h-3 mr-1 text-text-quaternary transition-all group-hover:text-text-tertiary',
|
||||
!collapseState && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<BlockIcon className={cn('shrink-0 mr-2')} type={isLLM ? BlockEnum.LLM : BlockEnum.Tool} toolIcon={toolCall.tool_icon} />
|
||||
<div className={cn(
|
||||
'grow text-text-secondary text-[13px] leading-[16px] font-semibold truncate',
|
||||
)} title={toolName}>{toolName}</div>
|
||||
<div className='shrink-0 text-text-tertiary text-xs leading-[18px]'>
|
||||
{toolCall.time_cost && (
|
||||
<span>{getTime(toolCall.time_cost || 0)}</span>
|
||||
)}
|
||||
{isLLM && (
|
||||
<span>{`${getTokenCount(tokens || 0)} tokens`}</span>
|
||||
)}
|
||||
</div>
|
||||
{toolCall.status === 'success' && (
|
||||
<RiCheckboxCircleLine className='shrink-0 ml-2 w-3.5 h-3.5 text-[#12B76A]' />
|
||||
)}
|
||||
{toolCall.status === 'error' && (
|
||||
<RiErrorWarningLine className='shrink-0 ml-2 w-3.5 h-3.5 text-[#F04438]' />
|
||||
)}
|
||||
</div>
|
||||
{!collapseState && (
|
||||
<div className='pb-2'>
|
||||
<div className={cn('px-[10px] py-1')}>
|
||||
{toolCall.status === 'error' && (
|
||||
<div className='px-3 py-[10px] bg-[#fef3f2] rounded-lg border-[0.5px] border-[rbga(0,0,0,0.05)] text-xs leading-[18px] text-[#d92d20] shadow-xs'>{toolCall.error}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* {toolCall.tool_input && (
|
||||
<div className={cn('px-[10px] py-1')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>INPUT</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={toolCall.tool_input}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{toolCall.tool_output && (
|
||||
<div className={cn('px-[10px] py-1')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>OUTPUT</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={toolCall.tool_output}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isLLM && (
|
||||
<div className={cn('px-[10px] py-1')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>OBSERVATION</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={observation}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isLLM && (
|
||||
<div className={cn('px-[10px] py-1')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>{isFinal ? 'FINAL ANSWER' : 'THOUGHT'}</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={finalAnswer}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolCallItem
|
||||
25
app/components/base/agent-log-modal/tracing.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import Iteration from './iteration'
|
||||
import type { AgentIteration } from '@/models/log'
|
||||
|
||||
type TracingPanelProps = {
|
||||
list: AgentIteration[]
|
||||
}
|
||||
|
||||
const TracingPanel: FC<TracingPanelProps> = ({ list }) => {
|
||||
return (
|
||||
<div className='bg-background-section'>
|
||||
{list.map((iteration, index) => (
|
||||
<Iteration
|
||||
key={index}
|
||||
index={index + 1}
|
||||
isFinal={index + 1 === list.length}
|
||||
iterationInfo={iteration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TracingPanel
|
||||
47
app/components/base/answer-icon/index.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { init } from 'emoji-mart'
|
||||
import data from '@emoji-mart/data'
|
||||
import classNames from '@/utils/classnames'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
|
||||
init({ data })
|
||||
|
||||
export type AnswerIconProps = {
|
||||
iconType?: AppIconType | null
|
||||
icon?: string | null
|
||||
background?: string | null
|
||||
imageUrl?: string | null
|
||||
}
|
||||
|
||||
const AnswerIcon: FC<AnswerIconProps> = ({
|
||||
iconType,
|
||||
icon,
|
||||
background,
|
||||
imageUrl,
|
||||
}) => {
|
||||
const wrapperClassName = classNames(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'w-full',
|
||||
'h-full',
|
||||
'rounded-full',
|
||||
'border-[0.5px]',
|
||||
'border-black/5',
|
||||
'text-xl',
|
||||
)
|
||||
const isValidImageIcon = iconType === 'image' && imageUrl
|
||||
return <div
|
||||
className={wrapperClassName}
|
||||
style={{ background: background || '#D5F5F6' }}
|
||||
>
|
||||
{isValidImageIcon
|
||||
? <img src={imageUrl} className="w-full h-full rounded-full" alt="answer icon" />
|
||||
: (icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default AnswerIcon
|
||||
71
app/components/base/app-icon/index.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { init } from 'emoji-mart'
|
||||
import data from '@emoji-mart/data'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
init({ data })
|
||||
|
||||
export type AppIconProps = {
|
||||
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large' | 'xl' | 'xxl'
|
||||
rounded?: boolean
|
||||
iconType?: AppIconType | null
|
||||
icon?: string
|
||||
background?: string | null
|
||||
imageUrl?: string | null
|
||||
className?: string
|
||||
innerIcon?: React.ReactNode
|
||||
onClick?: () => void
|
||||
}
|
||||
const appIconVariants = cva(
|
||||
'flex items-center justify-center relative text-lg rounded-lg grow-0 shrink-0 overflow-hidden leading-none',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'w-4 h-4 text-xs',
|
||||
tiny: 'w-6 h-6 text-base',
|
||||
small: 'w-8 h-8 text-xl',
|
||||
medium: 'w-9 h-9 text-[22px]',
|
||||
large: 'w-10 h-10 text-[24px]',
|
||||
xl: 'w-12 h-12 text-[28px]',
|
||||
xxl: 'w-14 h-14 text-[32px]',
|
||||
},
|
||||
rounded: {
|
||||
true: 'rounded-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
rounded: false,
|
||||
},
|
||||
})
|
||||
const AppIcon: FC<AppIconProps> = ({
|
||||
size = 'medium',
|
||||
rounded = false,
|
||||
iconType,
|
||||
icon,
|
||||
background,
|
||||
imageUrl,
|
||||
className,
|
||||
innerIcon,
|
||||
onClick,
|
||||
}) => {
|
||||
const isValidImageIcon = iconType === 'image' && imageUrl
|
||||
|
||||
return <span
|
||||
className={classNames(appIconVariants({ size, rounded }), className)}
|
||||
style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isValidImageIcon
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
? <img src={imageUrl} className="w-full h-full" alt="app icon" />
|
||||
: (innerIcon || ((icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />))
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
export default AppIcon
|
||||
23
app/components/base/app-icon/style.module.css
Normal file
@ -0,0 +1,23 @@
|
||||
.appIcon {
|
||||
@apply flex items-center justify-center relative w-9 h-9 text-lg 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.xs {
|
||||
@apply w-5 h-5 text-base;
|
||||
}
|
||||
|
||||
.appIcon.rounded {
|
||||
@apply rounded-full;
|
||||
}
|
||||
29
app/components/base/app-unavailable.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type IAppUnavailableProps = {
|
||||
code?: number
|
||||
isUnknownReason?: boolean
|
||||
unknownReason?: string
|
||||
}
|
||||
|
||||
const AppUnavailable: FC<IAppUnavailableProps> = ({
|
||||
code = 404,
|
||||
isUnknownReason,
|
||||
unknownReason,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
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)',
|
||||
}}>{code}</h1>
|
||||
<div className='text-sm'>{unknownReason || (isUnknownReason ? t('share.common.appUnknownError') : t('share.common.appUnavailable'))}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AppUnavailable)
|
||||
54
app/components/base/audio-btn/audio.player.manager.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import AudioPlayer from '@/app/components/base/audio-btn/audio'
|
||||
declare global {
|
||||
// eslint-disable-next-line ts/consistent-type-definitions
|
||||
interface AudioPlayerManager {
|
||||
instance: AudioPlayerManager
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AudioPlayerManager {
|
||||
private static instance: AudioPlayerManager
|
||||
private audioPlayers: AudioPlayer | null = null
|
||||
private msgId: string | undefined
|
||||
|
||||
// eslint-disable-next-line
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
public static getInstance(): AudioPlayerManager {
|
||||
if (!AudioPlayerManager.instance) {
|
||||
AudioPlayerManager.instance = new AudioPlayerManager()
|
||||
this.instance = AudioPlayerManager.instance
|
||||
}
|
||||
|
||||
return AudioPlayerManager.instance
|
||||
}
|
||||
|
||||
public getAudioPlayer(url: string, isPublic: boolean, id: string | undefined, msgContent: string | null | undefined, voice: string | undefined, callback: ((event: string) => {}) | null): AudioPlayer {
|
||||
if (this.msgId && this.msgId === id && this.audioPlayers) {
|
||||
this.audioPlayers.setCallback(callback)
|
||||
return this.audioPlayers
|
||||
}
|
||||
else {
|
||||
if (this.audioPlayers) {
|
||||
try {
|
||||
this.audioPlayers.pauseAudio()
|
||||
this.audioPlayers.cacheBuffers = []
|
||||
this.audioPlayers.sourceBuffer?.abort()
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
this.msgId = id
|
||||
this.audioPlayers = new AudioPlayer(url, isPublic, id, msgContent, voice, callback)
|
||||
return this.audioPlayers
|
||||
}
|
||||
}
|
||||
|
||||
public resetMsgId(msgId: string) {
|
||||
this.msgId = msgId
|
||||
this.audioPlayers?.resetMsgId(msgId)
|
||||
}
|
||||
}
|
||||
252
app/components/base/audio-btn/audio.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import Toast from '@/app/components/base/toast'
|
||||
// TODO Mars
|
||||
// import { textToAudioStream } from '@/service/share'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line ts/consistent-type-definitions
|
||||
interface Window {
|
||||
ManagedMediaSource: any
|
||||
}
|
||||
}
|
||||
|
||||
export default class AudioPlayer {
|
||||
mediaSource: MediaSource | null
|
||||
audio: HTMLAudioElement
|
||||
audioContext: AudioContext
|
||||
sourceBuffer?: any
|
||||
cacheBuffers: ArrayBuffer[] = []
|
||||
pauseTimer: number | null = null
|
||||
msgId: string | undefined
|
||||
msgContent: string | null | undefined = null
|
||||
voice: string | undefined = undefined
|
||||
isLoadData = false
|
||||
url: string
|
||||
isPublic: boolean
|
||||
callback: ((event: string) => {}) | null
|
||||
|
||||
constructor(streamUrl: string, isPublic: boolean, msgId: string | undefined, msgContent: string | null | undefined, voice: string | undefined, callback: ((event: string) => {}) | null) {
|
||||
this.audioContext = new AudioContext()
|
||||
this.msgId = msgId
|
||||
this.msgContent = msgContent
|
||||
this.url = streamUrl
|
||||
this.isPublic = isPublic
|
||||
this.voice = voice
|
||||
this.callback = callback
|
||||
|
||||
// Compatible with iphone ios17 ManagedMediaSource
|
||||
const MediaSource = window.ManagedMediaSource || window.MediaSource
|
||||
if (!MediaSource) {
|
||||
Toast.notify({
|
||||
message: 'Your browser does not support audio streaming, if you are using an iPhone, please update to iOS 17.1 or later.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
this.mediaSource = MediaSource ? new MediaSource() : null
|
||||
this.audio = new Audio()
|
||||
this.setCallback(callback)
|
||||
if (!window.MediaSource) { // if use ManagedMediaSource
|
||||
this.audio.disableRemotePlayback = true
|
||||
this.audio.controls = true
|
||||
}
|
||||
this.audio.src = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
|
||||
this.audio.autoplay = true
|
||||
|
||||
const source = this.audioContext.createMediaElementSource(this.audio)
|
||||
source.connect(this.audioContext.destination)
|
||||
this.listenMediaSource('audio/mpeg')
|
||||
}
|
||||
|
||||
public resetMsgId(msgId: string) {
|
||||
this.msgId = msgId
|
||||
}
|
||||
|
||||
private listenMediaSource(contentType: string) {
|
||||
this.mediaSource?.addEventListener('sourceopen', () => {
|
||||
if (this.sourceBuffer)
|
||||
return
|
||||
|
||||
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
|
||||
})
|
||||
}
|
||||
|
||||
public setCallback(callback: ((event: string) => {}) | null) {
|
||||
this.callback = callback
|
||||
if (callback) {
|
||||
this.audio.addEventListener('ended', () => {
|
||||
callback('ended')
|
||||
}, false)
|
||||
this.audio.addEventListener('paused', () => {
|
||||
callback('paused')
|
||||
}, true)
|
||||
this.audio.addEventListener('loaded', () => {
|
||||
callback('loaded')
|
||||
}, true)
|
||||
this.audio.addEventListener('play', () => {
|
||||
callback('play')
|
||||
}, true)
|
||||
this.audio.addEventListener('timeupdate', () => {
|
||||
callback('timeupdate')
|
||||
}, true)
|
||||
this.audio.addEventListener('loadeddate', () => {
|
||||
callback('loadeddate')
|
||||
}, true)
|
||||
this.audio.addEventListener('canplay', () => {
|
||||
callback('canplay')
|
||||
}, true)
|
||||
this.audio.addEventListener('error', () => {
|
||||
callback('error')
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAudio() {
|
||||
try {
|
||||
const audioResponse: any = await textToAudioStream(this.url, this.isPublic, { content_type: 'audio/mpeg' }, {
|
||||
message_id: this.msgId,
|
||||
streaming: true,
|
||||
voice: this.voice,
|
||||
text: this.msgContent,
|
||||
})
|
||||
|
||||
if (audioResponse.status !== 200) {
|
||||
this.isLoadData = false
|
||||
if (this.callback)
|
||||
this.callback('error')
|
||||
}
|
||||
|
||||
const reader = audioResponse.body.getReader()
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
this.receiveAudioData(value)
|
||||
break
|
||||
}
|
||||
|
||||
this.receiveAudioData(value)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
this.isLoadData = false
|
||||
this.callback && this.callback('error')
|
||||
}
|
||||
}
|
||||
|
||||
// play audio
|
||||
public playAudio() {
|
||||
if (this.isLoadData) {
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback && this.callback('play')
|
||||
})
|
||||
}
|
||||
else if (this.audio.ended) {
|
||||
this.audio.play()
|
||||
this.callback && this.callback('play')
|
||||
}
|
||||
if (this.callback)
|
||||
this.callback('play')
|
||||
}
|
||||
else {
|
||||
this.isLoadData = true
|
||||
this.loadAudio()
|
||||
}
|
||||
}
|
||||
|
||||
private theEndOfStream() {
|
||||
const endTimer = setInterval(() => {
|
||||
if (!this.sourceBuffer?.updating) {
|
||||
this.mediaSource?.endOfStream()
|
||||
clearInterval(endTimer)
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
|
||||
private finishStream() {
|
||||
const timer = setInterval(() => {
|
||||
if (!this.cacheBuffers.length) {
|
||||
this.theEndOfStream()
|
||||
clearInterval(timer)
|
||||
}
|
||||
|
||||
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
|
||||
const arrayBuffer = this.cacheBuffers.shift()!
|
||||
this.sourceBuffer?.appendBuffer(arrayBuffer)
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
|
||||
public async playAudioWithAudio(audio: string, play = true) {
|
||||
if (!audio || !audio.length) {
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
|
||||
const audioContent = Buffer.from(audio, 'base64')
|
||||
this.receiveAudioData(new Uint8Array(audioContent))
|
||||
if (play) {
|
||||
this.isLoadData = true
|
||||
if (this.audio.paused) {
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback && this.callback('play')
|
||||
})
|
||||
}
|
||||
else if (this.audio.ended) {
|
||||
this.audio.play()
|
||||
this.callback && this.callback('play')
|
||||
}
|
||||
else if (this.audio.played) { /* empty */ }
|
||||
|
||||
else {
|
||||
this.audio.play()
|
||||
this.callback && this.callback('play')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public pauseAudio() {
|
||||
this.callback && this.callback('paused')
|
||||
this.audio.pause()
|
||||
this.audioContext.suspend()
|
||||
}
|
||||
|
||||
private cancer() {
|
||||
|
||||
}
|
||||
|
||||
private receiveAudioData(unit8Array: Uint8Array) {
|
||||
if (!unit8Array) {
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
const audioData = this.byteArrayToArrayBuffer(unit8Array)
|
||||
if (!audioData.byteLength) {
|
||||
if (this.mediaSource?.readyState === 'open')
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.sourceBuffer?.updating) {
|
||||
this.cacheBuffers.push(audioData)
|
||||
}
|
||||
else {
|
||||
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
|
||||
this.cacheBuffers.push(audioData)
|
||||
const cacheBuffer = this.cacheBuffers.shift()!
|
||||
this.sourceBuffer?.appendBuffer(cacheBuffer)
|
||||
}
|
||||
else {
|
||||
this.sourceBuffer?.appendBuffer(audioData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byteArrayToArrayBuffer(byteArray: Uint8Array): ArrayBuffer {
|
||||
const arrayBuffer = new ArrayBuffer(byteArray.length)
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
uint8Array.set(byteArray)
|
||||
return arrayBuffer
|
||||
}
|
||||
}
|
||||
110
app/components/base/audio-btn/index.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import s from './style.module.css'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
|
||||
interface AudioBtnProps {
|
||||
id?: string
|
||||
voice?: string
|
||||
value?: string
|
||||
className?: string
|
||||
isAudition?: boolean
|
||||
noCache?: boolean
|
||||
}
|
||||
|
||||
type AudioState = 'initial' | 'loading' | 'playing' | 'paused' | 'ended'
|
||||
|
||||
const AudioBtn = ({
|
||||
id,
|
||||
voice,
|
||||
value,
|
||||
className,
|
||||
isAudition,
|
||||
}: AudioBtnProps) => {
|
||||
const [audioState, setAudioState] = useState<AudioState>('initial')
|
||||
|
||||
const params = useParams()
|
||||
const pathname = usePathname()
|
||||
const audio_finished_call = (event: string): any => {
|
||||
switch (event) {
|
||||
case 'ended':
|
||||
setAudioState('ended')
|
||||
break
|
||||
case 'paused':
|
||||
setAudioState('ended')
|
||||
break
|
||||
case 'loaded':
|
||||
setAudioState('loading')
|
||||
break
|
||||
case 'play':
|
||||
setAudioState('playing')
|
||||
break
|
||||
case 'error':
|
||||
setAudioState('ended')
|
||||
break
|
||||
}
|
||||
}
|
||||
let url = ''
|
||||
let isPublic = false
|
||||
|
||||
if (params.token) {
|
||||
url = '/text-to-audio'
|
||||
isPublic = true
|
||||
}
|
||||
else if (params.appId) {
|
||||
if (pathname.search('explore/installed') > -1)
|
||||
url = `/installed-apps/${params.appId}/text-to-audio`
|
||||
else
|
||||
url = `/apps/${params.appId}/text-to-audio`
|
||||
}
|
||||
const handleToggle = async () => {
|
||||
if (audioState === 'playing' || audioState === 'loading') {
|
||||
setTimeout(() => setAudioState('paused'), 1)
|
||||
AudioPlayerManager.getInstance().getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call).pauseAudio()
|
||||
}
|
||||
else {
|
||||
setTimeout(() => setAudioState('loading'), 1)
|
||||
AudioPlayerManager.getInstance().getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call).playAudio()
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipContent = {
|
||||
initial: t('appApi.play'),
|
||||
ended: t('appApi.play'),
|
||||
paused: t('appApi.pause'),
|
||||
playing: t('appApi.playing'),
|
||||
loading: t('appApi.loading'),
|
||||
}[audioState]
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center justify-center ${(audioState === 'loading' || audioState === 'playing') ? 'mr-1' : className}`}>
|
||||
<Tooltip
|
||||
popupContent={tooltipContent}
|
||||
>
|
||||
<button
|
||||
disabled={audioState === 'loading'}
|
||||
className={`box-border w-6 h-6 flex items-center justify-center cursor-pointer ${isAudition ? 'p-0.5' : 'p-0 rounded-md bg-white'}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{audioState === 'loading'
|
||||
? (
|
||||
<div className='w-full h-full rounded-md flex items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={`w-full h-full rounded-md flex items-center justify-center ${!isAudition ? 'hover:bg-gray-50' : 'hover:bg-gray-50'}`}>
|
||||
<div className={`w-4 h-4 ${(audioState === 'playing') ? s.pauseIcon : s.playIcon}`}></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioBtn
|
||||
10
app/components/base/audio-btn/style.module.css
Normal file
@ -0,0 +1,10 @@
|
||||
.playIcon {
|
||||
background-image: url(~@/app/components/develop/secret-key/assets/play.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.pauseIcon {
|
||||
background-image: url(~@/app/components/develop/secret-key/assets/pause.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
117
app/components/base/audio-gallery/AudioPlayer.module.css
Normal file
@ -0,0 +1,117 @@
|
||||
.audioPlayer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--color-components-chat-input-audio-bg-alt);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
min-width: 240px;
|
||||
max-width: 420px;
|
||||
max-height: 40px;
|
||||
backdrop-filter: blur(5px);
|
||||
border: 1px solid var(--color-components-panel-border-subtle);
|
||||
box-shadow: 0 1px 2px var(--color-shadow-shadow-3);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playButton {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-components-button-primary-bg);
|
||||
color: var(--color-components-chat-input-audio-bg-alt);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playButton:hover {
|
||||
background-color: var(--color-components-button-primary-bg-hover);
|
||||
}
|
||||
|
||||
.playButton:disabled {
|
||||
background-color: var(--color-components-button-primary-bg-disabled);
|
||||
}
|
||||
|
||||
.audioControls {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.progressBarContainer {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waveform {
|
||||
position: relative;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0.5;
|
||||
border-radius: 2px;
|
||||
flex: none;
|
||||
order: 55;
|
||||
flex-grow: 0;
|
||||
height: 100%;
|
||||
background-color: rgba(66, 133, 244, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeDisplay {
|
||||
/* position: absolute; */
|
||||
color: var(--color-text-accent-secondary);
|
||||
font-size: 12px;
|
||||
order: 0;
|
||||
height: 100%;
|
||||
width: 50px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* .currentTime {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 5px);
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(255,255,255,.8);
|
||||
padding: 2px 4px;
|
||||
border-radius:10px;
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.08);
|
||||
} */
|
||||
|
||||
.duration {
|
||||
padding: 2px 4px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.source_unavailable {
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
color: #bdbdbf;
|
||||
}
|
||||
|
||||
.playButton svg path,
|
||||
.playButton svg rect {
|
||||
fill: currentColor;
|
||||
}
|
||||
320
app/components/base/audio-gallery/AudioPlayer.tsx
Normal file
@ -0,0 +1,320 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import styles from './AudioPlayer.module.css'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
type AudioPlayerProps = {
|
||||
src: string
|
||||
}
|
||||
|
||||
const AudioPlayer: React.FC<AudioPlayerProps> = ({ src }) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [waveformData, setWaveformData] = useState<number[]>([])
|
||||
const [bufferedTime, setBufferedTime] = useState(0)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [hasStartedPlaying, setHasStartedPlaying] = useState(false)
|
||||
const [hoverTime, setHoverTime] = useState(0)
|
||||
const [isAudioAvailable, setIsAudioAvailable] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio)
|
||||
return
|
||||
|
||||
const handleError = () => {
|
||||
setIsAudioAvailable(false)
|
||||
}
|
||||
|
||||
const setAudioData = () => {
|
||||
setDuration(audio.duration)
|
||||
}
|
||||
|
||||
const setAudioTime = () => {
|
||||
setCurrentTime(audio.currentTime)
|
||||
}
|
||||
|
||||
const handleProgress = () => {
|
||||
if (audio.buffered.length > 0)
|
||||
setBufferedTime(audio.buffered.end(audio.buffered.length - 1))
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
|
||||
audio.addEventListener('loadedmetadata', setAudioData)
|
||||
audio.addEventListener('timeupdate', setAudioTime)
|
||||
audio.addEventListener('progress', handleProgress)
|
||||
audio.addEventListener('ended', handleEnded)
|
||||
audio.addEventListener('error', handleError)
|
||||
|
||||
// Preload audio metadata
|
||||
audio.load()
|
||||
|
||||
// Delayed generation of waveform data
|
||||
// eslint-disable-next-line ts/no-use-before-define
|
||||
const timer = setTimeout(() => generateWaveformData(src), 1000)
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('loadedmetadata', setAudioData)
|
||||
audio.removeEventListener('timeupdate', setAudioTime)
|
||||
audio.removeEventListener('progress', handleProgress)
|
||||
audio.removeEventListener('ended', handleEnded)
|
||||
audio.removeEventListener('error', handleError)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [src])
|
||||
|
||||
const generateWaveformData = async (audioSrc: string) => {
|
||||
if (!window.AudioContext && !(window as any).webkitAudioContext) {
|
||||
setIsAudioAvailable(false)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Web Audio API is not supported in this browser',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const url = new URL(src)
|
||||
const isHttp = url.protocol === 'http:' || url.protocol === 'https:'
|
||||
if (!isHttp) {
|
||||
setIsAudioAvailable(false)
|
||||
return null
|
||||
}
|
||||
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const samples = 70
|
||||
|
||||
try {
|
||||
const response = await fetch(audioSrc, { mode: 'cors' })
|
||||
if (!response || !response.ok) {
|
||||
setIsAudioAvailable(false)
|
||||
return null
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
||||
const channelData = audioBuffer.getChannelData(0)
|
||||
const blockSize = Math.floor(channelData.length / samples)
|
||||
const waveformData: number[] = []
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
let sum = 0
|
||||
for (let j = 0; j < blockSize; j++)
|
||||
sum += Math.abs(channelData[i * blockSize + j])
|
||||
|
||||
// Apply nonlinear scaling to enhance small amplitudes
|
||||
waveformData.push((sum / blockSize) * 5)
|
||||
}
|
||||
|
||||
// Normalized waveform data
|
||||
const maxAmplitude = Math.max(...waveformData)
|
||||
const normalizedWaveform = waveformData.map(amp => amp / maxAmplitude)
|
||||
|
||||
setWaveformData(normalizedWaveform)
|
||||
setIsAudioAvailable(true)
|
||||
}
|
||||
catch (error) {
|
||||
const waveform: number[] = []
|
||||
let prevValue = Math.random()
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const targetValue = Math.random()
|
||||
const interpolatedValue = prevValue + (targetValue - prevValue) * 0.3
|
||||
waveform.push(interpolatedValue)
|
||||
prevValue = interpolatedValue
|
||||
}
|
||||
|
||||
const maxAmplitude = Math.max(...waveform)
|
||||
const randomWaveform = waveform.map(amp => amp / maxAmplitude)
|
||||
|
||||
setWaveformData(randomWaveform)
|
||||
setIsAudioAvailable(true)
|
||||
}
|
||||
finally {
|
||||
await audioContext.close()
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio && isAudioAvailable) {
|
||||
if (isPlaying) {
|
||||
setHasStartedPlaying(false)
|
||||
audio.pause()
|
||||
}
|
||||
else {
|
||||
setHasStartedPlaying(true)
|
||||
audio.play().catch(error => console.error('Error playing audio:', error))
|
||||
}
|
||||
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Audio element not found',
|
||||
})
|
||||
setIsAudioAvailable(false)
|
||||
}
|
||||
}, [isAudioAvailable, isPlaying])
|
||||
|
||||
const handleCanvasInteraction = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const getClientX = (event: React.MouseEvent | React.TouchEvent): number => {
|
||||
if ('touches' in event)
|
||||
return event.touches[0].clientX
|
||||
return event.clientX
|
||||
}
|
||||
|
||||
const updateProgress = (clientX: number) => {
|
||||
const canvas = canvasRef.current
|
||||
const audio = audioRef.current
|
||||
if (!canvas || !audio)
|
||||
return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width
|
||||
const newTime = percent * duration
|
||||
|
||||
// Removes the buffer check, allowing drag to any location
|
||||
audio.currentTime = newTime
|
||||
setCurrentTime(newTime)
|
||||
|
||||
if (!isPlaying) {
|
||||
setIsPlaying(true)
|
||||
audio.play().catch((error) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: `Error playing audio: ${error}`,
|
||||
})
|
||||
setIsPlaying(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updateProgress(getClientX(e))
|
||||
}, [duration, isPlaying])
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const drawWaveform = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas)
|
||||
return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx)
|
||||
return
|
||||
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
const data = waveformData
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const barWidth = width / data.length
|
||||
const playedWidth = (currentTime / duration) * width
|
||||
const cornerRadius = 2
|
||||
|
||||
// Draw waveform bars
|
||||
data.forEach((value, index) => {
|
||||
let color
|
||||
|
||||
if (index * barWidth <= playedWidth)
|
||||
color = '#296DFF'
|
||||
else if ((index * barWidth / width) * duration <= hoverTime)
|
||||
color = 'rgba(21,90,239,.40)'
|
||||
else
|
||||
color = 'rgba(21,90,239,.20)'
|
||||
|
||||
const barHeight = value * height
|
||||
const rectX = index * barWidth
|
||||
const rectY = (height - barHeight) / 2
|
||||
const rectWidth = barWidth * 0.5
|
||||
const rectHeight = barHeight
|
||||
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = color
|
||||
if (ctx.roundRect) {
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius)
|
||||
ctx.fill()
|
||||
}
|
||||
else {
|
||||
ctx.fillRect(rectX, rectY, rectWidth, rectHeight)
|
||||
}
|
||||
})
|
||||
}, [currentTime, duration, hoverTime, waveformData])
|
||||
|
||||
useEffect(() => {
|
||||
drawWaveform()
|
||||
}, [drawWaveform, bufferedTime, hasStartedPlaying])
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const canvas = canvasRef.current
|
||||
const audio = audioRef.current
|
||||
if (!canvas || !audio)
|
||||
return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const percent = Math.min(Math.max(0, e.clientX - rect.left), rect.width) / rect.width
|
||||
const time = percent * duration
|
||||
|
||||
// Check if the hovered position is within a buffered range before updating hoverTime
|
||||
for (let i = 0; i < audio.buffered.length; i++) {
|
||||
if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {
|
||||
setHoverTime(time)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [duration])
|
||||
|
||||
return (
|
||||
<div className={styles.audioPlayer}>
|
||||
<audio ref={audioRef} src={src} preload="auto"/>
|
||||
<button className={styles.playButton} onClick={togglePlay} disabled={!isAudioAvailable}>
|
||||
{isPlaying
|
||||
? (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<rect x="7" y="6" width="3" height="12" rx="1.5" ry="1.5"/>
|
||||
<rect x="15" y="6" width="3" height="12" rx="1.5" ry="1.5"/>
|
||||
</svg>
|
||||
)
|
||||
: (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path d="M8 5v14l11-7z" fill="currentColor"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className={isAudioAvailable ? styles.audioControls : styles.audioControls_disabled} hidden={!isAudioAvailable}>
|
||||
<div className={styles.progressBarContainer}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={styles.waveform}
|
||||
onClick={handleCanvasInteraction}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleCanvasInteraction}
|
||||
/>
|
||||
{/* <div className={styles.currentTime} style={{ left: `${(currentTime / duration) * 81}%`, bottom: '29px' }}>
|
||||
{formatTime(currentTime)}
|
||||
</div> */}
|
||||
<div className={styles.timeDisplay}>
|
||||
<span className={styles.duration}>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.source_unavailable} hidden={isAudioAvailable}>{t('common.operation.audioSourceUnavailable')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioPlayer
|
||||
12
app/components/base/audio-gallery/index.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import AudioPlayer from './AudioPlayer'
|
||||
|
||||
type Props = {
|
||||
srcs: string[]
|
||||
}
|
||||
|
||||
const AudioGallery: React.FC<Props> = ({ srcs }) => {
|
||||
return (<><br/>{srcs.map((src, index) => (<AudioPlayer key={`audio_${index}`} src={src}/>))}</>)
|
||||
}
|
||||
|
||||
export default React.memo(AudioGallery)
|
||||
74
app/components/base/auto-height-textarea/index.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
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,
|
||||
) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
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
|
||||
28
app/components/base/badge/index.css
Normal file
@ -0,0 +1,28 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.badge {
|
||||
@apply inline-flex justify-center items-center text-text-tertiary border border-divider-deep
|
||||
}
|
||||
|
||||
.badge-l {
|
||||
@apply rounded-md gap-1 min-w-6
|
||||
}
|
||||
|
||||
/* m is for the regular button */
|
||||
.badge-m {
|
||||
@apply rounded-md gap-[3px] min-w-5
|
||||
}
|
||||
|
||||
.badge-s {
|
||||
@apply rounded-[5px] gap-0.5 min-w-[18px]
|
||||
}
|
||||
|
||||
.badge.badge-warning {
|
||||
@apply text-text-warning border border-text-warning
|
||||
}
|
||||
|
||||
.badge.badge-accent {
|
||||
@apply text-text-accent-secondary border border-text-accent-secondary
|
||||
}
|
||||
}
|
||||
81
app/components/base/badge/index.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import classNames from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
enum BadgeState {
|
||||
Warning = 'warning',
|
||||
Accent = 'accent',
|
||||
Default = '',
|
||||
}
|
||||
|
||||
const BadgeVariants = cva(
|
||||
'badge',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
s: 'badge-s',
|
||||
m: 'badge-m',
|
||||
l: 'badge-l',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'm',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type BadgeProps = {
|
||||
size?: 's' | 'm' | 'l'
|
||||
iconOnly?: boolean
|
||||
uppercase?: boolean
|
||||
state?: BadgeState
|
||||
styleCss?: CSSProperties
|
||||
children?: ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof BadgeVariants>
|
||||
|
||||
function getBadgeState(state: BadgeState) {
|
||||
switch (state) {
|
||||
case BadgeState.Warning:
|
||||
return 'badge-warning'
|
||||
case BadgeState.Accent:
|
||||
return 'badge-accent'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const Badge: React.FC<BadgeProps> = ({
|
||||
className,
|
||||
size,
|
||||
state = BadgeState.Default,
|
||||
iconOnly = false,
|
||||
uppercase = false,
|
||||
styleCss,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
BadgeVariants({ size, className }),
|
||||
getBadgeState(state),
|
||||
size === 's'
|
||||
? (iconOnly ? 'p-[3px]' : 'px-[5px] py-[3px]')
|
||||
: size === 'l'
|
||||
? (iconOnly ? 'p-1.5' : 'px-2 py-1')
|
||||
: (iconOnly ? 'p-1' : 'px-[5px] py-[2px]'),
|
||||
uppercase ? 'system-2xs-medium-uppercase' : 'system-2xs-medium',
|
||||
)}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Badge.displayName = 'Badge'
|
||||
|
||||
export default Badge
|
||||
export { Badge, BadgeState, BadgeVariants }
|
||||
22
app/components/base/button/add-button.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const AddButton: FC<Props> = ({
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'p-1 rounded-md cursor-pointer hover:bg-state-base-hover select-none')} onClick={onClick}>
|
||||
<RiAddLine className='w-4 h-4 text-text-tertiary' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AddButton)
|
||||
112
app/components/base/button/index.css
Normal file
@ -0,0 +1,112 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex justify-center items-center cursor-pointer whitespace-nowrap;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
@apply px-2 h-6 rounded-md text-xs font-medium;
|
||||
}
|
||||
|
||||
.btn-medium {
|
||||
@apply px-3.5 h-8 rounded-lg text-[13px] leading-4 font-medium;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
@apply px-4 h-9 rounded-[10px] text-sm font-semibold;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply shadow bg-components-button-primary-bg border-components-button-primary-border hover:bg-components-button-primary-bg-hover hover:border-components-button-primary-border-hover text-components-button-primary-text;
|
||||
}
|
||||
|
||||
.btn-primary.btn-destructive {
|
||||
@apply bg-components-button-destructive-primary-bg border-components-button-destructive-primary-border hover:bg-components-button-destructive-primary-bg-hover hover:border-components-button-destructive-primary-border-hover text-components-button-destructive-primary-text;
|
||||
}
|
||||
|
||||
.btn-primary.btn-disabled {
|
||||
@apply shadow-none bg-components-button-primary-bg-disabled border-components-button-primary-border-disabled text-components-button-primary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-primary.btn-destructive.btn-disabled {
|
||||
@apply shadow-none bg-components-button-destructive-primary-bg-disabled border-components-button-destructive-primary-border-disabled text-components-button-destructive-primary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border-[0.5px] shadow-xs bg-components-button-secondary-bg border-components-button-secondary-border hover:bg-components-button-secondary-bg-hover hover:border-components-button-secondary-border-hover text-components-button-secondary-text;
|
||||
}
|
||||
|
||||
.btn-secondary.btn-disabled {
|
||||
@apply bg-components-button-secondary-bg-disabled border-components-button-secondary-border-disabled text-components-button-secondary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-secondary.btn-destructive {
|
||||
@apply bg-components-button-destructive-secondary-bg border-components-button-destructive-secondary-border hover:bg-components-button-destructive-secondary-bg-hover hover:border-components-button-destructive-secondary-border-hover text-components-button-destructive-secondary-text;
|
||||
}
|
||||
|
||||
.btn-secondary.btn-destructive.btn-disabled {
|
||||
@apply bg-components-button-destructive-secondary-bg-disabled border-components-button-destructive-secondary-border-disabled text-components-button-destructive-secondary-text-disabled;
|
||||
}
|
||||
|
||||
|
||||
.btn-secondary-accent {
|
||||
@apply border-[0.5px] shadow-xs bg-components-button-secondary-bg border-components-button-secondary-border hover:bg-components-button-secondary-bg-hover hover:border-components-button-secondary-border-hover text-components-button-secondary-accent-text;
|
||||
}
|
||||
|
||||
.btn-secondary-accent.btn-disabled {
|
||||
@apply bg-components-button-secondary-bg-disabled border-components-button-secondary-border-disabled text-components-button-secondary-accent-text-disabled;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply bg-components-button-destructive-primary-bg border-components-button-destructive-primary-border hover:bg-components-button-destructive-primary-bg-hover hover:border-components-button-destructive-primary-border-hover text-components-button-destructive-primary-text;
|
||||
}
|
||||
|
||||
.btn-warning.btn-disabled {
|
||||
@apply bg-components-button-destructive-primary-bg-disabled border-components-button-destructive-primary-border-disabled text-components-button-destructive-primary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-tertiary {
|
||||
@apply bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover text-components-button-tertiary-text;
|
||||
}
|
||||
|
||||
.btn-tertiary.btn-disabled {
|
||||
@apply bg-components-button-tertiary-bg-disabled text-components-button-tertiary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-tertiary.btn-destructive {
|
||||
@apply bg-components-button-destructive-tertiary-bg hover:bg-components-button-destructive-tertiary-bg-hover text-components-button-destructive-tertiary-text;
|
||||
}
|
||||
|
||||
.btn-tertiary.btn-destructive.btn-disabled {
|
||||
@apply bg-components-button-destructive-tertiary-bg-disabled text-components-button-destructive-tertiary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply hover:bg-components-button-ghost-bg-hover text-components-button-ghost-text;
|
||||
}
|
||||
|
||||
.btn-ghost.btn-disabled {
|
||||
@apply text-components-button-ghost-text-disabled;
|
||||
}
|
||||
|
||||
.btn-ghost.btn-destructive {
|
||||
@apply hover:bg-components-button-destructive-ghost-bg-hover text-components-button-destructive-ghost-text;
|
||||
}
|
||||
|
||||
.btn-ghost.btn-destructive.btn-disabled {
|
||||
@apply text-components-button-destructive-ghost-text-disabled;
|
||||
}
|
||||
|
||||
.btn-ghost-accent {
|
||||
@apply hover:bg-state-accent-hover text-components-button-secondary-accent-text;
|
||||
}
|
||||
|
||||
.btn-ghost-accent.btn-disabled {
|
||||
@apply text-components-button-secondary-accent-text-disabled;
|
||||
}
|
||||
}
|
||||
60
app/components/base/button/index.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import React from 'react'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import Spinner from '../spinner'
|
||||
import classNames from '@/utils/classnames'
|
||||
const buttonVariants = cva(
|
||||
'btn disabled:btn-disabled',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
'primary': 'btn-primary',
|
||||
'warning': 'btn-warning',
|
||||
'secondary': 'btn-secondary',
|
||||
'secondary-accent': 'btn-secondary-accent',
|
||||
'ghost': 'btn-ghost',
|
||||
'ghost-accent': 'btn-ghost-accent',
|
||||
'tertiary': 'btn-tertiary',
|
||||
},
|
||||
size: {
|
||||
small: 'btn-small',
|
||||
medium: 'btn-medium',
|
||||
large: 'btn-large',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'secondary',
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonProps = {
|
||||
destructive?: boolean
|
||||
loading?: boolean
|
||||
styleCss?: CSSProperties
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, destructive, loading, styleCss, children, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
buttonVariants({ variant, size, className }),
|
||||
destructive && 'btn-destructive',
|
||||
)}
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{loading && <Spinner loading={loading} className='!text-white !h-3 !w-3 !border-2 !ml-1' />}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export default Button
|
||||
export { Button, buttonVariants }
|
||||
104
app/components/base/confirm/index.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '../button'
|
||||
|
||||
export type IConfirm = {
|
||||
className?: string
|
||||
isShow: boolean
|
||||
type?: 'info' | 'warning'
|
||||
title: string
|
||||
content?: React.ReactNode
|
||||
confirmText?: string | null
|
||||
onConfirm: () => void
|
||||
cancelText?: string
|
||||
onCancel: () => void
|
||||
isLoading?: boolean
|
||||
isDisabled?: boolean
|
||||
showConfirm?: boolean
|
||||
showCancel?: boolean
|
||||
maskClosable?: boolean
|
||||
}
|
||||
|
||||
function Confirm({
|
||||
isShow,
|
||||
type = 'warning',
|
||||
title,
|
||||
content,
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
showConfirm = true,
|
||||
showCancel = true,
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
maskClosable = true,
|
||||
}: IConfirm) {
|
||||
const { t } = useTranslation()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const [isVisible, setIsVisible] = useState(isShow)
|
||||
|
||||
const confirmTxt = confirmText || `${t('common.operation.confirm')}`
|
||||
const cancelTxt = cancelText || `${t('common.operation.cancel')}`
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape')
|
||||
onCancel()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (maskClosable && dialogRef.current && !dialogRef.current.contains(event.target as Node))
|
||||
onCancel()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [maskClosable])
|
||||
|
||||
useEffect(() => {
|
||||
if (isShow) {
|
||||
setIsVisible(true)
|
||||
}
|
||||
else {
|
||||
const timer = setTimeout(() => setIsVisible(false), 200)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isShow])
|
||||
|
||||
if (!isVisible)
|
||||
return null
|
||||
|
||||
return createPortal(
|
||||
<div className={'fixed inset-0 flex items-center justify-center z-[10000000] bg-background-overlay'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}>
|
||||
<div ref={dialogRef} className={'relative w-full max-w-[480px] overflow-hidden'}>
|
||||
<div className='flex flex-col items-start max-w-full rounded-2xl border-[0.5px] border-solid border-components-panel-border shadows-shadow-lg bg-components-panel-bg'>
|
||||
<div className='flex pt-6 pl-6 pr-6 pb-4 flex-col items-start gap-2 self-stretch'>
|
||||
<div className='title-2xl-semi-bold text-text-primary'>{title}</div>
|
||||
<div className='system-md-regular text-text-tertiary w-full'>{content}</div>
|
||||
</div>
|
||||
<div className='flex p-6 gap-2 justify-end items-start self-stretch'>
|
||||
{showCancel && <Button onClick={onCancel}>{cancelTxt}</Button>}
|
||||
{showConfirm && <Button variant={'primary'} destructive={type !== 'info'} loading={isLoading} disabled={isDisabled} onClick={onConfirm}>{confirmTxt}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>, document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Confirm)
|
||||
54
app/components/base/copy-btn/index.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import { debounce } from 'lodash-es'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import s from './style.module.css'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type ICopyBtnProps = {
|
||||
value: string
|
||||
className?: string
|
||||
isPlain?: boolean
|
||||
}
|
||||
|
||||
const CopyBtn = ({
|
||||
value,
|
||||
className,
|
||||
isPlain,
|
||||
}: ICopyBtnProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const onClickCopy = debounce(() => {
|
||||
copy(value)
|
||||
setIsCopied(true)
|
||||
}, 100)
|
||||
|
||||
const onMouseLeave = debounce(() => {
|
||||
setIsCopied(false)
|
||||
}, 100)
|
||||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<Tooltip
|
||||
popupContent={(isCopied ? t('appApi.copied') : t('appApi.copy'))}
|
||||
asChild={false}
|
||||
>
|
||||
<div
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={'box-border p-0.5 flex items-center justify-center rounded-md bg-components-button-secondary-bg cursor-pointer'}
|
||||
style={!isPlain
|
||||
? {
|
||||
boxShadow: '0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06)',
|
||||
}
|
||||
: {}}
|
||||
onClick={onClickCopy}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-md hover:bg-components-button-secondary-bg-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`}></div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyBtn
|
||||
15
app/components/base/copy-btn/style.module.css
Normal file
@ -0,0 +1,15 @@
|
||||
.copyIcon {
|
||||
background-image: url(~@/app/components/develop/secret-key/assets/copy.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.copyIcon:hover {
|
||||
background-image: url(~@/app/components/develop/secret-key/assets/copy-hover.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.copyIcon.copied {
|
||||
background-image: url(~@/app/components/develop/secret-key/assets/copied.svg);
|
||||
}
|
||||
91
app/components/base/copy-feedback/index.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiClipboardFill,
|
||||
RiClipboardLine,
|
||||
} from '@remixicon/react'
|
||||
import { debounce } from 'lodash-es'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import copyStyle from './style.module.css'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const prefixEmbedded = 'appOverview.overview.appInfo.embedded'
|
||||
|
||||
const CopyFeedback = ({ content }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false)
|
||||
|
||||
const onClickCopy = debounce(() => {
|
||||
copy(content)
|
||||
setIsCopied(true)
|
||||
}, 100)
|
||||
|
||||
const onMouseLeave = debounce(() => {
|
||||
setIsCopied(false)
|
||||
}, 100)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(isCopied
|
||||
? t(`${prefixEmbedded}.copied`)
|
||||
: t(`${prefixEmbedded}.copy`)) || ''
|
||||
}
|
||||
>
|
||||
<ActionButton>
|
||||
<div
|
||||
onClick={onClickCopy}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{isCopied && <RiClipboardFill className='w-4 h-4' />}
|
||||
{!isCopied && <RiClipboardLine className='w-4 h-4' />}
|
||||
</div>
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyFeedback
|
||||
|
||||
export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className' | 'content'>) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false)
|
||||
|
||||
const onClickCopy = debounce(() => {
|
||||
copy(content)
|
||||
setIsCopied(true)
|
||||
}, 100)
|
||||
|
||||
const onMouseLeave = debounce(() => {
|
||||
setIsCopied(false)
|
||||
}, 100)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(isCopied
|
||||
? t(`${prefixEmbedded}.copied`)
|
||||
: t(`${prefixEmbedded}.copy`)) || ''
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 cursor-pointer hover:bg-components-button-ghost-bg-hover rounded-lg ${className ?? ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={onClickCopy}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={`w-full h-full ${copyStyle.copyIcon} ${isCopied ? copyStyle.copied : ''
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
15
app/components/base/copy-feedback/style.module.css
Normal file
@ -0,0 +1,15 @@
|
||||
.copyIcon {
|
||||
background-image: url(~@/app/components/develop/secret-key/assets/copy.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.copyIcon:hover {
|
||||
background-image: url(~@/app/components/develop/secret-key/assets/copy-hover.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.copyIcon.copied {
|
||||
background-image: url(~@/app/components/develop/secret-key/assets/copied.svg);
|
||||
}
|
||||
53
app/components/base/copy-icon/index.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { debounce } from 'lodash-es'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import Tooltip from '../tooltip'
|
||||
import {
|
||||
Clipboard,
|
||||
ClipboardCheck,
|
||||
} from '@/app/components/base/icons/src/vender/line/files'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
}
|
||||
|
||||
const prefixEmbedded = 'appOverview.overview.appInfo.embedded'
|
||||
|
||||
export const CopyIcon = ({ content }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false)
|
||||
|
||||
const onClickCopy = debounce(() => {
|
||||
copy(content)
|
||||
setIsCopied(true)
|
||||
}, 100)
|
||||
|
||||
const onMouseLeave = debounce(() => {
|
||||
setIsCopied(false)
|
||||
}, 100)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(isCopied
|
||||
? t(`${prefixEmbedded}.copied`)
|
||||
: t(`${prefixEmbedded}.copy`)) || ''
|
||||
}
|
||||
>
|
||||
<div onMouseLeave={onMouseLeave}>
|
||||
{!isCopied
|
||||
? (
|
||||
<Clipboard className='mx-1 w-3.5 h-3.5 text-text-tertiary cursor-pointer' onClick={onClickCopy} />
|
||||
)
|
||||
: (
|
||||
<ClipboardCheck className='mx-1 w-3.5 h-3.5 text-text-tertiary' />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyIcon
|
||||
36
app/components/base/divider/index.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
import React from 'react'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
const dividerVariants = cva('',
|
||||
{
|
||||
variants: {
|
||||
type: {
|
||||
horizontal: 'w-full h-[0.5px] my-2 ',
|
||||
vertical: 'w-[1px] h-full mx-2',
|
||||
},
|
||||
bgStyle: {
|
||||
gradient: 'bg-gradient-to-r from-divider-regular to-background-gradient-mask-transparent',
|
||||
solid: 'bg-divider-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: 'horizontal',
|
||||
bgStyle: 'solid',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type DividerProps = {
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
} & VariantProps<typeof dividerVariants>
|
||||
|
||||
const Divider: FC<DividerProps> = ({ type, bgStyle, className = '', style }) => {
|
||||
return (
|
||||
<div className={classNames(dividerVariants({ type, bgStyle }), className)} style={style}></div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Divider
|
||||
23
app/components/base/divider/with-label.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import type { FC } from 'react'
|
||||
import type { DividerProps } from '.'
|
||||
import Divider from '.'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
export type DividerWithLabelProps = DividerProps & {
|
||||
label: string
|
||||
}
|
||||
|
||||
export const DividerWithLabel: FC<DividerWithLabelProps> = (props) => {
|
||||
const { label, className, ...rest } = props
|
||||
return <div
|
||||
className="flex items-center gap-2 my-2"
|
||||
>
|
||||
<Divider {...rest} className={classNames('flex-1', className)} />
|
||||
<span className="text-text-tertiary text-xs">
|
||||
{label}
|
||||
</span>
|
||||
<Divider {...rest} className={classNames('flex-1', className)} />
|
||||
</div>
|
||||
}
|
||||
|
||||
export default DividerWithLabel
|
||||
@ -0,0 +1,136 @@
|
||||
// 'use client'
|
||||
// import type { FC } from 'react'
|
||||
// import React, { useRef, useState } from 'react'
|
||||
// import { useHover } from 'ahooks'
|
||||
// import { useTranslation } from 'react-i18next'
|
||||
// import cn from '@/utils/classnames'
|
||||
// import { MessageCheckRemove, MessageFastPlus } from '@/app/components/base/icons/src/vender/line/communication'
|
||||
// import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
// import { Edit04 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
// import RemoveAnnotationConfirmModal from '@/app/components/app/annotation/remove-annotation-confirm-modal'
|
||||
// import Tooltip from '@/app/components/base/tooltip'
|
||||
// import { addAnnotation, delAnnotation } from '@/service/annotation'
|
||||
// import Toast from '@/app/components/base/toast'
|
||||
// import { useProviderContext } from '@/context/provider-context'
|
||||
// import { useModalContext } from '@/context/modal-context'
|
||||
|
||||
// type Props = {
|
||||
// appId: string
|
||||
// messageId?: string
|
||||
// annotationId?: string
|
||||
// className?: string
|
||||
// cached: boolean
|
||||
// query: string
|
||||
// answer: string
|
||||
// onAdded: (annotationId: string, authorName: string) => void
|
||||
// onEdit: () => void
|
||||
// onRemoved: () => void
|
||||
// }
|
||||
|
||||
// const CacheCtrlBtn: FC<Props> = ({
|
||||
// className,
|
||||
// cached,
|
||||
// query,
|
||||
// answer,
|
||||
// appId,
|
||||
// messageId,
|
||||
// annotationId,
|
||||
// onAdded,
|
||||
// onEdit,
|
||||
// onRemoved,
|
||||
// }) => {
|
||||
// const { t } = useTranslation()
|
||||
// const { plan, enableBilling } = useProviderContext()
|
||||
// const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
|
||||
// const { setShowAnnotationFullModal } = useModalContext()
|
||||
// const [showModal, setShowModal] = useState(false)
|
||||
// const cachedBtnRef = useRef<HTMLDivElement>(null)
|
||||
// const isCachedBtnHovering = useHover(cachedBtnRef)
|
||||
// const handleAdd = async () => {
|
||||
// if (isAnnotationFull) {
|
||||
// setShowAnnotationFullModal()
|
||||
// return
|
||||
// }
|
||||
// const res: any = await addAnnotation(appId, {
|
||||
// message_id: messageId,
|
||||
// question: query,
|
||||
// answer,
|
||||
// })
|
||||
// Toast.notify({
|
||||
// message: t('common.api.actionSuccess') as string,
|
||||
// type: 'success',
|
||||
// })
|
||||
// onAdded(res.id, res.account?.name)
|
||||
// }
|
||||
|
||||
// const handleRemove = async () => {
|
||||
// await delAnnotation(appId, annotationId!)
|
||||
// Toast.notify({
|
||||
// message: t('common.api.actionSuccess') as string,
|
||||
// type: 'success',
|
||||
// })
|
||||
// onRemoved()
|
||||
// setShowModal(false)
|
||||
// }
|
||||
// return (
|
||||
// <div className={cn('inline-block', className)}>
|
||||
// <div className='inline-flex p-0.5 space-x-0.5 rounded-lg bg-white border border-gray-100 shadow-md text-gray-500 cursor-pointer'>
|
||||
// {cached
|
||||
// ? (
|
||||
// <div>
|
||||
// <div
|
||||
// ref={cachedBtnRef}
|
||||
// className={cn(isCachedBtnHovering ? 'bg-[#FEF3F2] text-[#D92D20]' : 'bg-[#EEF4FF] text-[#444CE7]', 'flex p-1 space-x-1 items-center rounded-md leading-4 text-xs font-medium')}
|
||||
// onClick={() => setShowModal(true)}
|
||||
// >
|
||||
// {!isCachedBtnHovering
|
||||
// ? (
|
||||
// <>
|
||||
// <MessageFast className='w-4 h-4' />
|
||||
// <div>{t('appDebug.feature.annotation.cached')}</div>
|
||||
// </>
|
||||
// )
|
||||
// : <>
|
||||
// <MessageCheckRemove className='w-4 h-4' />
|
||||
// <div>{t('appDebug.feature.annotation.remove')}</div>
|
||||
// </>}
|
||||
// </div>
|
||||
// </div>
|
||||
// )
|
||||
// : answer
|
||||
// ? (
|
||||
// <Tooltip
|
||||
// popupContent={t('appDebug.feature.annotation.add')}
|
||||
// >
|
||||
// <div
|
||||
// className='p-1 rounded-md hover:bg-[#EEF4FF] hover:text-[#444CE7] cursor-pointer'
|
||||
// onClick={handleAdd}
|
||||
// >
|
||||
// <MessageFastPlus className='w-4 h-4' />
|
||||
// </div>
|
||||
// </Tooltip>
|
||||
// )
|
||||
// : null
|
||||
// }
|
||||
// <Tooltip
|
||||
// popupContent={t('appDebug.feature.annotation.edit')}
|
||||
// >
|
||||
// <div
|
||||
// className='p-1 cursor-pointer rounded-md hover:bg-black/5'
|
||||
// onClick={onEdit}
|
||||
// >
|
||||
// <Edit04 className='w-4 h-4' />
|
||||
// </div>
|
||||
// </Tooltip>
|
||||
|
||||
// </div>
|
||||
// <RemoveAnnotationConfirmModal
|
||||
// isShow={showModal}
|
||||
// onHide={() => setShowModal(false)}
|
||||
// onRemove={handleRemove}
|
||||
// />
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
// export default React.memo(CacheCtrlBtn)
|
||||
// TODO MARS
|
||||
80
app/components/base/features/types.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import type { Resolution, TransferMethod, TtsAutoPlay } from '@/types/app'
|
||||
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
|
||||
export interface EnabledOrDisabled {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type MoreLikeThis = EnabledOrDisabled
|
||||
|
||||
export type OpeningStatement = EnabledOrDisabled & {
|
||||
opening_statement?: string
|
||||
suggested_questions?: string[]
|
||||
}
|
||||
|
||||
export type SuggestedQuestionsAfterAnswer = EnabledOrDisabled
|
||||
|
||||
export type TextToSpeech = EnabledOrDisabled & {
|
||||
language?: string
|
||||
voice?: string
|
||||
autoPlay?: TtsAutoPlay
|
||||
}
|
||||
|
||||
export type SpeechToText = EnabledOrDisabled
|
||||
|
||||
export type RetrieverResource = EnabledOrDisabled
|
||||
|
||||
export type SensitiveWordAvoidance = EnabledOrDisabled & {
|
||||
type?: string
|
||||
config?: any
|
||||
}
|
||||
|
||||
export type FileUpload = {
|
||||
image?: EnabledOrDisabled & {
|
||||
detail?: Resolution
|
||||
number_limits?: number
|
||||
transfer_methods?: TransferMethod[]
|
||||
}
|
||||
allowed_file_types?: string[]
|
||||
allowed_file_extensions?: string[]
|
||||
allowed_file_upload_methods?: TransferMethod[]
|
||||
number_limits?: number
|
||||
fileUploadConfig?: FileUploadConfigResponse
|
||||
} & EnabledOrDisabled
|
||||
|
||||
export interface AnnotationReplyConfig {
|
||||
enabled: boolean
|
||||
id?: string
|
||||
score_threshold?: number
|
||||
embedding_model?: {
|
||||
embedding_provider_name: string
|
||||
embedding_model_name: string
|
||||
}
|
||||
}
|
||||
|
||||
export enum FeatureEnum {
|
||||
moreLikeThis = 'moreLikeThis',
|
||||
opening = 'opening',
|
||||
suggested = 'suggested',
|
||||
text2speech = 'text2speech',
|
||||
speech2text = 'speech2text',
|
||||
citation = 'citation',
|
||||
moderation = 'moderation',
|
||||
file = 'file',
|
||||
annotationReply = 'annotationReply',
|
||||
}
|
||||
|
||||
export interface Features {
|
||||
[FeatureEnum.moreLikeThis]?: MoreLikeThis
|
||||
[FeatureEnum.opening]?: OpeningStatement
|
||||
[FeatureEnum.suggested]?: SuggestedQuestionsAfterAnswer
|
||||
[FeatureEnum.text2speech]?: TextToSpeech
|
||||
[FeatureEnum.speech2text]?: SpeechToText
|
||||
[FeatureEnum.citation]?: RetrieverResource
|
||||
[FeatureEnum.moderation]?: SensitiveWordAvoidance
|
||||
[FeatureEnum.file]?: FileUpload
|
||||
[FeatureEnum.annotationReply]?: AnnotationReplyConfig
|
||||
}
|
||||
|
||||
export type OnFeaturesChange = (features?: Features) => void
|
||||
55
app/components/base/file-icon/index.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
Csv,
|
||||
Doc,
|
||||
Docx,
|
||||
Html,
|
||||
Json,
|
||||
Md,
|
||||
Pdf,
|
||||
Txt,
|
||||
Unknown,
|
||||
Xlsx,
|
||||
} from '@/app/components/base/icons/src/public/files'
|
||||
import { Notion } from '@/app/components/base/icons/src/public/common'
|
||||
|
||||
type FileIconProps = {
|
||||
type: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const FileIcon: FC<FileIconProps> = ({
|
||||
type,
|
||||
className,
|
||||
}) => {
|
||||
switch (type) {
|
||||
case 'csv':
|
||||
return <Csv className={className} />
|
||||
case 'doc':
|
||||
return <Doc className={className} />
|
||||
case 'docx':
|
||||
return <Docx className={className} />
|
||||
case 'htm':
|
||||
case 'html':
|
||||
return <Html className={className} />
|
||||
case 'json':
|
||||
return <Json className={className} />
|
||||
case 'md':
|
||||
case 'markdown':
|
||||
case 'mdx':
|
||||
return <Md className={className} />
|
||||
case 'pdf':
|
||||
return <Pdf className={className} />
|
||||
case 'txt':
|
||||
return <Txt className={className} />
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return <Xlsx className={className} />
|
||||
case 'notion':
|
||||
return <Notion className={className} />
|
||||
default:
|
||||
return <Unknown className={className} />
|
||||
}
|
||||
}
|
||||
|
||||
export default FileIcon
|
||||
47
app/components/base/file-uploader/audio-preview.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import React from 'react'
|
||||
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
type AudioPreviewProps = {
|
||||
url: string
|
||||
title: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const AudioPreview: FC<AudioPreviewProps> = ({
|
||||
url,
|
||||
title,
|
||||
onCancel,
|
||||
}) => {
|
||||
useHotkeys('esc', onCancel)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]'
|
||||
onClick={e => e.stopPropagation()}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div>
|
||||
<audio controls title={title} autoPlay={false} preload="metadata">
|
||||
<source
|
||||
type="audio/mpeg"
|
||||
src={url}
|
||||
className='max-w-full max-h-full'
|
||||
/>
|
||||
</audio>
|
||||
</div>
|
||||
<div
|
||||
className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='w-4 h-4 text-gray-500'/>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioPreview
|
||||
8
app/components/base/file-uploader/constants.ts
Normal file
@ -0,0 +1,8 @@
|
||||
// fallback for file size limit of dify_config
|
||||
export const IMG_SIZE_LIMIT = 10 * 1024 * 1024
|
||||
export const FILE_SIZE_LIMIT = 15 * 1024 * 1024
|
||||
export const AUDIO_SIZE_LIMIT = 50 * 1024 * 1024
|
||||
export const VIDEO_SIZE_LIMIT = 100 * 1024 * 1024
|
||||
export const MAX_FILE_UPLOAD_LIMIT = 10
|
||||
|
||||
export const FILE_URL_REGEX = /^(https?|ftp):\/\//
|
||||
17
app/components/base/file-uploader/dynamic-pdf-preview.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
type DynamicPdfPreviewProps = {
|
||||
url: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const DynamicPdfPreview = dynamic<DynamicPdfPreviewProps>(
|
||||
(() => {
|
||||
if (typeof window !== 'undefined')
|
||||
return import('./pdf-preview')
|
||||
}) as any,
|
||||
{ ssr: false }, // This will prevent the module from being loaded on the server-side
|
||||
)
|
||||
|
||||
export default DynamicPdfPreview
|
||||
@ -0,0 +1,129 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiUploadCloud2Line } from '@remixicon/react'
|
||||
import FileInput from '../file-input'
|
||||
import { useFile } from '../hooks'
|
||||
import { useStore } from '../store'
|
||||
import { FILE_URL_REGEX } from '../constants'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type FileFromLinkOrLocalProps = {
|
||||
showFromLink?: boolean
|
||||
showFromLocal?: boolean
|
||||
trigger: (open: boolean) => React.ReactNode
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileFromLinkOrLocal = ({
|
||||
showFromLink = true,
|
||||
showFromLocal = true,
|
||||
trigger,
|
||||
fileConfig,
|
||||
}: FileFromLinkOrLocalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const files = useStore(s => s.files)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [showError, setShowError] = useState(false)
|
||||
const { handleLoadFileFromLink } = useFile(fileConfig)
|
||||
const disabled = !!fileConfig.number_limits && files.length >= fileConfig.number_limits
|
||||
|
||||
const handleSaveUrl = () => {
|
||||
if (!url)
|
||||
return
|
||||
|
||||
if (!FILE_URL_REGEX.test(url)) {
|
||||
setShowError(true)
|
||||
return
|
||||
}
|
||||
handleLoadFileFromLink(url)
|
||||
setUrl('')
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='top'
|
||||
offset={4}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} asChild>
|
||||
{trigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1001]'>
|
||||
<div className='p-3 w-[280px] bg-components-panel-bg-blur border-[0.5px] border-components-panel-border rounded-xl shadow-lg'>
|
||||
{
|
||||
showFromLink && (
|
||||
<>
|
||||
<div className={cn(
|
||||
'flex items-center p-1 h-8 bg-components-input-bg-active border border-components-input-border-active rounded-lg shadow-xs',
|
||||
showError && 'border-components-input-border-destructive',
|
||||
)}>
|
||||
<input
|
||||
className='grow block mr-0.5 px-1 bg-transparent system-sm-regular outline-none appearance-none'
|
||||
placeholder={t('common.fileUploader.pasteFileLinkInputPlaceholder') || ''}
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setShowError(false)
|
||||
setUrl(e.target.value)
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
className='shrink-0'
|
||||
size='small'
|
||||
variant='primary'
|
||||
disabled={!url || disabled}
|
||||
onClick={handleSaveUrl}
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
{
|
||||
showError && (
|
||||
<div className='mt-0.5 body-xs-regular text-text-destructive'>
|
||||
{t('common.fileUploader.pasteFileLinkInvalid')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFromLink && showFromLocal && (
|
||||
<div className='flex items-center p-2 h-7 system-2xs-medium-uppercase text-text-quaternary'>
|
||||
<div className='mr-2 w-[93px] h-[1px] bg-gradient-to-l from-[rgba(16,24,40,0.08)]' />
|
||||
OR
|
||||
<div className='ml-2 w-[93px] h-[1px] bg-gradient-to-r from-[rgba(16,24,40,0.08)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFromLocal && (
|
||||
<Button
|
||||
className='relative w-full'
|
||||
variant='secondary-accent'
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiUploadCloud2Line className='mr-1 w-4 h-4' />
|
||||
{t('common.fileUploader.uploadFromComputer')}
|
||||
<FileInput fileConfig={fileConfig} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileFromLinkOrLocal)
|
||||
32
app/components/base/file-uploader/file-image-render.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type FileImageRenderProps = {
|
||||
imageUrl: string
|
||||
className?: string
|
||||
alt?: string
|
||||
onLoad?: () => void
|
||||
onError?: () => void
|
||||
showDownloadAction?: boolean
|
||||
}
|
||||
const FileImageRender = ({
|
||||
imageUrl,
|
||||
className,
|
||||
alt,
|
||||
onLoad,
|
||||
onError,
|
||||
showDownloadAction,
|
||||
}: FileImageRenderProps) => {
|
||||
return (
|
||||
<div className={cn('border-[2px] border-effects-image-frame shadow-xs', className)}>
|
||||
<img
|
||||
className={cn('w-full h-full object-cover', showDownloadAction && 'cursor-pointer')}
|
||||
alt={alt || 'Preview'}
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileImageRender
|
||||
49
app/components/base/file-uploader/file-input.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useFile } from './hooks'
|
||||
import { useStore } from './store'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
|
||||
type FileInputProps = {
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileInput = ({
|
||||
fileConfig,
|
||||
}: FileInputProps) => {
|
||||
const files = useStore(s => s.files)
|
||||
const { handleLocalFileUpload } = useFile(fileConfig)
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const targetFiles = e.target.files
|
||||
|
||||
if (targetFiles) {
|
||||
if (fileConfig.number_limits) {
|
||||
for (let i = 0; i < targetFiles.length; i++) {
|
||||
if (i + 1 + files.length <= fileConfig.number_limits)
|
||||
handleLocalFileUpload(targetFiles[i])
|
||||
}
|
||||
}
|
||||
else {
|
||||
handleLocalFileUpload(targetFiles[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allowedFileTypes = fileConfig.allowed_file_types
|
||||
const isCustom = allowedFileTypes?.includes(SupportUploadFileTypes.custom)
|
||||
const exts = isCustom ? (fileConfig.allowed_file_extensions || []) : (allowedFileTypes?.map(type => FILE_EXTS[type]) || []).flat().map(item => `.${item}`)
|
||||
const accept = exts.join(',')
|
||||
|
||||
return (
|
||||
<input
|
||||
className='absolute block inset-0 opacity-0 text-[0] w-full disabled:cursor-not-allowed cursor-pointer'
|
||||
onClick={e => ((e.target as HTMLInputElement).value = '')}
|
||||
type='file'
|
||||
onChange={handleChange}
|
||||
accept={accept}
|
||||
disabled={!!(fileConfig.number_limits && files.length >= fileConfig?.number_limits)}
|
||||
multiple={!!fileConfig.number_limits && fileConfig.number_limits > 1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileInput
|
||||
106
app/components/base/file-uploader/file-list-in-log.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import FileImageRender from './file-image-render'
|
||||
import FileTypeIcon from './file-type-icon'
|
||||
import FileItem from './file-uploader-in-attachment/file-item'
|
||||
import type { FileEntity } from './types'
|
||||
import {
|
||||
getFileAppearanceType,
|
||||
} from './utils'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
fileList: {
|
||||
varName: string
|
||||
list: FileEntity[]
|
||||
}[]
|
||||
isExpanded?: boolean
|
||||
noBorder?: boolean
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPadding = false }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(isExpanded)
|
||||
const fullList = useMemo(() => {
|
||||
return fileList.reduce((acc: FileEntity[], { list }) => {
|
||||
return [...acc, ...list]
|
||||
}, [])
|
||||
}, [fileList])
|
||||
|
||||
if (!fileList.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('px-3 py-2', expanded && 'py-3', !noBorder && 'border-t border-divider-subtle', noPadding && '!p-0')}>
|
||||
<div className='flex justify-between gap-1'>
|
||||
{expanded && (
|
||||
<div className='grow py-1 text-text-secondary system-xs-semibold-uppercase cursor-pointer' onClick={() => setExpanded(!expanded)}>{t('appLog.runDetail.fileListLabel')}</div>
|
||||
)}
|
||||
{!expanded && (
|
||||
<div className='flex gap-1'>
|
||||
{fullList.map((file) => {
|
||||
const { id, name, type, supportFileType, base64Url, url } = file
|
||||
const isImageFile = supportFileType === SupportUploadFileTypes.image
|
||||
return (
|
||||
<>
|
||||
{isImageFile && (
|
||||
<Tooltip
|
||||
popupContent={name}
|
||||
>
|
||||
<div key={id}>
|
||||
<FileImageRender
|
||||
className='w-8 h-8'
|
||||
imageUrl={base64Url || url || ''}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isImageFile && (
|
||||
<Tooltip
|
||||
popupContent={name}
|
||||
>
|
||||
<div key={id} className='p-1.5 rounded-md bg-components-panel-on-panel-item-bg border-[0.5px] border-components-panel-border shadow-xs'>
|
||||
<FileTypeIcon
|
||||
type={getFileAppearanceType(name, type)}
|
||||
size='md'
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center gap-1 cursor-pointer' onClick={() => setExpanded(!expanded)}>
|
||||
{!expanded && <div className='text-text-tertiary system-xs-medium-uppercase'>{t('appLog.runDetail.fileListDetail')}</div>}
|
||||
<RiArrowRightSLine className={cn('w-4 h-4 text-text-tertiary', expanded && 'rotate-90')} />
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className='flex flex-col gap-3'>
|
||||
{fileList.map(item => (
|
||||
<div key={item.varName} className='flex flex-col gap-1 system-xs-regular'>
|
||||
<div className='py-1 text-text-tertiary '>{item.varName}</div>
|
||||
{item.list.map(file => (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileListInLog
|
||||
91
app/components/base/file-uploader/file-type-icon.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
RiFile3Fill,
|
||||
RiFileCodeFill,
|
||||
RiFileExcelFill,
|
||||
RiFileGifFill,
|
||||
RiFileImageFill,
|
||||
RiFileMusicFill,
|
||||
RiFilePdf2Fill,
|
||||
RiFilePpt2Fill,
|
||||
RiFileTextFill,
|
||||
RiFileVideoFill,
|
||||
RiFileWordFill,
|
||||
RiMarkdownFill,
|
||||
} from '@remixicon/react'
|
||||
import { FileAppearanceTypeEnum } from './types'
|
||||
import type { FileAppearanceType } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const FILE_TYPE_ICON_MAP = {
|
||||
[FileAppearanceTypeEnum.pdf]: {
|
||||
component: RiFilePdf2Fill,
|
||||
color: 'text-[#EA3434]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.image]: {
|
||||
component: RiFileImageFill,
|
||||
color: 'text-[#00B2EA]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.video]: {
|
||||
component: RiFileVideoFill,
|
||||
color: 'text-[#844FDA]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.audio]: {
|
||||
component: RiFileMusicFill,
|
||||
color: 'text-[#FF3093]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.document]: {
|
||||
component: RiFileTextFill,
|
||||
color: 'text-[#6F8BB5]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.code]: {
|
||||
component: RiFileCodeFill,
|
||||
color: 'text-[#BCC0D1]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.markdown]: {
|
||||
component: RiMarkdownFill,
|
||||
color: 'text-[#309BEC]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.custom]: {
|
||||
component: RiFile3Fill,
|
||||
color: 'text-[#BCC0D1]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.excel]: {
|
||||
component: RiFileExcelFill,
|
||||
color: 'text-[#01AC49]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.word]: {
|
||||
component: RiFileWordFill,
|
||||
color: 'text-[#2684FF]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.ppt]: {
|
||||
component: RiFilePpt2Fill,
|
||||
color: 'text-[#FF650F]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.gif]: {
|
||||
component: RiFileGifFill,
|
||||
color: 'text-[#00B2EA]',
|
||||
},
|
||||
}
|
||||
type FileTypeIconProps = {
|
||||
type: FileAppearanceType
|
||||
size?: 'sm' | 'lg' | 'md'
|
||||
className?: string
|
||||
}
|
||||
const SizeMap = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
}
|
||||
const FileTypeIcon = ({
|
||||
type,
|
||||
size = 'sm',
|
||||
className,
|
||||
}: FileTypeIconProps) => {
|
||||
const Icon = FILE_TYPE_ICON_MAP[type]?.component || FILE_TYPE_ICON_MAP[FileAppearanceTypeEnum.document].component
|
||||
const color = FILE_TYPE_ICON_MAP[type]?.color || FILE_TYPE_ICON_MAP[FileAppearanceTypeEnum.document].color
|
||||
|
||||
return <Icon className={cn('shrink-0', SizeMap[size], color, className)} />
|
||||
}
|
||||
|
||||
export default memo(FileTypeIcon)
|
||||
@ -0,0 +1,154 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiDownloadLine,
|
||||
RiEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import FileTypeIcon from '../file-type-icon'
|
||||
import {
|
||||
downloadFile,
|
||||
fileIsUploaded,
|
||||
getFileAppearanceType,
|
||||
getFileExtension,
|
||||
} from '../utils'
|
||||
import FileImageRender from '../file-image-render'
|
||||
import type { FileEntity } from '../types'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import cn from '@/utils/classnames'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
|
||||
type FileInAttachmentItemProps = {
|
||||
file: FileEntity
|
||||
showDeleteAction?: boolean
|
||||
showDownloadAction?: boolean
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
canPreview?: boolean
|
||||
}
|
||||
const FileInAttachmentItem = ({
|
||||
file,
|
||||
showDeleteAction,
|
||||
showDownloadAction = true,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
canPreview,
|
||||
}: FileInAttachmentItemProps) => {
|
||||
const { id, name, type, progress, supportFileType, base64Url, url, isRemote } = file
|
||||
const ext = getFileExtension(name, type, isRemote)
|
||||
const isImageFile = supportFileType === SupportUploadFileTypes.image
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
|
||||
return (
|
||||
<>
|
||||
<div className={cn(
|
||||
'flex items-center pr-3 h-12 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs',
|
||||
progress === -1 && 'bg-state-destructive-hover border-state-destructive-border',
|
||||
)}>
|
||||
<div className='flex items-center justify-center w-12 h-12'>
|
||||
{
|
||||
isImageFile && (
|
||||
<FileImageRender
|
||||
className='w-8 h-8'
|
||||
imageUrl={base64Url || url || ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isImageFile && (
|
||||
<FileTypeIcon
|
||||
type={getFileAppearanceType(name, type)}
|
||||
size='lg'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className='grow w-0 mr-1'>
|
||||
<div
|
||||
className='flex items-center mb-0.5 system-xs-medium text-text-secondary truncate'
|
||||
title={file.name}
|
||||
>
|
||||
<div className='truncate'>{name}</div>
|
||||
</div>
|
||||
<div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
|
||||
{
|
||||
ext && (
|
||||
<span>{ext.toLowerCase()}</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
ext && (
|
||||
<span className='mx-1 system-2xs-medium'>•</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!file.size && (
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='shrink-0 flex items-center'>
|
||||
{
|
||||
progress >= 0 && !fileIsUploaded(file) && (
|
||||
<ProgressCircle
|
||||
className='mr-2.5'
|
||||
percentage={progress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
progress === -1 && (
|
||||
<ActionButton
|
||||
className='mr-1'
|
||||
onClick={() => onReUpload?.(id)}
|
||||
>
|
||||
<ReplayLine className='w-4 h-4 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
showDeleteAction && (
|
||||
<ActionButton onClick={() => onRemove?.(id)}>
|
||||
<RiDeleteBinLine className='w-4 h-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
canPreview && isImageFile && (
|
||||
<ActionButton className='mr-1' onClick={() => setImagePreviewUrl(url || '')}>
|
||||
<RiEyeLine className='w-4 h-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
showDownloadAction && (
|
||||
<ActionButton onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadFile(url || base64Url || '', name)
|
||||
}}>
|
||||
<RiDownloadLine className='w-4 h-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
imagePreviewUrl && canPreview && (
|
||||
<ImagePreview
|
||||
title={name}
|
||||
url={imagePreviewUrl}
|
||||
onCancel={() => setImagePreviewUrl('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileInAttachmentItem)
|
||||
@ -0,0 +1,134 @@
|
||||
import {
|
||||
JSX,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import {
|
||||
RiLink,
|
||||
RiUploadCloud2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FileFromLinkOrLocal from '../file-from-link-or-local'
|
||||
import {
|
||||
FileContextProvider,
|
||||
useStore,
|
||||
} from '../store'
|
||||
import type { FileEntity } from '../types'
|
||||
import FileInput from '../file-input'
|
||||
import { useFile } from '../hooks'
|
||||
import FileItem from './file-item'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type Option = {
|
||||
value: string
|
||||
label: string
|
||||
icon: JSX.Element
|
||||
}
|
||||
type FileUploaderInAttachmentProps = {
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileUploaderInAttachment = ({
|
||||
fileConfig,
|
||||
}: FileUploaderInAttachmentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const files = useStore(s => s.files)
|
||||
const {
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
} = useFile(fileConfig)
|
||||
const options = [
|
||||
{
|
||||
value: TransferMethod.local_file,
|
||||
label: t('common.fileUploader.uploadFromComputer'),
|
||||
icon: <RiUploadCloud2Line className='w-4 h-4' />,
|
||||
},
|
||||
{
|
||||
value: TransferMethod.remote_url,
|
||||
label: t('common.fileUploader.pasteFileLink'),
|
||||
icon: <RiLink className='w-4 h-4' />,
|
||||
},
|
||||
]
|
||||
|
||||
const renderButton = useCallback((option: Option, open?: boolean) => {
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant='tertiary'
|
||||
className={cn('grow relative', open && 'bg-components-button-tertiary-bg-hover')}
|
||||
disabled={!!(fileConfig.number_limits && files.length >= fileConfig.number_limits)}
|
||||
>
|
||||
{option.icon}
|
||||
<span className='ml-1'>{option.label}</span>
|
||||
{
|
||||
option.value === TransferMethod.local_file && (
|
||||
<FileInput fileConfig={fileConfig} />
|
||||
)
|
||||
}
|
||||
</Button>
|
||||
)
|
||||
}, [fileConfig, files.length])
|
||||
const renderTrigger = useCallback((option: Option) => {
|
||||
return (open: boolean) => renderButton(option, open)
|
||||
}, [renderButton])
|
||||
const renderOption = useCallback((option: Option) => {
|
||||
if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file))
|
||||
return renderButton(option)
|
||||
|
||||
if (option.value === TransferMethod.remote_url && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)) {
|
||||
return (
|
||||
<FileFromLinkOrLocal
|
||||
key={option.value}
|
||||
showFromLocal={false}
|
||||
trigger={renderTrigger(option)}
|
||||
fileConfig={fileConfig}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}, [renderButton, renderTrigger, fileConfig])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='flex items-center space-x-1'>
|
||||
{options.map(renderOption)}
|
||||
</div>
|
||||
<div className='mt-1 space-y-1'>
|
||||
{
|
||||
files.map(file => (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
showDeleteAction
|
||||
showDownloadAction={false}
|
||||
onRemove={() => handleRemoveFile(file.id)}
|
||||
onReUpload={() => handleReUploadFile(file.id)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FileUploaderInAttachmentWrapperProps = {
|
||||
value?: FileEntity[]
|
||||
onChange: (files: FileEntity[]) => void
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileUploaderInAttachmentWrapper = ({
|
||||
value,
|
||||
onChange,
|
||||
fileConfig,
|
||||
}: FileUploaderInAttachmentWrapperProps) => {
|
||||
return (
|
||||
<FileContextProvider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
>
|
||||
<FileUploaderInAttachment fileConfig={fileConfig} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUploaderInAttachmentWrapper
|
||||
@ -0,0 +1,109 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiDownloadLine,
|
||||
} from '@remixicon/react'
|
||||
import FileImageRender from '../file-image-render'
|
||||
import type { FileEntity } from '../types'
|
||||
import {
|
||||
downloadFile,
|
||||
fileIsUploaded,
|
||||
} from '../utils'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
|
||||
type FileImageItemProps = {
|
||||
file: FileEntity
|
||||
showDeleteAction?: boolean
|
||||
showDownloadAction?: boolean
|
||||
canPreview?: boolean
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
}
|
||||
const FileImageItem = ({
|
||||
file,
|
||||
showDeleteAction,
|
||||
showDownloadAction,
|
||||
canPreview,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
}: FileImageItemProps) => {
|
||||
const { id, progress, base64Url, url, name } = file
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='group/file-image relative cursor-pointer'
|
||||
onClick={() => canPreview && setImagePreviewUrl(base64Url || url || '')}
|
||||
>
|
||||
{
|
||||
showDeleteAction && (
|
||||
<Button
|
||||
className='hidden group-hover/file-image:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]'
|
||||
onClick={() => onRemove?.(id)}
|
||||
>
|
||||
<RiCloseLine className='w-4 h-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<FileImageRender
|
||||
className='w-[68px] h-[68px] shadow-md'
|
||||
imageUrl={base64Url || url || ''}
|
||||
showDownloadAction={showDownloadAction}
|
||||
/>
|
||||
{
|
||||
progress >= 0 && !fileIsUploaded(file) && (
|
||||
<div className='absolute inset-0 flex items-center justify-center border-[2px] border-effects-image-frame bg-background-overlay-alt z-10'>
|
||||
<ProgressCircle
|
||||
percentage={progress}
|
||||
size={12}
|
||||
circleStrokeColor='stroke-components-progress-white-border'
|
||||
circleFillColor='fill-transparent'
|
||||
sectorFillColor='fill-components-progress-white-progress'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
progress === -1 && (
|
||||
<div className='absolute inset-0 flex items-center justify-center border-[2px] border-state-destructive-border bg-background-overlay-destructive z-10'>
|
||||
<ReplayLine
|
||||
className='w-5 h-5'
|
||||
onClick={() => onReUpload?.(id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
showDownloadAction && (
|
||||
<div className='hidden group-hover/file-image:block absolute inset-0.5 bg-background-overlay-alt bg-opacity-[0.3] z-10'>
|
||||
<div
|
||||
className='absolute bottom-0.5 right-0.5 flex items-center justify-center w-6 h-6 rounded-lg bg-components-actionbar-bg shadow-md'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadFile(url || base64Url || '', name)
|
||||
}}
|
||||
>
|
||||
<RiDownloadLine className='w-4 h-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
imagePreviewUrl && canPreview && (
|
||||
<ImagePreview
|
||||
title={name}
|
||||
url={imagePreviewUrl}
|
||||
onCancel={() => setImagePreviewUrl('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileImageItem
|
||||
@ -0,0 +1,160 @@
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiDownloadLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
downloadFile,
|
||||
fileIsUploaded,
|
||||
getFileAppearanceType,
|
||||
getFileExtension,
|
||||
} from '../utils'
|
||||
import FileTypeIcon from '../file-type-icon'
|
||||
import type { FileEntity } from '../types'
|
||||
import cn from '@/utils/classnames'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Button from '@/app/components/base/button'
|
||||
import PdfPreview from '@/app/components/base/file-uploader/dynamic-pdf-preview'
|
||||
import AudioPreview from '@/app/components/base/file-uploader/audio-preview'
|
||||
import VideoPreview from '@/app/components/base/file-uploader/video-preview'
|
||||
|
||||
type FileItemProps = {
|
||||
file: FileEntity
|
||||
showDeleteAction?: boolean
|
||||
showDownloadAction?: boolean
|
||||
canPreview?: boolean
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
}
|
||||
const FileItem = ({
|
||||
file,
|
||||
showDeleteAction,
|
||||
showDownloadAction = true,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
canPreview,
|
||||
}: FileItemProps) => {
|
||||
const { id, name, type, progress, url, base64Url, isRemote } = file
|
||||
const [previewUrl, setPreviewUrl] = useState('')
|
||||
const ext = getFileExtension(name, type, isRemote)
|
||||
const uploadError = progress === -1
|
||||
|
||||
let tmp_preview_url = url || base64Url
|
||||
if (!tmp_preview_url && file?.originalFile)
|
||||
tmp_preview_url = URL.createObjectURL(file.originalFile.slice()).toString()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'group/file-item relative p-2 w-[144px] h-[68px] rounded-lg border-[0.5px] border-components-panel-border bg-components-card-bg shadow-xs',
|
||||
!uploadError && 'hover:bg-components-card-bg-alt',
|
||||
uploadError && 'border border-state-destructive-border bg-state-destructive-hover',
|
||||
uploadError && 'hover:border-[0.5px] hover:border-state-destructive-border bg-state-destructive-hover-alt',
|
||||
)}
|
||||
>
|
||||
{
|
||||
showDeleteAction && (
|
||||
<Button
|
||||
className='hidden group-hover/file-item:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]'
|
||||
onClick={() => onRemove?.(id)}
|
||||
>
|
||||
<RiCloseLine className='w-4 h-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className='mb-1 h-8 line-clamp-2 system-xs-medium text-text-tertiary break-all cursor-pointer'
|
||||
title={name}
|
||||
onClick={() => canPreview && setPreviewUrl(tmp_preview_url || '')}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div className='relative flex items-center justify-between'>
|
||||
<div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
|
||||
<FileTypeIcon
|
||||
size='sm'
|
||||
type={getFileAppearanceType(name, type)}
|
||||
className='mr-1'
|
||||
/>
|
||||
{
|
||||
ext && (
|
||||
<>
|
||||
{ext}
|
||||
<div className='mx-1'>·</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!file.size && formatFileSize(file.size)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
showDownloadAction && tmp_preview_url && (
|
||||
<ActionButton
|
||||
size='m'
|
||||
className='hidden group-hover/file-item:flex absolute -right-1 -top-1'
|
||||
onClick={(e) => {
|
||||
//手动修改下载文件名
|
||||
let filename = name
|
||||
if (tmp_preview_url.indexOf('.bin')) {
|
||||
filename = `${name.split('.')[0]}.${ext}`
|
||||
}
|
||||
e.stopPropagation()
|
||||
downloadFile(tmp_preview_url || '', filename)
|
||||
}}
|
||||
>
|
||||
<RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
progress >= 0 && !fileIsUploaded(file) && (
|
||||
<ProgressCircle
|
||||
percentage={progress}
|
||||
size={12}
|
||||
className='shrink-0'
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
uploadError && (
|
||||
<ReplayLine
|
||||
className='w-4 h-4 text-text-tertiary'
|
||||
onClick={() => onReUpload?.(id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
type.split('/')[0] === 'audio' && canPreview && previewUrl && (
|
||||
<AudioPreview
|
||||
title={name}
|
||||
url={previewUrl}
|
||||
onCancel={() => setPreviewUrl('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
type.split('/')[0] === 'video' && canPreview && previewUrl && (
|
||||
<VideoPreview
|
||||
title={name}
|
||||
url={previewUrl}
|
||||
onCancel={() => setPreviewUrl('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
type.split('/')[1] === 'pdf' && canPreview && previewUrl && (
|
||||
<PdfPreview url={previewUrl} onCancel={() => { setPreviewUrl('') }} />
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileItem
|
||||
@ -0,0 +1,82 @@
|
||||
import { useFile } from '../hooks'
|
||||
import { useStore } from '../store'
|
||||
import type { FileEntity } from '../types'
|
||||
import FileImageItem from './file-image-item'
|
||||
import FileItem from './file-item'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type FileListProps = {
|
||||
className?: string
|
||||
files: FileEntity[]
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
showDeleteAction?: boolean
|
||||
showDownloadAction?: boolean
|
||||
canPreview?: boolean
|
||||
}
|
||||
export const FileList = ({
|
||||
className,
|
||||
files,
|
||||
onReUpload,
|
||||
onRemove,
|
||||
showDeleteAction = true,
|
||||
showDownloadAction = false,
|
||||
canPreview = true,
|
||||
}: FileListProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||
{
|
||||
files.map((file) => {
|
||||
if (file.supportFileType === SupportUploadFileTypes.image) {
|
||||
return (
|
||||
<FileImageItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
showDeleteAction={showDeleteAction}
|
||||
showDownloadAction={showDownloadAction}
|
||||
onRemove={onRemove}
|
||||
onReUpload={onReUpload}
|
||||
canPreview={canPreview}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
showDeleteAction={showDeleteAction}
|
||||
showDownloadAction={showDownloadAction}
|
||||
onRemove={onRemove}
|
||||
onReUpload={onReUpload}
|
||||
canPreview={canPreview}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FileListInChatInputProps = {
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
export const FileListInChatInput = ({
|
||||
fileConfig,
|
||||
}: FileListInChatInputProps) => {
|
||||
const files = useStore(s => s.files)
|
||||
const {
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
} = useFile(fileConfig)
|
||||
|
||||
return (
|
||||
<FileList
|
||||
files={files}
|
||||
onReUpload={handleReUploadFile}
|
||||
onRemove={handleRemoveFile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import {
|
||||
RiAttachmentLine,
|
||||
} from '@remixicon/react'
|
||||
import FileFromLinkOrLocal from '../file-from-link-or-local'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type FileUploaderInChatInputProps = {
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileUploaderInChatInput = ({
|
||||
fileConfig,
|
||||
}: FileUploaderInChatInputProps) => {
|
||||
const renderTrigger = useCallback((open: boolean) => {
|
||||
return (
|
||||
<ActionButton
|
||||
size='l'
|
||||
className={cn(open && 'bg-state-base-hover')}
|
||||
>
|
||||
<RiAttachmentLine className='w-5 h-5' />
|
||||
</ActionButton>
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<FileFromLinkOrLocal
|
||||
trigger={renderTrigger}
|
||||
fileConfig={fileConfig}
|
||||
showFromLocal={fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file)}
|
||||
showFromLink={fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileUploaderInChatInput)
|
||||
365
app/components/base/file-uploader/hooks.ts
Normal file
@ -0,0 +1,365 @@
|
||||
import type { ClipboardEvent } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { produce } from 'immer'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { FileEntity } from './types'
|
||||
import { useFileStore } from './store'
|
||||
import {
|
||||
fileUpload,
|
||||
getSupportFileType,
|
||||
isAllowedFileExtension,
|
||||
} from './utils'
|
||||
import {
|
||||
AUDIO_SIZE_LIMIT,
|
||||
FILE_SIZE_LIMIT,
|
||||
IMG_SIZE_LIMIT,
|
||||
MAX_FILE_UPLOAD_LIMIT,
|
||||
VIDEO_SIZE_LIMIT,
|
||||
} from '@/app/components/base/file-uploader/constants'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import { uploadRemoteFileInfo } from '@/service/index'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
export const useFileSizeLimit = (fileUploadConfig?: FileUploadConfigResponse) => {
|
||||
const imgSizeLimit = Number(fileUploadConfig?.image_file_size_limit) * 1024 * 1024 || IMG_SIZE_LIMIT
|
||||
const docSizeLimit = Number(fileUploadConfig?.file_size_limit) * 1024 * 1024 || FILE_SIZE_LIMIT
|
||||
const audioSizeLimit = Number(fileUploadConfig?.audio_file_size_limit) * 1024 * 1024 || AUDIO_SIZE_LIMIT
|
||||
const videoSizeLimit = Number(fileUploadConfig?.video_file_size_limit) * 1024 * 1024 || VIDEO_SIZE_LIMIT
|
||||
const maxFileUploadLimit = Number(fileUploadConfig?.workflow_file_upload_limit) || MAX_FILE_UPLOAD_LIMIT
|
||||
|
||||
return {
|
||||
imgSizeLimit,
|
||||
docSizeLimit,
|
||||
audioSizeLimit,
|
||||
videoSizeLimit,
|
||||
maxFileUploadLimit,
|
||||
}
|
||||
}
|
||||
|
||||
export const useFile = (fileConfig: FileUpload) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const fileStore = useFileStore()
|
||||
const params = useParams()
|
||||
const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit(fileConfig.fileUploadConfig)
|
||||
|
||||
const checkSizeLimit = useCallback((fileType: string, fileSize: number) => {
|
||||
switch (fileType) {
|
||||
case SupportUploadFileTypes.image: {
|
||||
if (fileSize > imgSizeLimit) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.uploadFromComputerLimit', {
|
||||
type: SupportUploadFileTypes.image,
|
||||
size: formatFileSize(imgSizeLimit),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
case SupportUploadFileTypes.document: {
|
||||
if (fileSize > docSizeLimit) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.uploadFromComputerLimit', {
|
||||
type: SupportUploadFileTypes.document,
|
||||
size: formatFileSize(docSizeLimit),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
case SupportUploadFileTypes.audio: {
|
||||
if (fileSize > audioSizeLimit) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.uploadFromComputerLimit', {
|
||||
type: SupportUploadFileTypes.audio,
|
||||
size: formatFileSize(audioSizeLimit),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
case SupportUploadFileTypes.video: {
|
||||
if (fileSize > videoSizeLimit) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.uploadFromComputerLimit', {
|
||||
type: SupportUploadFileTypes.video,
|
||||
size: formatFileSize(videoSizeLimit),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
case SupportUploadFileTypes.custom: {
|
||||
if (fileSize > docSizeLimit) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.uploadFromComputerLimit', {
|
||||
type: SupportUploadFileTypes.document,
|
||||
size: formatFileSize(docSizeLimit),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
default: {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}, [audioSizeLimit, docSizeLimit, imgSizeLimit, notify, t, videoSizeLimit])
|
||||
|
||||
const handleAddFile = useCallback((newFile: FileEntity) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
|
||||
const newFiles = produce(files, (draft) => {
|
||||
draft.push(newFile)
|
||||
})
|
||||
setFiles(newFiles)
|
||||
}, [fileStore])
|
||||
|
||||
const handleUpdateFile = useCallback((newFile: FileEntity) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
|
||||
const newFiles = produce(files, (draft) => {
|
||||
const index = draft.findIndex(file => file.id === newFile.id)
|
||||
|
||||
if (index > -1)
|
||||
draft[index] = newFile
|
||||
})
|
||||
setFiles(newFiles)
|
||||
}, [fileStore])
|
||||
|
||||
const handleRemoveFile = useCallback((fileId: string) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
|
||||
const newFiles = files.filter(file => file.id !== fileId)
|
||||
setFiles(newFiles)
|
||||
}, [fileStore])
|
||||
|
||||
const handleReUploadFile = useCallback((fileId: string) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
const index = files.findIndex(file => file.id === fileId)
|
||||
|
||||
if (index > -1) {
|
||||
const uploadingFile = files[index]
|
||||
const newFiles = produce(files, (draft) => {
|
||||
draft[index].progress = 0
|
||||
})
|
||||
setFiles(newFiles)
|
||||
fileUpload({
|
||||
file: uploadingFile.originalFile!,
|
||||
onProgressCallback: (progress) => {
|
||||
handleUpdateFile({ ...uploadingFile, progress })
|
||||
},
|
||||
onSuccessCallback: (res) => {
|
||||
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
|
||||
},
|
||||
onErrorCallback: () => {
|
||||
notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerUploadError') })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
}, !!params.token)
|
||||
}
|
||||
}, [fileStore, notify, t, handleUpdateFile, params])
|
||||
|
||||
const startProgressTimer = useCallback((fileId: string) => {
|
||||
const timer = setInterval(() => {
|
||||
const files = fileStore.getState().files
|
||||
const file = files.find(file => file.id === fileId)
|
||||
|
||||
if (file && file.progress < 80 && file.progress >= 0)
|
||||
handleUpdateFile({ ...file, progress: file.progress + 20 })
|
||||
else
|
||||
clearTimeout(timer)
|
||||
}, 200)
|
||||
}, [fileStore, handleUpdateFile])
|
||||
const handleLoadFileFromLink = useCallback((url: string) => {
|
||||
const allowedFileTypes = fileConfig.allowed_file_types
|
||||
|
||||
const uploadingFile = {
|
||||
id: uuid4(),
|
||||
name: url,
|
||||
type: '',
|
||||
size: 0,
|
||||
progress: 0,
|
||||
transferMethod: TransferMethod.remote_url,
|
||||
supportFileType: '',
|
||||
url,
|
||||
isRemote: true,
|
||||
}
|
||||
handleAddFile(uploadingFile)
|
||||
startProgressTimer(uploadingFile.id)
|
||||
|
||||
uploadRemoteFileInfo(url, !!params.token).then((res) => {
|
||||
|
||||
const newFile = {
|
||||
...uploadingFile,
|
||||
type: res.mime_type,
|
||||
size: res.size,
|
||||
progress: 100,
|
||||
supportFileType: getSupportFileType(res.name, res.mime_type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)),
|
||||
uploadedId: res.id,
|
||||
url: res.url,
|
||||
}
|
||||
if (!isAllowedFileExtension(res.name, res.mime_type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) {
|
||||
notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') })
|
||||
handleRemoveFile(uploadingFile.id)
|
||||
}
|
||||
if (!checkSizeLimit(newFile.supportFileType, newFile.size))
|
||||
handleRemoveFile(uploadingFile.id)
|
||||
else
|
||||
handleUpdateFile(newFile)
|
||||
}).catch(() => {
|
||||
notify({ type: 'error', message: t('common.fileUploader.pasteFileLinkInvalid') })
|
||||
handleRemoveFile(uploadingFile.id)
|
||||
})
|
||||
}, [checkSizeLimit, handleAddFile, handleUpdateFile, notify, t, handleRemoveFile, fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, params.token])
|
||||
|
||||
const handleLoadFileFromLinkSuccess = useCallback(() => { }, [])
|
||||
|
||||
const handleLoadFileFromLinkError = useCallback(() => { }, [])
|
||||
|
||||
const handleClearFiles = useCallback(() => {
|
||||
const {
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
setFiles([])
|
||||
}, [fileStore])
|
||||
|
||||
const handleLocalFileUpload = useCallback((file: File) => {
|
||||
if (!isAllowedFileExtension(file.name, file.type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) {
|
||||
notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') })
|
||||
return
|
||||
}
|
||||
const allowedFileTypes = fileConfig.allowed_file_types
|
||||
const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom))
|
||||
if (!checkSizeLimit(fileType, file.size))
|
||||
return
|
||||
|
||||
const reader = new FileReader()
|
||||
const isImage = file.type.startsWith('image')
|
||||
|
||||
reader.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
const uploadingFile = {
|
||||
id: uuid4(),
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
progress: 0,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)),
|
||||
originalFile: file,
|
||||
base64Url: isImage ? reader.result as string : '',
|
||||
}
|
||||
handleAddFile(uploadingFile)
|
||||
fileUpload({
|
||||
file: uploadingFile.originalFile,
|
||||
onProgressCallback: (progress) => {
|
||||
handleUpdateFile({ ...uploadingFile, progress })
|
||||
},
|
||||
onSuccessCallback: (res) => {
|
||||
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
|
||||
},
|
||||
onErrorCallback: () => {
|
||||
notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerUploadError') })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
}, !!params.token)
|
||||
},
|
||||
false,
|
||||
)
|
||||
reader.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerReadError') })
|
||||
},
|
||||
false,
|
||||
)
|
||||
reader.readAsDataURL(file)
|
||||
}, [checkSizeLimit, notify, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions])
|
||||
|
||||
const handleClipboardPasteFile = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const file = e.clipboardData?.files[0]
|
||||
if (file) {
|
||||
e.preventDefault()
|
||||
handleLocalFileUpload(file)
|
||||
}
|
||||
}, [handleLocalFileUpload])
|
||||
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
const handleDragFileEnter = useCallback((e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(true)
|
||||
}, [])
|
||||
|
||||
const handleDragFileOver = useCallback((e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDragFileLeave = useCallback((e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleDropFile = useCallback((e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
|
||||
const file = e.dataTransfer.files[0]
|
||||
|
||||
if (file)
|
||||
handleLocalFileUpload(file)
|
||||
}, [handleLocalFileUpload])
|
||||
|
||||
return {
|
||||
handleAddFile,
|
||||
handleUpdateFile,
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
handleLoadFileFromLink,
|
||||
handleLoadFileFromLinkSuccess,
|
||||
handleLoadFileFromLinkError,
|
||||
handleClearFiles,
|
||||
handleLocalFileUpload,
|
||||
handleClipboardPasteFile,
|
||||
isDragActive,
|
||||
handleDragFileEnter,
|
||||
handleDragFileOver,
|
||||
handleDragFileLeave,
|
||||
handleDropFile,
|
||||
}
|
||||
}
|
||||
7
app/components/base/file-uploader/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { default as FileUploaderInAttachmentWrapper } from './file-uploader-in-attachment'
|
||||
export { default as FileItemInAttachment } from './file-uploader-in-attachment/file-item'
|
||||
export { default as FileUploaderInChatInput } from './file-uploader-in-chat-input'
|
||||
export { default as FileTypeIcon } from './file-type-icon'
|
||||
export { FileListInChatInput } from './file-uploader-in-chat-input/file-list'
|
||||
export { FileList } from './file-uploader-in-chat-input/file-list'
|
||||
export { default as FileItem } from './file-uploader-in-chat-input/file-item'
|
||||
102
app/components/base/file-uploader/pdf-preview.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import 'react-pdf-highlighter/dist/style.css'
|
||||
import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
|
||||
import { t } from 'i18next'
|
||||
import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
|
||||
import React, { useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type PdfPreviewProps = {
|
||||
url: string
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const PdfPreview: FC<PdfPreviewProps> = ({
|
||||
url,
|
||||
onCancel,
|
||||
}) => {
|
||||
const media = useBreakpoints()
|
||||
const [scale, setScale] = useState(1)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const isMobile = media === MediaType.mobile
|
||||
|
||||
const zoomIn = () => {
|
||||
setScale(prevScale => Math.min(prevScale * 1.2, 15))
|
||||
setPosition({ x: position.x - 50, y: position.y - 50 })
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
setScale((prevScale) => {
|
||||
const newScale = Math.max(prevScale / 1.2, 0.5)
|
||||
if (newScale === 1)
|
||||
setPosition({ x: 0, y: 0 })
|
||||
else
|
||||
setPosition({ x: position.x + 50, y: position.y + 50 })
|
||||
|
||||
return newScale
|
||||
})
|
||||
}
|
||||
|
||||
useHotkeys('esc', onCancel)
|
||||
useHotkeys('up', zoomIn)
|
||||
useHotkeys('down', zoomOut)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 flex items-center justify-center bg-black/80 z-[1000] ${!isMobile && 'p-8'}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className='h-[95vh] w-[100vw] max-w-full max-h-full overflow-hidden'
|
||||
style={{ transform: `scale(${scale})`, transformOrigin: 'center', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<PdfLoader
|
||||
workerSrc='/pdf.worker.min.mjs'
|
||||
url={url}
|
||||
beforeLoad={<div className='flex justify-center items-center h-64'><Loading type='app' /></div>}
|
||||
>
|
||||
{(pdfDocument) => {
|
||||
return (
|
||||
<PdfHighlighter
|
||||
pdfDocument={pdfDocument}
|
||||
enableAreaSelection={event => event.altKey}
|
||||
scrollRef={() => { }}
|
||||
onScrollChange={() => { }}
|
||||
onSelectionFinished={() => null}
|
||||
highlightTransform={() => { return <div/> }}
|
||||
highlights={[]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</PdfLoader>
|
||||
</div>
|
||||
<Tooltip popupContent={t('common.operation.zoomOut')}>
|
||||
<div className='absolute top-6 right-24 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
|
||||
onClick={zoomOut}>
|
||||
<RiZoomOutLine className='w-4 h-4 text-gray-500'/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.zoomIn')}>
|
||||
<div className='absolute top-6 right-16 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
|
||||
onClick={zoomIn}>
|
||||
<RiZoomInLine className='w-4 h-4 text-gray-500'/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.cancel')}>
|
||||
<div
|
||||
className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/8 rounded-lg backdrop-blur-[2px] cursor-pointer'
|
||||
onClick={onCancel}>
|
||||
<RiCloseLine className='w-4 h-4 text-gray-500'/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default PdfPreview
|
||||
67
app/components/base/file-uploader/store.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
create,
|
||||
useStore as useZustandStore,
|
||||
} from 'zustand'
|
||||
import type {
|
||||
FileEntity,
|
||||
} from './types'
|
||||
|
||||
type Shape = {
|
||||
files: FileEntity[]
|
||||
setFiles: (files: FileEntity[]) => void
|
||||
}
|
||||
|
||||
export const createFileStore = (
|
||||
value: FileEntity[] = [],
|
||||
onChange?: (files: FileEntity[]) => void,
|
||||
) => {
|
||||
return create<Shape>(set => ({
|
||||
files: [...value],
|
||||
setFiles: (files) => {
|
||||
set({ files })
|
||||
onChange?.(files)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
type FileStore = ReturnType<typeof createFileStore>
|
||||
export const FileContext = createContext<FileStore | null>(null)
|
||||
|
||||
export function useStore<T>(selector: (state: Shape) => T): T {
|
||||
const store = useContext(FileContext)
|
||||
if (!store)
|
||||
throw new Error('Missing FileContext.Provider in the tree')
|
||||
|
||||
return useZustandStore(store, selector)
|
||||
}
|
||||
|
||||
export const useFileStore = () => {
|
||||
return useContext(FileContext)!
|
||||
}
|
||||
|
||||
type FileProviderProps = {
|
||||
children: React.ReactNode
|
||||
value?: FileEntity[]
|
||||
onChange?: (files: FileEntity[]) => void
|
||||
}
|
||||
export const FileContextProvider = ({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
}: FileProviderProps) => {
|
||||
const storeRef = useRef<FileStore>()
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createFileStore(value, onChange)
|
||||
|
||||
return (
|
||||
<FileContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</FileContext.Provider>
|
||||
)
|
||||
}
|
||||
33
app/components/base/file-uploader/types.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { TransferMethod } from '@/types/app'
|
||||
|
||||
export enum FileAppearanceTypeEnum {
|
||||
image = 'image',
|
||||
video = 'video',
|
||||
audio = 'audio',
|
||||
document = 'document',
|
||||
code = 'code',
|
||||
pdf = 'pdf',
|
||||
markdown = 'markdown',
|
||||
excel = 'excel',
|
||||
word = 'word',
|
||||
ppt = 'ppt',
|
||||
gif = 'gif',
|
||||
custom = 'custom',
|
||||
}
|
||||
|
||||
export type FileAppearanceType = keyof typeof FileAppearanceTypeEnum
|
||||
|
||||
export type FileEntity = {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
progress: number
|
||||
transferMethod: TransferMethod
|
||||
supportFileType: string
|
||||
originalFile?: File
|
||||
uploadedId?: string
|
||||
base64Url?: string
|
||||
url?: string
|
||||
isRemote?: boolean
|
||||
}
|
||||
197
app/components/base/file-uploader/utils.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import mime from 'mime'
|
||||
import { FileAppearanceTypeEnum } from './types'
|
||||
import type { FileEntity } from './types'
|
||||
import { upload } from '@/service/base'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type FileUploadParams = {
|
||||
file: File
|
||||
onProgressCallback: (progress: number) => void
|
||||
onSuccessCallback: (res: { id: string }) => void
|
||||
onErrorCallback: () => void
|
||||
}
|
||||
type FileUpload = (v: FileUploadParams, isPublic?: boolean, url?: string) => void
|
||||
export const fileUpload: FileUpload = ({
|
||||
file,
|
||||
onProgressCallback,
|
||||
onSuccessCallback,
|
||||
onErrorCallback,
|
||||
}, isPublic, url) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const onProgress = (e: ProgressEvent) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.floor(e.loaded / e.total * 100)
|
||||
onProgressCallback(percent)
|
||||
}
|
||||
}
|
||||
// TODO
|
||||
upload({
|
||||
xhr: new XMLHttpRequest(),
|
||||
data: formData,
|
||||
onprogress: onProgress,
|
||||
}, isPublic, url)
|
||||
.then((res) => {
|
||||
// debugger
|
||||
onSuccessCallback(res)
|
||||
})
|
||||
.catch(() => {
|
||||
onErrorCallback()
|
||||
})
|
||||
}
|
||||
|
||||
export const getFileExtension = (fileName: string, fileMimetype: string, isRemote?: boolean) => {
|
||||
let extension = ''
|
||||
if (fileMimetype)
|
||||
extension = mime.getExtension(fileMimetype) || ''
|
||||
|
||||
if (fileName && !extension) {
|
||||
const fileNamePair = fileName.split('.')
|
||||
const fileNamePairLength = fileNamePair.length
|
||||
|
||||
if (fileNamePairLength > 1)
|
||||
extension = fileNamePair[fileNamePairLength - 1]
|
||||
else
|
||||
extension = ''
|
||||
}
|
||||
|
||||
if (isRemote)
|
||||
extension = ''
|
||||
|
||||
return extension
|
||||
}
|
||||
|
||||
export const getFileAppearanceType = (fileName: string, fileMimetype: string) => {
|
||||
const extension = getFileExtension(fileName, fileMimetype)
|
||||
|
||||
if (extension === 'gif')
|
||||
return FileAppearanceTypeEnum.gif
|
||||
|
||||
if (FILE_EXTS.image.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.image
|
||||
|
||||
if (FILE_EXTS.video.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.video
|
||||
|
||||
if (FILE_EXTS.audio.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.audio
|
||||
|
||||
if (extension === 'html')
|
||||
return FileAppearanceTypeEnum.code
|
||||
|
||||
if (extension === 'pdf')
|
||||
return FileAppearanceTypeEnum.pdf
|
||||
|
||||
if (extension === 'md' || extension === 'markdown' || extension === 'mdx')
|
||||
return FileAppearanceTypeEnum.markdown
|
||||
|
||||
if (extension === 'xlsx' || extension === 'xls')
|
||||
return FileAppearanceTypeEnum.excel
|
||||
|
||||
if (extension === 'docx' || extension === 'doc')
|
||||
return FileAppearanceTypeEnum.word
|
||||
|
||||
if (extension === 'pptx' || extension === 'ppt')
|
||||
return FileAppearanceTypeEnum.ppt
|
||||
|
||||
if (FILE_EXTS.document.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.document
|
||||
|
||||
return FileAppearanceTypeEnum.custom
|
||||
}
|
||||
|
||||
export const getSupportFileType = (fileName: string, fileMimetype: string, isCustom?: boolean) => {
|
||||
if (isCustom)
|
||||
return SupportUploadFileTypes.custom
|
||||
|
||||
const extension = getFileExtension(fileName, fileMimetype)
|
||||
for (const key in FILE_EXTS) {
|
||||
if ((FILE_EXTS[key]).includes(extension.toUpperCase()))
|
||||
return key
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getProcessedFiles = (files: FileEntity[]) => {
|
||||
return files.filter(file => file.progress !== -1).map(fileItem => ({
|
||||
type: fileItem.supportFileType,
|
||||
transfer_method: fileItem.transferMethod,
|
||||
url: fileItem.url || '',
|
||||
upload_file_id: fileItem.uploadedId || '',
|
||||
}))
|
||||
}
|
||||
|
||||
export const getProcessedFilesFromResponse = (files: FileResponse[]) => {
|
||||
return files.map((fileItem) => {
|
||||
return {
|
||||
id: fileItem.related_id,
|
||||
name: fileItem.filename,
|
||||
size: fileItem.size || 0,
|
||||
type: fileItem.mime_type,
|
||||
progress: 100,
|
||||
transferMethod: fileItem.transfer_method,
|
||||
supportFileType: fileItem.type,
|
||||
uploadedId: fileItem.related_id,
|
||||
url: fileItem.url,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getFileNameFromUrl = (url: string) => {
|
||||
const urlParts = url.split('/')
|
||||
return urlParts[urlParts.length - 1] || ''
|
||||
}
|
||||
|
||||
export const getSupportFileExtensionList = (allowFileTypes: string[], allowFileExtensions: string[]) => {
|
||||
if (allowFileTypes.includes(SupportUploadFileTypes.custom))
|
||||
return allowFileExtensions.map(item => item.slice(1).toUpperCase())
|
||||
|
||||
return allowFileTypes.map(type => FILE_EXTS[type]).flat()
|
||||
}
|
||||
|
||||
export const isAllowedFileExtension = (fileName: string, fileMimetype: string, allowFileTypes: string[], allowFileExtensions: string[]) => {
|
||||
return getSupportFileExtensionList(allowFileTypes, allowFileExtensions).includes(getFileExtension(fileName, fileMimetype).toUpperCase())
|
||||
}
|
||||
|
||||
export const getFilesInLogs = (rawData: any) => {
|
||||
const result = Object.keys(rawData || {}).map((key) => {
|
||||
if (typeof rawData[key] === 'object' && rawData[key]?.dify_model_identity === '__dify__file__') {
|
||||
return {
|
||||
varName: key,
|
||||
list: getProcessedFilesFromResponse([rawData[key]]),
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rawData[key]) && rawData[key].some(item => item?.dify_model_identity === '__dify__file__')) {
|
||||
return {
|
||||
varName: key,
|
||||
list: getProcessedFilesFromResponse(rawData[key]),
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}).filter(Boolean)
|
||||
return result
|
||||
}
|
||||
|
||||
export const fileIsUploaded = (file: FileEntity) => {
|
||||
if (file.uploadedId)
|
||||
return true
|
||||
|
||||
if (file.transferMethod === TransferMethod.remote_url && file.progress === 100)
|
||||
return true
|
||||
}
|
||||
|
||||
export const downloadFile = (url: string, filename: string) => {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.style.display = 'none'
|
||||
anchor.target = '_blank'
|
||||
anchor.title = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
}
|
||||
45
app/components/base/file-uploader/video-preview.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import React from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
type VideoPreviewProps = {
|
||||
url: string
|
||||
title: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const VideoPreview: FC<VideoPreviewProps> = ({
|
||||
url,
|
||||
title,
|
||||
onCancel,
|
||||
}) => {
|
||||
useHotkeys('esc', onCancel)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]'
|
||||
onClick={e => e.stopPropagation()}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div>
|
||||
<video controls title={title} autoPlay={false} preload="metadata">
|
||||
<source
|
||||
type="video/mp4"
|
||||
src={url}
|
||||
className='max-w-full max-h-full'
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
<div
|
||||
className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='w-4 h-4 text-gray-500'/>
|
||||
</div>
|
||||
</div>
|
||||
, document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoPreview
|
||||
33
app/components/base/icons/IconBase.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { generate } from './utils'
|
||||
import type { AbstractNode } from './utils'
|
||||
|
||||
export type IconData = {
|
||||
name: string
|
||||
icon: AbstractNode
|
||||
}
|
||||
|
||||
export type IconBaseProps = {
|
||||
data: IconData
|
||||
className?: string
|
||||
onClick?: React.MouseEventHandler<SVGElement>
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const IconBase = forwardRef<React.MutableRefObject<HTMLOrSVGElement>, IconBaseProps>((props, ref) => {
|
||||
const { data, className, onClick, style, ...restProps } = props
|
||||
|
||||
return generate(data.icon, `svg-${data.name}`, {
|
||||
className,
|
||||
onClick,
|
||||
style,
|
||||
'data-icon': data.name,
|
||||
'aria-hidden': 'true',
|
||||
...restProps,
|
||||
'ref': ref,
|
||||
})
|
||||
})
|
||||
|
||||
IconBase.displayName = 'IconBase'
|
||||
|
||||
export default IconBase
|
||||
BIN
app/components/base/icons/assets/image/llm/baichuan-text-cn.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
app/components/base/icons/assets/image/llm/minimax-text.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
app/components/base/icons/assets/image/llm/minimax.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
app/components/base/icons/assets/image/llm/tongyi-text-cn.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
app/components/base/icons/assets/image/llm/tongyi-text.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
app/components/base/icons/assets/image/llm/tongyi.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/components/base/icons/assets/image/llm/wxyy-text-cn.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
app/components/base/icons/assets/image/llm/wxyy-text.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
app/components/base/icons/assets/image/llm/wxyy.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
11
app/components/base/icons/assets/public/avatar/robot.svg
Normal file
|
After Width: | Height: | Size: 36 KiB |
12
app/components/base/icons/assets/public/avatar/user.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5968_39205)">
|
||||
<rect width="512" height="512" rx="256" fill="#B2DDFF"/>
|
||||
<circle opacity="0.68" cx="256" cy="196" r="84" fill="white"/>
|
||||
<ellipse opacity="0.68" cx="256" cy="583.5" rx="266" ry="274.5" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5968_39205">
|
||||
<rect width="512" height="512" rx="256" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
14
app/components/base/icons/assets/public/billing/sparkles.svg
Normal file
|
After Width: | Height: | Size: 364 KiB |
16
app/components/base/icons/assets/public/common/d.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 1H7.94339C11.8094 1 14.9434 4.13401 14.9434 8C14.9434 11.866 11.8094 15 7.9434 15H2V1Z" fill="white"/>
|
||||
<path d="M2 1H7.94339C11.8094 1 14.9434 4.13401 14.9434 8C14.9434 11.866 11.8094 15 7.9434 15H2V1Z" fill="url(#paint0_angular_19344_240446)"/>
|
||||
<path d="M7.94336 8H8.20751V15H7.94336V8Z" fill="url(#paint1_linear_19344_240446)"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_angular_19344_240446" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(7.9434 8) rotate(90) scale(8.75 8.75)">
|
||||
<stop stop-color="#001FC2"/>
|
||||
<stop offset="0.711334" stop-color="#0667F8" stop-opacity="0.2"/>
|
||||
<stop offset="1" stop-color="#155EEF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="paint1_linear_19344_240446" x1="8.06244" y1="8.43754" x2="7.93744" y2="9.20317" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@ -0,0 +1,3 @@
|
||||
<svg width="7" height="20" viewBox="0 0 7 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Line 3" d="M1 19.3544L5.94174 0.645657" stroke="#EAECF0" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 193 B |
8
app/components/base/icons/assets/public/common/dify.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="50" height="26" viewBox="0 0 50 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Dify">
|
||||
<path d="M6.61784 2.064C8.37784 2.064 9.92184 2.408 11.2498 3.096C12.5938 3.784 13.6258 4.768 14.3458 6.048C15.0818 7.312 15.4498 8.784 15.4498 10.464C15.4498 12.144 15.0818 13.616 14.3458 14.88C13.6258 16.128 12.5938 17.096 11.2498 17.784C9.92184 18.472 8.37784 18.816 6.61784 18.816H0.761841V2.064H6.61784ZM6.49784 15.96C8.25784 15.96 9.61784 15.48 10.5778 14.52C11.5378 13.56 12.0178 12.208 12.0178 10.464C12.0178 8.72 11.5378 7.36 10.5778 6.384C9.61784 5.392 8.25784 4.896 6.49784 4.896H4.12184V15.96H6.49784Z" fill="#1D2939"/>
|
||||
<path d="M20.869 3.936C20.277 3.936 19.781 3.752 19.381 3.384C18.997 3 18.805 2.528 18.805 1.968C18.805 1.408 18.997 0.944 19.381 0.576C19.781 0.192 20.277 0 20.869 0C21.461 0 21.949 0.192 22.333 0.576C22.733 0.944 22.933 1.408 22.933 1.968C22.933 2.528 22.733 3 22.333 3.384C21.949 3.752 21.461 3.936 20.869 3.936ZM22.525 5.52V18.816H19.165V5.52H22.525Z" fill="#1D2939"/>
|
||||
<path d="M33.1407 8.28H30.8127V18.816H27.4047V8.28H25.8927V5.52H27.4047V4.848C27.4047 3.216 27.8687 2.016 28.7967 1.248C29.7247 0.48 31.1247 0.12 32.9967 0.168001V3C32.1807 2.984 31.6127 3.12 31.2927 3.408C30.9727 3.696 30.8127 4.216 30.8127 4.968V5.52H33.1407V8.28Z" fill="#1D2939"/>
|
||||
<path d="M49.2381 5.52L41.0061 25.104H37.4301L40.3101 18.48L34.9821 5.52H38.7501L42.1821 14.808L45.6621 5.52H49.2381Z" fill="#1D2939"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,5 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="github">
|
||||
<path id="Vector" d="M9 1.125C4.64906 1.125 1.125 4.64906 1.125 9C1.125 12.4847 3.37922 15.428 6.50953 16.4714C6.90328 16.5403 7.05094 16.3041 7.05094 16.0973C7.05094 15.9103 7.04109 15.2902 7.04109 14.6306C5.0625 14.9948 4.55062 14.1483 4.39312 13.7053C4.30453 13.4789 3.92063 12.78 3.58594 12.593C3.31031 12.4453 2.91656 12.0811 3.57609 12.0712C4.19625 12.0614 4.63922 12.6422 4.78688 12.8784C5.49563 14.0695 6.62766 13.7348 7.08047 13.5281C7.14938 13.0163 7.35609 12.6717 7.5825 12.4748C5.83031 12.278 3.99938 11.5987 3.99938 8.58656C3.99938 7.73016 4.30453 7.02141 4.80656 6.47016C4.72781 6.27328 4.45219 5.46609 4.88531 4.38328C4.88531 4.38328 5.54484 4.17656 7.05094 5.19047C7.68094 5.01328 8.35031 4.92469 9.01969 4.92469C9.68906 4.92469 10.3584 5.01328 10.9884 5.19047C12.4945 4.16672 13.1541 4.38328 13.1541 4.38328C13.5872 5.46609 13.3116 6.27328 13.2328 6.47016C13.7348 7.02141 14.04 7.72031 14.04 8.58656C14.04 11.6086 12.1992 12.278 10.447 12.4748C10.7325 12.7209 10.9786 13.1934 10.9786 13.9317C10.9786 14.985 10.9688 15.8316 10.9688 16.0973C10.9688 16.3041 11.1164 16.5502 11.5102 16.4714C13.0735 15.9436 14.432 14.9389 15.3943 13.5986C16.3567 12.2583 16.8746 10.65 16.875 9C16.875 4.64906 13.3509 1.125 9 1.125Z" fill="#24292F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="46" height="24" viewBox="0 0 46 24" fill="none">
|
||||
<path opacity="0.5" d="M-6.5 8C-6.5 3.58172 -2.91828 0 1.5 0H45.5L33.0248 24H1.49999C-2.91829 24 -6.5 20.4183 -6.5 16V8Z" fill="url(#paint0_linear_6333_42118)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_6333_42118" x1="1.81679" y1="5.47784e-07" x2="101.257" y2="30.3866" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0.12"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 561 B |