Initial commit

This commit is contained in:
饺子大魔王 2025-04-11 08:14:27 +08:00
commit 626af60456
1540 changed files with 110511 additions and 0 deletions

22
.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.

View 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)
}
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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,
})
}
}

View 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)
}
}

View 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
View 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([])
}
}

View 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)
}

View 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
View 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
View 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([])
}
}

View 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
View 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)

View 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)

View 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 })),
}))

View 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
}
}

View 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 }

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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;
}

View 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)

View 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)
}
}

View 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
}
}

View 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

View 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;
}

View 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;
}

View 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

View 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)

View 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

View 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
}
}

View 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 }

View 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)

View 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;
}
}

View 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 }

View 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)

View 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

View 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);
}

View 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>
)
}

View 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);
}

View 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

View 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

View 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

View File

@ -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

View 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

View 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

View 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

View 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):\/\//

View 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

View File

@ -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)

View 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

View 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

View 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

View 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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}
/>
)
}

View File

@ -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)

View 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,
}
}

View 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'

View 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

View 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>
)
}

View 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
}

View 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)
}

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 364 KiB

View 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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More