mirror of
https://github.com/langgenius/webapp-conversation.git
synced 2026-02-02 00:25:19 +08:00
Compare commits
76 Commits
fix/not-sh
...
510ac2afaa
| Author | SHA1 | Date | |
|---|---|---|---|
| 510ac2afaa | |||
| b26ffc7f1a | |||
| 123d55694a | |||
| 0d220f5a54 | |||
| ee5ed029bb | |||
| 6b0302e093 | |||
| 55a77ea86d | |||
| c4b8029702 | |||
| aabdcdb3df | |||
| 71a13ba418 | |||
| 6a8f4e6db1 | |||
| 9b1288fc96 | |||
| 518ec4fb9d | |||
| 12e52b5d7e | |||
| 238611f547 | |||
| d28e2c29fc | |||
| 34bb34f890 | |||
| 18a4229464 | |||
| 0cb8fdcf15 | |||
| 532aba026a | |||
| 7a07e8d56b | |||
| c907432782 | |||
| 672ad29e5d | |||
| 2d426902bd | |||
| 3025356fa7 | |||
| ba95a431b3 | |||
| 9ff81c0306 | |||
| f809b706a0 | |||
| 05dcfcf0ca | |||
| 2b1882a5e3 | |||
| d3909927af | |||
| 17007b2014 | |||
| e91a1f6194 | |||
| 74a656fda2 | |||
| 8f02afed98 | |||
| dc1659463e | |||
| b35f0effe5 | |||
| 60a33804cc | |||
| 7495bf44a2 | |||
| d009b00012 | |||
| 1249ea88c9 | |||
| d3482db74d | |||
| beda954867 | |||
| 4ae03c2101 | |||
| 7216f40bee | |||
| e9923e8220 | |||
| 9a7e1be35d | |||
| db4e78d796 | |||
| 87c81b99dd | |||
| a8ea35e5c6 | |||
| 41406a8596 | |||
| 9d2d092e9e | |||
| 1f5607221a | |||
| 009674b231 | |||
| 25ef02d2aa | |||
| f6f65cff68 | |||
| 8c6302d1fc | |||
| 291e9a067b | |||
| ac0e3e807d | |||
| b7f703852e | |||
| ef15747e4a | |||
| f9bd745bb0 | |||
| e2b37c1a9c | |||
| 0f490de7ff | |||
| aaeb440210 | |||
| b45262add9 | |||
| 368c6b3dae | |||
| f6fb9c7cea | |||
| 69044eb8a3 | |||
| 1c12b1dce3 | |||
| 94d09ed23b | |||
| 5d313f7463 | |||
| 97203f5ac6 | |||
| 349e081f1f | |||
| 7f24387eef | |||
| 5a1c84e79f |
16
.cursor/rules/api-client.mdc
Normal file
16
.cursor/rules/api-client.mdc
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
description: API client usage and streaming
|
||||
---
|
||||
|
||||
### API Client
|
||||
|
||||
- Use domain functions in `[service/index.ts](mdc:service/index.ts)` for app features.
|
||||
- Prefer `get/post/put/del` from `[service/base.ts](mdc:service/base.ts)`; they apply base options, timeout, and error toasts.
|
||||
- Set request bodies via `options.body`; it will be JSON-stringified automatically.
|
||||
- Add query via `options.params` on GET.
|
||||
- Downloads: set `Content-type` header to `application/octet-stream`.
|
||||
|
||||
### SSE Streaming
|
||||
|
||||
- For streaming responses, use `ssePost` from `[service/base.ts](mdc:service/base.ts)` and supply callbacks: `onData`, `onCompleted`, `onThought`, `onFile`, `onMessageEnd`, `onMessageReplace`, `onWorkflowStarted`, `onNodeStarted`, `onNodeFinished`, `onWorkflowFinished`, `onError`.
|
||||
- Chat messages helper: `sendChatMessage` in `[service/index.ts](mdc:service/index.ts)` preconfigures streaming.
|
||||
10
.cursor/rules/i18n.mdc
Normal file
10
.cursor/rules/i18n.mdc
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
description: i18n usage and locale resolution
|
||||
---
|
||||
|
||||
### i18n
|
||||
|
||||
- Server locale: `getLocaleOnServer()` reads cookie or negotiates from headers: `[i18n/server.ts](mdc:i18n/server.ts)`.
|
||||
- Client locale: use `getLocaleOnClient()` / `setLocaleOnClient()` in `[i18n/client.ts](mdc:i18n/client.ts)`; uses `LOCALE_COOKIE_NAME` from config.
|
||||
- Place translations in `i18n/lang/**`. Keep keys synchronized across locales.
|
||||
- Render `<html lang>` using the resolved locale in `[app/layout.tsx](mdc:app/layout.tsx)`.
|
||||
21
.cursor/rules/project-structure.mdc
Normal file
21
.cursor/rules/project-structure.mdc
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
### Project Structure Overview
|
||||
|
||||
- **App Router (Next.js 14)**: Entry is `app/layout.tsx` and `app/page.tsx`.
|
||||
- **API Routes**: Located under `app/api/**`. Server handlers live in `route.ts` files per folder.
|
||||
- **Components**: UI under `app/components/**` with feature folders (e.g., `chat`, `workflow`, `base`).
|
||||
- **Services (API client)**: Client-side HTTP/SSE utilities in `[service/base.ts](mdc:service/base.ts)` and domain methods in `[service/index.ts](mdc:service/index.ts)`.
|
||||
- **Config**: Global config in `[config/index.ts](mdc:config/index.ts)` and Next config in `[next.config.js](mdc:next.config.js)`.
|
||||
- **i18n**: Client/server helpers in `[i18n/client.ts](mdc:i18n/client.ts)` and `[i18n/server.ts](mdc:i18n/server.ts)`, with resources in `i18n/lang/**`.
|
||||
- **Styles**: Tailwind setup `[tailwind.config.js](mdc:tailwind.config.js)`, global styles under `app/styles/**`.
|
||||
|
||||
Key entrypoints:
|
||||
|
||||
- Layout: `[app/layout.tsx](mdc:app/layout.tsx)`
|
||||
- Home page: `[app/page.tsx](mdc:app/page.tsx)`
|
||||
- HTTP utilities: `[service/base.ts](mdc:service/base.ts)`
|
||||
- API domain functions: `[service/index.ts](mdc:service/index.ts)`
|
||||
- Internationalization: `[i18n/server.ts](mdc:i18n/server.ts)`, `[i18n/client.ts](mdc:i18n/client.ts)`
|
||||
20
.cursor/rules/typescript-react.mdc
Normal file
20
.cursor/rules/typescript-react.mdc
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
globs: *.ts,*.tsx
|
||||
---
|
||||
|
||||
### TypeScript/React Conventions
|
||||
|
||||
- **Strict TS**: `strict: true` in `tsconfig.json`. Avoid `any`. Prefer explicit function signatures for exports.
|
||||
- **Paths**: Use `@/*` alias (tsconfig `paths`) for absolute imports.
|
||||
- **React 18**: Prefer function components. Use `React.memo` only for measurable perf wins.
|
||||
- **Hooks**: Co-locate hooks under `hooks/**`. Keep hook names prefixed with `use`.
|
||||
- **App Router**: Server components by default. Mark client components with `'use client'` when needed.
|
||||
- **Styling**: Tailwind-first; SCSS only where necessary.
|
||||
- **Classnames**: Use `classnames` or `tailwind-merge` for conditional classes.
|
||||
- **Control Flow**: Early returns, handle edge cases first; avoid deep nesting.
|
||||
|
||||
### Next.js Notes
|
||||
|
||||
- Route handlers belong in `app/api/**/route.ts`.
|
||||
- Do not import server-only modules into client components.
|
||||
- Keep environment access to server files; avoid exposing secrets.
|
||||
11
.cursor/rules/ui-components.mdc
Normal file
11
.cursor/rules/ui-components.mdc
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
description: UI components and styling conventions
|
||||
---
|
||||
|
||||
### UI Components
|
||||
|
||||
- Component folders under `app/components/**`; keep base primitives in `base/**` (buttons, icons, inputs, uploader, etc.).
|
||||
- Larger features (chat, workflow) live in their own folders with `index.tsx` and submodules.
|
||||
- Prefer colocated `style.module.css` or Tailwind classes. Global styles in `app/styles/**`.
|
||||
- Use `app/components/base/toast` for error/display notifications.
|
||||
- Avoid unnecessary client components; mark with `'use client'` only when needed (state, effects, browser APIs).
|
||||
@ -1,28 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"@antfu",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/consistent-type-definitions": [
|
||||
"error",
|
||||
"type"
|
||||
],
|
||||
"no-console": "off",
|
||||
"indent": "off",
|
||||
"@typescript-eslint/indent": [
|
||||
"error",
|
||||
2,
|
||||
{
|
||||
"SwitchCase": 1,
|
||||
"flatTernaryExpressions": false,
|
||||
"ignoredNodes": [
|
||||
"PropertyDefinition[decorators]",
|
||||
"TSUnionType",
|
||||
"FunctionExpression[params]:has(Identifier[decorators])"
|
||||
]
|
||||
}
|
||||
],
|
||||
"react-hooks/exhaustive-deps": "warn"
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -47,5 +47,7 @@ package-lock.json
|
||||
yarn.lock
|
||||
.yarnrc.yml
|
||||
|
||||
# mcp
|
||||
.serena
|
||||
# pmpm
|
||||
pnpm-lock.yaml
|
||||
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@ -0,0 +1 @@
|
||||
pnpm lint-staged
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -4,7 +4,7 @@
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"[python]": {
|
||||
"editor.formatOnType": true
|
||||
@ -29,4 +29,4 @@
|
||||
"i18n/lang",
|
||||
"app/api/messages"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
Dockerfile
17
Dockerfile
@ -1,12 +1,17 @@
|
||||
FROM --platform=linux/amd64 node:19-bullseye-slim
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
RUN yarn install
|
||||
FROM deps AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN yarn build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["yarn","start"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
10
README.md
10
README.md
@ -4,11 +4,15 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
|
||||
## 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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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=
|
||||
```
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
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 }
|
||||
params: Promise<{ conversationId: string }>
|
||||
}) {
|
||||
const body = await request.json()
|
||||
const {
|
||||
auto_generate,
|
||||
name,
|
||||
} = body
|
||||
const { conversationId } = params
|
||||
const { conversationId } = await params
|
||||
const { user } = getInfo(request)
|
||||
|
||||
// auto generate name
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { type NextRequest } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo, setSession } from '@/app/api/utils/common'
|
||||
|
||||
@ -11,7 +9,11 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(data, {
|
||||
headers: setSession(sessionId),
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
catch (error: any) {
|
||||
return NextResponse.json({
|
||||
data: [],
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { client, getInfo } from '@/app/api/utils/common'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
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 }
|
||||
params: Promise<{ messageId: string }>
|
||||
}) {
|
||||
const body = await request.json()
|
||||
const {
|
||||
rating,
|
||||
} = body
|
||||
const { messageId } = params
|
||||
const { messageId } = await params
|
||||
const { user } = getInfo(request)
|
||||
const { data } = await client.messageFeedback(messageId, rating, user)
|
||||
return NextResponse.json(data)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo, setSession } from '@/app/api/utils/common'
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo, setSession } from '@/app/api/utils/common'
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { ChatClient } from 'dify-client'
|
||||
import { v4 } from 'uuid'
|
||||
import { API_KEY, API_URL, APP_ID } from '@/config'
|
||||
import { API_KEY, API_URL, APP_ID, APP_INFO } from '@/config'
|
||||
|
||||
const userPrefix = `user_${APP_ID}:`
|
||||
|
||||
@ -15,6 +15,9 @@ export const getInfo = (request: NextRequest) => {
|
||||
}
|
||||
|
||||
export const setSession = (sessionId: string) => {
|
||||
if (APP_INFO.disable_session_same_site)
|
||||
{ return { 'Set-Cookie': `session_id=${sessionId}; SameSite=None; Secure` } }
|
||||
|
||||
return { 'Set-Cookie': `session_id=${sessionId}` }
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type IAppUnavailableProps = {
|
||||
interface IAppUnavailableProps {
|
||||
isUnknownReason: boolean
|
||||
errMessage?: string
|
||||
}
|
||||
@ -14,8 +14,7 @@ const AppUnavailable: FC<IAppUnavailableProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
let message = errMessage
|
||||
if (!errMessage)
|
||||
message = (isUnknownReason ? t('app.common.appUnkonwError') : t('app.common.appUnavailable')) as string
|
||||
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'>
|
||||
|
||||
45
app/components/base/action-button/index.css
Normal file
45
app/components/base/action-button/index.css
Normal file
@ -0,0 +1,45 @@
|
||||
@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-hover {
|
||||
@apply 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
|
||||
}
|
||||
|
||||
}
|
||||
73
app/components/base/action-button/index.tsx
Normal file
73
app/components/base/action-button/index.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import React from 'react'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
enum ActionButtonState {
|
||||
Destructive = 'destructive',
|
||||
Active = 'active',
|
||||
Disabled = 'disabled',
|
||||
Default = '',
|
||||
Hover = 'hover',
|
||||
}
|
||||
|
||||
const actionButtonVariants = cva(
|
||||
'action-btn',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'action-btn-xs',
|
||||
m: 'action-btn-m',
|
||||
l: 'action-btn-l',
|
||||
xl: 'action-btn-xl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'm',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ActionButtonProps = {
|
||||
size?: 'xs' | 's' | 'm' | 'l' | 'xl'
|
||||
state?: ActionButtonState
|
||||
styleCss?: CSSProperties
|
||||
} & React.ButtonHTMLAttributes<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'
|
||||
case ActionButtonState.Hover:
|
||||
return 'action-btn-hover'
|
||||
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 }
|
||||
@ -2,7 +2,7 @@ import type { FC } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import style from './style.module.css'
|
||||
|
||||
export type AppIconProps = {
|
||||
export interface AppIconProps {
|
||||
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large'
|
||||
rounded?: boolean
|
||||
icon?: string
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { forwardRef, useEffect, useRef } from 'react'
|
||||
import cn from 'classnames'
|
||||
|
||||
type IProps = {
|
||||
interface IProps {
|
||||
placeholder?: string
|
||||
value: string
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
@ -36,19 +36,16 @@ const AutoHeightTextarea = forwardRef(
|
||||
let hasFocus = false
|
||||
const runId = setInterval(() => {
|
||||
hasFocus = doFocus()
|
||||
if (hasFocus)
|
||||
clearInterval(runId)
|
||||
if (hasFocus) { clearInterval(runId) }
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus)
|
||||
focus()
|
||||
if (autoFocus) { focus() }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (controlFocus)
|
||||
focus()
|
||||
if (controlFocus) { focus() }
|
||||
}, [controlFocus])
|
||||
|
||||
return (
|
||||
|
||||
@ -2,7 +2,7 @@ import type { FC, MouseEventHandler } from 'react'
|
||||
import React from 'react'
|
||||
import Spinner from '@/app/components/base/spinner'
|
||||
|
||||
export type IButtonProps = {
|
||||
export interface IButtonProps {
|
||||
type?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
@ -21,6 +21,9 @@ const Button: FC<IButtonProps> = ({
|
||||
}) => {
|
||||
let style = 'cursor-pointer'
|
||||
switch (type) {
|
||||
case 'link':
|
||||
style = disabled ? 'border-solid border border-gray-200 bg-gray-200 cursor-not-allowed text-gray-800' : 'border-solid border border-gray-200 cursor-pointer text-blue-600 bg-white hover:shadow-sm hover:border-gray-300'
|
||||
break
|
||||
case 'primary':
|
||||
style = (disabled || loading) ? 'bg-primary-600/75 cursor-not-allowed text-white' : 'bg-primary-600 hover:bg-primary-600/75 hover:shadow-md cursor-pointer text-white hover:shadow-sm'
|
||||
break
|
||||
|
||||
16
app/components/base/file-uploader-in-attachment/constants.ts
Normal file
16
app/components/base/file-uploader-in-attachment/constants.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { SupportUploadFileTypes } from './types'
|
||||
// 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):\/\//
|
||||
|
||||
export const FILE_EXTS: Record<string, string[]> = {
|
||||
[SupportUploadFileTypes.image]: ['JPG', 'JPEG', 'PNG', 'GIF', 'WEBP', 'SVG'],
|
||||
[SupportUploadFileTypes.document]: ['TXT', 'MD', 'MDX', 'MARKDOWN', 'PDF', 'HTML', 'XLSX', 'XLS', 'DOC', 'DOCX', 'CSV', 'EML', 'MSG', 'PPTX', 'PPT', 'XML', 'EPUB'],
|
||||
[SupportUploadFileTypes.audio]: ['MP3', 'M4A', 'WAV', 'AMR', 'MPGA'],
|
||||
[SupportUploadFileTypes.video]: ['MP4', 'MOV', 'MPEG', 'WEBM'],
|
||||
}
|
||||
@ -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 type { FileUpload } from '../types'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
interface 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='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg'>
|
||||
{
|
||||
showFromLink && (
|
||||
<>
|
||||
<div className={cn(
|
||||
'flex h-8 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-active p-1 shadow-xs',
|
||||
showError && 'border-components-input-border-destructive',
|
||||
)}>
|
||||
<input
|
||||
className='system-sm-regular mr-0.5 block grow appearance-none bg-transparent px-1 outline-none'
|
||||
placeholder={t('common.fileUploader.pasteFileLinkInputPlaceholder') || ''}
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setShowError(false)
|
||||
setUrl(e.target.value.trim())
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
className='shrink-0'
|
||||
// size='small'
|
||||
// variant='primary'
|
||||
type='primary'
|
||||
disabled={!url || disabled}
|
||||
onClick={handleSaveUrl}
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
{
|
||||
showError && (
|
||||
<div className='body-xs-regular mt-0.5 text-text-destructive'>
|
||||
{t('common.fileUploader.pasteFileLinkInvalid')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFromLink && showFromLocal && (
|
||||
<div className='system-2xs-medium-uppercase flex h-7 items-center p-2 text-text-quaternary'>
|
||||
<div className='mr-2 h-[1px] w-[93px] bg-gradient-to-l from-[rgba(16,24,40,0.08)]' />
|
||||
OR
|
||||
<div className='ml-2 h-[1px] w-[93px] 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 h-4 w-4' />
|
||||
{t('common.fileUploader.uploadFromComputer')}
|
||||
<FileInput fileConfig={fileConfig} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileFromLinkOrLocal)
|
||||
@ -0,0 +1,32 @@
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
interface 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('h-full w-full object-cover', showDownloadAction && 'cursor-pointer')}
|
||||
alt={alt || 'Preview'}
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileImageRender
|
||||
@ -0,0 +1,48 @@
|
||||
import { useFile } from './hooks'
|
||||
import { useStore } from './store'
|
||||
import type { FileUpload } from './types'
|
||||
import { FILE_EXTS } from './constants'
|
||||
import { SupportUploadFileTypes } from './types'
|
||||
|
||||
interface 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 inset-0 block w-full cursor-pointer text-[0] opacity-0 disabled:cursor-not-allowed'
|
||||
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
|
||||
154
app/components/base/file-uploader-in-attachment/file-item.tsx
Normal file
154
app/components/base/file-uploader-in-attachment/file-item.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiDownloadLine,
|
||||
RiEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import FileTypeIcon from './file-type-icon'
|
||||
import FileImageRender from './file-image-render'
|
||||
import type { FileEntity } from './types'
|
||||
import {
|
||||
downloadFile,
|
||||
fileIsUploaded,
|
||||
getFileAppearanceType,
|
||||
getFileExtension,
|
||||
} from './utils'
|
||||
import { SupportUploadFileTypes } 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/other/ReplayLine'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
|
||||
interface 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 h-12 items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pr-3 shadow-xs',
|
||||
progress === -1 && 'border-state-destructive-border bg-state-destructive-hover',
|
||||
)}>
|
||||
<div className='flex h-12 w-12 items-center justify-center'>
|
||||
{
|
||||
isImageFile && (
|
||||
<FileImageRender
|
||||
className='h-8 w-8'
|
||||
imageUrl={base64Url || url || ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isImageFile && (
|
||||
<FileTypeIcon
|
||||
type={getFileAppearanceType(name, type)}
|
||||
size='lg'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className='mr-1 w-0 grow'>
|
||||
<div
|
||||
className='system-xs-medium mb-0.5 flex items-center truncate text-text-secondary'
|
||||
title={file.name}
|
||||
>
|
||||
<div className='truncate'>{name}</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase flex items-center text-text-tertiary'>
|
||||
{
|
||||
ext && (
|
||||
<span>{ext.toLowerCase()}</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
ext && (
|
||||
<span className='system-2xs-medium mx-1'>•</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!file.size && (
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center'>
|
||||
{
|
||||
progress >= 0 && !fileIsUploaded(file) && (
|
||||
<ProgressCircle
|
||||
className='mr-2.5'
|
||||
percentage={progress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
progress === -1 && (
|
||||
<ActionButton
|
||||
className='mr-1'
|
||||
onClick={() => onReUpload?.(id)}
|
||||
>
|
||||
<ReplayLine className='h-4 w-4 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
showDeleteAction && (
|
||||
<ActionButton onClick={() => onRemove?.(id)}>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
canPreview && isImageFile && (
|
||||
<ActionButton className='mr-1' onClick={() => setImagePreviewUrl(url || '')}>
|
||||
<RiEyeLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
showDownloadAction && (
|
||||
<ActionButton onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadFile(url || base64Url || '', name)
|
||||
}}>
|
||||
<RiDownloadLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
imagePreviewUrl && canPreview && (
|
||||
<ImagePreview
|
||||
title={name}
|
||||
url={imagePreviewUrl}
|
||||
onCancel={() => setImagePreviewUrl('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileInAttachmentItem)
|
||||
@ -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]',
|
||||
},
|
||||
}
|
||||
interface 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)
|
||||
361
app/components/base/file-uploader-in-attachment/hooks.ts
Normal file
361
app/components/base/file-uploader-in-attachment/hooks.ts
Normal file
@ -0,0 +1,361 @@
|
||||
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 { noop } from 'lodash-es'
|
||||
import type { FileEntity, FileUpload, FileUploadConfigResponse } 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 './constants'
|
||||
import { SupportUploadFileTypes } from './types'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
const uploadRemoteFileInfo = () => {
|
||||
console.log('TODO')
|
||||
}
|
||||
|
||||
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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [fileStore, notify, t, handleUpdateFile])
|
||||
|
||||
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(noop, [])
|
||||
|
||||
const handleLoadFileFromLinkError = useCallback(noop, [])
|
||||
|
||||
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 })
|
||||
},
|
||||
})
|
||||
},
|
||||
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]
|
||||
const text = e.clipboardData?.getData('text/plain')
|
||||
if (file && !text) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
131
app/components/base/file-uploader-in-attachment/index.tsx
Normal file
131
app/components/base/file-uploader-in-attachment/index.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import {
|
||||
RiLink,
|
||||
RiUploadCloud2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFile } from './hooks'
|
||||
import type { FileEntity, FileUpload } from './types'
|
||||
import FileFromLinkOrLocal from './file-from-link-or-local'
|
||||
import {
|
||||
FileContextProvider,
|
||||
useStore,
|
||||
} from './store'
|
||||
import FileInput from './file-input'
|
||||
import FileItem from './file-item'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
interface Option {
|
||||
value: string
|
||||
label: string
|
||||
icon: JSX.Element
|
||||
}
|
||||
interface 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='h-4 w-4' />,
|
||||
},
|
||||
{
|
||||
value: TransferMethod.remote_url,
|
||||
label: t('common.fileUploader.pasteFileLink'),
|
||||
icon: <RiLink className='h-4 w-4' />,
|
||||
},
|
||||
]
|
||||
|
||||
const renderButton = useCallback((option: Option, open?: boolean) => {
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
// variant='tertiary'
|
||||
className={cn('relative grow', 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>
|
||||
)
|
||||
}
|
||||
|
||||
interface 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
|
||||
65
app/components/base/file-uploader-in-attachment/store.tsx
Normal file
65
app/components/base/file-uploader-in-attachment/store.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
create,
|
||||
useStore as useZustandStore,
|
||||
} from 'zustand'
|
||||
import type {
|
||||
FileEntity,
|
||||
} from './types'
|
||||
|
||||
interface Shape {
|
||||
files: FileEntity[]
|
||||
setFiles: (files: FileEntity[]) => void
|
||||
}
|
||||
|
||||
export const createFileStore = (
|
||||
value: FileEntity[] = [],
|
||||
onChange?: (files: FileEntity[]) => void,
|
||||
) => {
|
||||
return create<Shape>(set => ({
|
||||
files: value ? [...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)!
|
||||
}
|
||||
|
||||
interface FileProviderProps {
|
||||
children: React.ReactNode
|
||||
value?: FileEntity[]
|
||||
onChange?: (files: FileEntity[]) => void
|
||||
}
|
||||
export const FileContextProvider = ({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
}: FileProviderProps) => {
|
||||
const storeRef = useRef<FileStore | undefined>(undefined)
|
||||
|
||||
if (!storeRef.current) { storeRef.current = createFileStore(value, onChange) }
|
||||
|
||||
return (
|
||||
<FileContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</FileContext.Provider>
|
||||
)
|
||||
}
|
||||
83
app/components/base/file-uploader-in-attachment/types.ts
Normal file
83
app/components/base/file-uploader-in-attachment/types.ts
Normal file
@ -0,0 +1,83 @@
|
||||
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 interface 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
|
||||
}
|
||||
|
||||
export interface EnabledOrDisabled {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export enum Resolution {
|
||||
low = 'low',
|
||||
high = 'high',
|
||||
}
|
||||
|
||||
export interface FileUploadConfigResponse {
|
||||
batch_count_limit: number
|
||||
image_file_size_limit?: number | string // default is 10MB
|
||||
file_size_limit: number // default is 15MB
|
||||
audio_file_size_limit?: number // default is 50MB
|
||||
video_file_size_limit?: number // default is 100MB
|
||||
workflow_file_upload_limit?: number // default is 10
|
||||
}
|
||||
|
||||
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 enum SupportUploadFileTypes {
|
||||
image = 'image',
|
||||
document = 'document',
|
||||
audio = 'audio',
|
||||
video = 'video',
|
||||
custom = 'custom',
|
||||
}
|
||||
|
||||
export interface FileResponse {
|
||||
related_id: string
|
||||
extension: string
|
||||
filename: string
|
||||
size: number
|
||||
mime_type: string
|
||||
transfer_method: TransferMethod
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
174
app/components/base/file-uploader-in-attachment/utils.ts
Normal file
174
app/components/base/file-uploader-in-attachment/utils.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import mime from 'mime'
|
||||
import { FileAppearanceTypeEnum, SupportUploadFileTypes } from './types'
|
||||
import type { FileEntity, FileResponse } from './types'
|
||||
import { FILE_EXTS } from './constants'
|
||||
import { upload } from '@/service/base'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
interface 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,
|
||||
}) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
upload({
|
||||
xhr: new XMLHttpRequest(),
|
||||
data: formData,
|
||||
onprogress: onProgress,
|
||||
})
|
||||
.then((res: { id: string }) => {
|
||||
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)
|
||||
}
|
||||
@ -2,12 +2,12 @@ import { forwardRef } from 'react'
|
||||
import { generate } from './utils'
|
||||
import type { AbstractNode } from './utils'
|
||||
|
||||
export type IconData = {
|
||||
export interface IconData {
|
||||
name: string
|
||||
icon: AbstractNode
|
||||
}
|
||||
|
||||
export type IconBaseProps = {
|
||||
export interface IconBaseProps {
|
||||
data: IconData
|
||||
className?: string
|
||||
onClick?: React.MouseEventHandler<SVGElement>
|
||||
|
||||
36
app/components/base/icons/other/ReplayLine.json
Normal file
36
app/components/base/icons/other/ReplayLine.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "20",
|
||||
"height": "20",
|
||||
"viewBox": "0 0 20 20",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "Retry"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "Vector",
|
||||
"d": "M9.99996 1.66669C14.6023 1.66669 18.3333 5.39765 18.3333 10C18.3333 14.6024 14.6023 18.3334 9.99996 18.3334C5.39758 18.3334 1.66663 14.6024 1.66663 10H3.33329C3.33329 13.6819 6.31806 16.6667 9.99996 16.6667C13.6819 16.6667 16.6666 13.6819 16.6666 10C16.6666 6.31812 13.6819 3.33335 9.99996 3.33335C7.70848 3.33335 5.68702 4.48947 4.48705 6.25022L6.66663 6.25002V7.91669H1.66663V2.91669H3.33329L3.3332 4.99934C4.85358 2.97565 7.2739 1.66669 9.99996 1.66669Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "ReplayLine"
|
||||
}
|
||||
20
app/components/base/icons/other/ReplayLine.tsx
Normal file
20
app/components/base/icons/other/ReplayLine.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './ReplayLine.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'ReplayLine'
|
||||
|
||||
export default Icon
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
export type AbstractNode = {
|
||||
export interface AbstractNode {
|
||||
name: string
|
||||
attributes: {
|
||||
[key: string]: string
|
||||
@ -8,7 +8,7 @@ export type AbstractNode = {
|
||||
children?: AbstractNode[]
|
||||
}
|
||||
|
||||
export type Attrs = {
|
||||
export interface Attrs {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import cn from 'classnames'
|
||||
import s from './style.module.css'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
srcs: string[]
|
||||
}
|
||||
|
||||
@ -32,12 +32,15 @@ const ImageGallery: FC<Props> = ({
|
||||
}) => {
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
|
||||
|
||||
const imgNum = srcs.length
|
||||
const validSrcs = srcs.filter(src => src && src.trim() !== '')
|
||||
const imgNum = validSrcs.length
|
||||
const imgStyle = getWidthStyle(imgNum)
|
||||
|
||||
if (imgNum === 0) { return null }
|
||||
|
||||
return (
|
||||
<div className={cn(s[`img-${imgNum}`], 'flex flex-wrap')}>
|
||||
{/* TODO: support preview */}
|
||||
{srcs.map((src, index) => (
|
||||
{validSrcs.map((src, index) => (
|
||||
<img
|
||||
key={index}
|
||||
className={s.item}
|
||||
@ -65,9 +68,9 @@ export const ImageGalleryTest = () => {
|
||||
const imgGallerySrcs = (() => {
|
||||
const srcs = []
|
||||
for (let i = 0; i < 6; i++)
|
||||
// srcs.push('https://placekitten.com/640/360')
|
||||
// srcs.push('https://placekitten.com/360/640')
|
||||
srcs.push('https://placekitten.com/360/360')
|
||||
// srcs.push('https://placekitten.com/640/360')
|
||||
// srcs.push('https://placekitten.com/360/640')
|
||||
{ srcs.push('https://placekitten.com/360/360') }
|
||||
|
||||
return srcs
|
||||
})()
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
import Upload03 from '@/app/components/base/icons/line/upload-03'
|
||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||
|
||||
type UploadOnlyFromLocalProps = {
|
||||
interface UploadOnlyFromLocalProps {
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
disabled?: boolean
|
||||
limit?: number
|
||||
@ -39,7 +39,7 @@ const UploadOnlyFromLocal: FC<UploadOnlyFromLocalProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
type UploaderButtonProps = {
|
||||
interface UploaderButtonProps {
|
||||
methods: VisionSettings['transfer_methods']
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
disabled?: boolean
|
||||
@ -62,8 +62,7 @@ const UploaderButton: FC<UploaderButtonProps> = ({
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled)
|
||||
return
|
||||
if (disabled) { return }
|
||||
|
||||
setOpen(v => !v)
|
||||
}
|
||||
@ -115,7 +114,7 @@ const UploaderButton: FC<UploaderButtonProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
type ChatImageUploaderProps = {
|
||||
interface ChatImageUploaderProps {
|
||||
settings: VisionSettings
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
disabled?: boolean
|
||||
|
||||
@ -5,7 +5,7 @@ import Button from '@/app/components/base/button'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type ImageLinkInputProps = {
|
||||
interface ImageLinkInputProps {
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
}
|
||||
const regex = /^(https?|ftp):\/\//
|
||||
|
||||
@ -10,7 +10,7 @@ import type { ImageFile } from '@/types/app'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
|
||||
type ImageListProps = {
|
||||
interface ImageListProps {
|
||||
list: ImageFile[]
|
||||
readonly?: boolean
|
||||
onRemove?: (imageFileId: string) => void
|
||||
@ -31,12 +31,10 @@ const ImageList: FC<ImageListProps> = ({
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
|
||||
|
||||
const handleImageLinkLoadSuccess = (item: ImageFile) => {
|
||||
if (item.type === TransferMethod.remote_url && onImageLinkLoadSuccess && item.progress !== -1)
|
||||
onImageLinkLoadSuccess(item._id)
|
||||
if (item.type === TransferMethod.remote_url && onImageLinkLoadSuccess && item.progress !== -1) { onImageLinkLoadSuccess(item._id) }
|
||||
}
|
||||
const handleImageLinkLoadError = (item: ImageFile) => {
|
||||
if (item.type === TransferMethod.remote_url && onImageLinkLoadError)
|
||||
onImageLinkLoadError(item._id)
|
||||
if (item.type === TransferMethod.remote_url && onImageLinkLoadError) { onImageLinkLoadError(item._id) }
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -2,7 +2,7 @@ import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import XClose from '@/app/components/base/icons/line/x-close'
|
||||
|
||||
type ImagePreviewProps = {
|
||||
interface ImagePreviewProps {
|
||||
url: string
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import type { ImageFile } from '@/types/app'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
type UploaderProps = {
|
||||
interface UploaderProps {
|
||||
children: (hovering: boolean) => JSX.Element
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
limit?: number
|
||||
@ -28,8 +28,7 @@ const Uploader: FC<UploaderProps> = ({
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
if (!file)
|
||||
return
|
||||
if (!file) { return }
|
||||
|
||||
if (limit && file.size > limit * 1024 * 1024) {
|
||||
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: limit }) })
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { upload } from '@/service/base'
|
||||
|
||||
type ImageUploadParams = {
|
||||
interface ImageUploadParams {
|
||||
file: File
|
||||
onProgressCallback: (progress: number) => void
|
||||
onSuccessCallback: (res: { id: string }) => void
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react'
|
||||
|
||||
import './style.css'
|
||||
|
||||
type ILoadingProps = {
|
||||
interface ILoadingProps {
|
||||
type?: 'area' | 'app'
|
||||
}
|
||||
const Loading = (
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
|
||||
import type { OffsetOptions, Placement } from '@floating-ui/react'
|
||||
|
||||
type PortalToFollowElemOptions = {
|
||||
interface PortalToFollowElemOptions {
|
||||
/*
|
||||
* top, bottom, left, right
|
||||
* start, end. Default is middle
|
||||
@ -85,8 +85,7 @@ const PortalToFollowElemContext = React.createContext<ContextType>(null)
|
||||
export function usePortalToFollowElemContext() {
|
||||
const context = React.useContext(PortalToFollowElemContext)
|
||||
|
||||
if (context == null)
|
||||
throw new Error('PortalToFollowElem components must be wrapped in <PortalToFollowElem />')
|
||||
if (context == null) { throw new Error('PortalToFollowElem components must be wrapped in <PortalToFollowElem />') }
|
||||
|
||||
return context
|
||||
}
|
||||
@ -106,7 +105,7 @@ export function PortalToFollowElem({
|
||||
}
|
||||
|
||||
export const PortalToFollowElemTrigger = React.forwardRef<
|
||||
HTMLElement,
|
||||
HTMLElement,
|
||||
React.HTMLProps<HTMLElement> & { asChild?: boolean }
|
||||
>(({ children, asChild = false, ...props }, propRef) => {
|
||||
const context = usePortalToFollowElemContext()
|
||||
@ -141,14 +140,13 @@ React.HTMLProps<HTMLElement> & { asChild?: boolean }
|
||||
PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
|
||||
|
||||
export const PortalToFollowElemContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLProps<HTMLDivElement>
|
||||
HTMLDivElement,
|
||||
React.HTMLProps<HTMLDivElement>
|
||||
>(({ style, ...props }, propRef) => {
|
||||
const context = usePortalToFollowElemContext()
|
||||
const ref = useMergeRefs([context.refs.setFloating, propRef])
|
||||
|
||||
if (!context.open)
|
||||
return null
|
||||
if (!context.open) { return null }
|
||||
|
||||
return (
|
||||
<FloatingPortal>
|
||||
|
||||
20
app/components/base/progress-bar/index.tsx
Normal file
20
app/components/base/progress-bar/index.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
interface ProgressBarProps {
|
||||
percent: number
|
||||
}
|
||||
const ProgressBar = ({
|
||||
percent = 0,
|
||||
}: ProgressBarProps) => {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-2 w-[100px] rounded-lg bg-gray-100'>
|
||||
<div
|
||||
className='h-1 rounded-lg bg-[#2970FF]'
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className='text-xs font-medium text-gray-500'>{percent}%</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressBar
|
||||
64
app/components/base/progress-bar/progress-circle.tsx
Normal file
64
app/components/base/progress-bar/progress-circle.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { memo } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
interface ProgressCircleProps {
|
||||
className?: string
|
||||
percentage?: number
|
||||
size?: number
|
||||
circleStrokeWidth?: number
|
||||
circleStrokeColor?: string
|
||||
circleFillColor?: string
|
||||
sectorFillColor?: string
|
||||
}
|
||||
|
||||
const ProgressCircle: React.FC<ProgressCircleProps> = ({
|
||||
className,
|
||||
percentage = 0,
|
||||
size = 12,
|
||||
circleStrokeWidth = 1,
|
||||
circleStrokeColor = 'stroke-components-progress-brand-border',
|
||||
circleFillColor = 'fill-components-progress-brand-bg',
|
||||
sectorFillColor = 'fill-components-progress-brand-progress',
|
||||
}) => {
|
||||
const radius = size / 2
|
||||
const center = size / 2
|
||||
const angle = (percentage / 101) * 360
|
||||
const radians = (angle * Math.PI) / 180
|
||||
const x = center + radius * Math.cos(radians - Math.PI / 2)
|
||||
const y = center + radius * Math.sin(radians - Math.PI / 2)
|
||||
const largeArcFlag = percentage > 50 ? 1 : 0
|
||||
|
||||
const pathData = `
|
||||
M ${center},${center}
|
||||
L ${center},${center - radius}
|
||||
A ${radius},${radius} 0 ${largeArcFlag} 1 ${x},${y}
|
||||
Z
|
||||
`
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size + circleStrokeWidth}
|
||||
height={size + circleStrokeWidth}
|
||||
viewBox={`0 0 ${size + circleStrokeWidth} ${size + circleStrokeWidth}`}
|
||||
className={className}
|
||||
>
|
||||
<circle
|
||||
className={cn(
|
||||
circleFillColor,
|
||||
circleStrokeColor,
|
||||
)}
|
||||
cx={center + circleStrokeWidth / 2}
|
||||
cy={center + circleStrokeWidth / 2}
|
||||
r={radius}
|
||||
strokeWidth={circleStrokeWidth}
|
||||
/>
|
||||
<path
|
||||
className={cn(sectorFillColor)}
|
||||
d={pathData}
|
||||
transform={`translate(${circleStrokeWidth / 2}, ${circleStrokeWidth / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ProgressCircle)
|
||||
@ -15,12 +15,12 @@ const defaultItems = [
|
||||
{ value: 7, name: 'option7' },
|
||||
]
|
||||
|
||||
export type Item = {
|
||||
export interface Item {
|
||||
value: number | string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ISelectProps = {
|
||||
export interface ISelectProps {
|
||||
className?: string
|
||||
items?: Item[]
|
||||
defaultValue?: number | string
|
||||
@ -45,8 +45,7 @@ const Select: FC<ISelectProps> = ({
|
||||
useEffect(() => {
|
||||
let defaultSelect = null
|
||||
const existed = items.find((item: Item) => item.value === defaultValue)
|
||||
if (existed)
|
||||
defaultSelect = existed
|
||||
if (existed) { defaultSelect = existed }
|
||||
|
||||
setSelectedItem(defaultSelect)
|
||||
}, [defaultValue])
|
||||
@ -77,23 +76,20 @@ const Select: FC<ISelectProps> = ({
|
||||
? <Combobox.Input
|
||||
className={`w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-not-allowed`}
|
||||
onChange={(event) => {
|
||||
if (!disabled)
|
||||
setQuery(event.target.value)
|
||||
if (!disabled) { setQuery(event.target.value) }
|
||||
}}
|
||||
displayValue={(item: Item) => item?.name}
|
||||
/>
|
||||
: <Combobox.Button onClick={
|
||||
() => {
|
||||
if (!disabled)
|
||||
setOpen(!open)
|
||||
if (!disabled) { setOpen(!open) }
|
||||
}
|
||||
} className={`flex items-center h-9 w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200`}>
|
||||
{selectedItem?.name}
|
||||
</Combobox.Button>}
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none group-hover:bg-gray-200" onClick={
|
||||
() => {
|
||||
if (!disabled)
|
||||
setOpen(!open)
|
||||
if (!disabled) { setOpen(!open) }
|
||||
}
|
||||
}>
|
||||
{open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
|
||||
@ -147,8 +143,7 @@ const SimpleSelect: FC<ISelectProps> = ({
|
||||
useEffect(() => {
|
||||
let defaultSelect = null
|
||||
const existed = items.find((item: Item) => item.value === defaultValue)
|
||||
if (existed)
|
||||
defaultSelect = existed
|
||||
if (existed) { defaultSelect = existed }
|
||||
|
||||
setSelectedItem(defaultSelect)
|
||||
}, [defaultValue])
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
loading?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode | string
|
||||
|
||||
18
app/components/base/streamdown-markdown.tsx
Normal file
18
app/components/base/streamdown-markdown.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
interface StreamdownMarkdownProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StreamdownMarkdown({ content, className = '' }: StreamdownMarkdownProps) {
|
||||
return (
|
||||
<div className={`streamdown-markdown ${className}`}>
|
||||
<Streamdown>{content}</Streamdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StreamdownMarkdown
|
||||
@ -11,14 +11,14 @@ import {
|
||||
} from '@heroicons/react/20/solid'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
|
||||
export type IToastProps = {
|
||||
export interface IToastProps {
|
||||
type?: 'success' | 'error' | 'warning' | 'info'
|
||||
duration?: number
|
||||
message: string
|
||||
children?: ReactNode
|
||||
onClose?: () => void
|
||||
}
|
||||
type IToastContext = {
|
||||
interface IToastContext {
|
||||
notify: (props: IToastProps) => void
|
||||
}
|
||||
const defaultDuring = 3000
|
||||
@ -33,8 +33,7 @@ const Toast = ({
|
||||
children,
|
||||
}: IToastProps) => {
|
||||
// sometimes message is react node array. Not handle it.
|
||||
if (typeof message !== 'string')
|
||||
return null
|
||||
if (typeof message !== 'string') { return null }
|
||||
|
||||
return <div className={classNames(
|
||||
'fixed rounded-md p-4 my-4 mx-8 z-50',
|
||||
@ -124,8 +123,7 @@ Toast.notify = ({
|
||||
root.render(<Toast type={type} message={message} duration={duration} />)
|
||||
document.body.appendChild(holder)
|
||||
setTimeout(() => {
|
||||
if (holder)
|
||||
holder.remove()
|
||||
if (holder) { holder.remove() }
|
||||
}, duration || defaultDuring)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
export type TooltipProps = {
|
||||
export interface TooltipProps {
|
||||
position?: 'top' | 'right' | 'bottom' | 'left'
|
||||
triggerMethod?: 'hover' | 'click'
|
||||
popupContent: React.ReactNode
|
||||
@ -13,7 +13,7 @@ const arrow = (
|
||||
<svg className="absolute text-white h-2 w-full left-0 top-full" x="0px" y="0px" viewBox="0 0 255 255"><polygon className="fill-current" points="0,0 127.5,127.5 255,0"></polygon></svg>
|
||||
)
|
||||
|
||||
const Tooltip: FC< TooltipProps> = ({
|
||||
const Tooltip: FC<TooltipProps> = ({
|
||||
position = 'top',
|
||||
triggerMethod = 'hover',
|
||||
popupContent,
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
import classNames from 'classnames'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { Tooltip as ReactTooltip } from 'react-tooltip' // fixed version to 5.8.3 https://github.com/ReactTooltip/react-tooltip/issues/972
|
||||
import 'react-tooltip/dist/react-tooltip.css'
|
||||
import React, { useState } from 'react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type TooltipProps = {
|
||||
interface TooltipProps {
|
||||
selector: string
|
||||
content?: string
|
||||
htmlContent?: React.ReactNode
|
||||
@ -15,6 +14,10 @@ type TooltipProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const arrow = (
|
||||
<svg className="absolute text-white h-2 w-full left-0 top-full" x="0px" y="0px" viewBox="0 0 255 255"><polygon className="fill-current" points="0,0 127.5,127.5 255,0"></polygon></svg>
|
||||
)
|
||||
|
||||
const Tooltip: FC<TooltipProps> = ({
|
||||
selector,
|
||||
content,
|
||||
@ -24,22 +27,31 @@ const Tooltip: FC<TooltipProps> = ({
|
||||
className,
|
||||
clickable,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerMethod = clickable ? 'click' : 'hover'
|
||||
|
||||
return (
|
||||
<div className='tooltip-container'>
|
||||
{React.cloneElement(children as React.ReactElement, {
|
||||
'data-tooltip-id': selector,
|
||||
})
|
||||
}
|
||||
<ReactTooltip
|
||||
id={selector}
|
||||
content={content}
|
||||
className={classNames('!bg-white !text-xs !font-normal !text-gray-700 !shadow-lg !opacity-100', className)}
|
||||
place={position}
|
||||
clickable={clickable}
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement={position}
|
||||
offset={10}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
data-selector={selector}
|
||||
onClick={() => triggerMethod === 'click' && setOpen(v => !v)}
|
||||
onMouseEnter={() => triggerMethod === 'hover' && setOpen(true)}
|
||||
onMouseLeave={() => triggerMethod === 'hover' && setOpen(false)}
|
||||
>
|
||||
{htmlContent && htmlContent}
|
||||
</ReactTooltip>
|
||||
</div>
|
||||
{children}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[999]">
|
||||
<div className={classNames('relative px-3 py-2 text-xs font-normal text-gray-700 bg-white rounded-md shadow-lg', className)}>
|
||||
{htmlContent ?? content}
|
||||
{arrow}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,29 +1,32 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LoadingAnim from '../loading-anim'
|
||||
import type { FeedbackFunc } from '../type'
|
||||
import s from '../style.module.css'
|
||||
import ImageGallery from '../../base/image-gallery'
|
||||
import Thought from '../thought'
|
||||
import { randomString } from '@/utils/string'
|
||||
import type { ChatItem, MessageRating, VisionFile } from '@/types/app'
|
||||
import type { Emoji } from '@/types/tools'
|
||||
import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import StreamdownMarkdown from '@/app/components/base/streamdown-markdown'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import WorkflowProcess from '@/app/components/workflow/workflow-process'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import type { Emoji } from '@/types/tools'
|
||||
import { randomString } from '@/utils/string'
|
||||
import ImageGallery from '../../base/image-gallery'
|
||||
import LoadingAnim from '../loading-anim'
|
||||
import s from '../style.module.css'
|
||||
import Thought from '../thought'
|
||||
|
||||
const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => (
|
||||
<div
|
||||
className={`relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800 ${className ?? ''}`}
|
||||
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={onClick && onClick}
|
||||
>
|
||||
{innerContent}
|
||||
</div>
|
||||
)
|
||||
function OperationBtn({ innerContent, onClick, className }: { innerContent: React.ReactNode, onClick?: () => void, className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800 ${className ?? ''}`}
|
||||
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={onClick && onClick}
|
||||
>
|
||||
{innerContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OpeningStatementIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@ -32,34 +35,41 @@ const OpeningStatementIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
)
|
||||
|
||||
const RatingIcon: FC<{ isLike: boolean }> = ({ isLike }) => {
|
||||
return isLike ? <HandThumbUpIcon className='w-4 h-4' /> : <HandThumbDownIcon className='w-4 h-4' />
|
||||
return isLike ? <HandThumbUpIcon className="w-4 h-4" /> : <HandThumbDownIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
const EditIcon: FC<{ className?: string }> = ({ className }) => {
|
||||
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path d="M14 11.9998L13.3332 12.7292C12.9796 13.1159 12.5001 13.3332 12.0001 13.3332C11.5001 13.3332 11.0205 13.1159 10.6669 12.7292C10.3128 12.3432 9.83332 12.1265 9.33345 12.1265C8.83359 12.1265 8.35409 12.3432 7.99998 12.7292M2 13.3332H3.11636C3.44248 13.3332 3.60554 13.3332 3.75899 13.2963C3.89504 13.2637 4.0251 13.2098 4.1444 13.1367C4.27895 13.0542 4.39425 12.9389 4.62486 12.7083L13 4.33316C13.5523 3.78087 13.5523 2.88544 13 2.33316C12.4477 1.78087 11.5523 1.78087 11 2.33316L2.62484 10.7083C2.39424 10.9389 2.27894 11.0542 2.19648 11.1888C2.12338 11.3081 2.0695 11.4381 2.03684 11.5742C2 11.7276 2 11.8907 2 12.2168V13.3332Z" stroke="#6B7280" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path d="M14 11.9998L13.3332 12.7292C12.9796 13.1159 12.5001 13.3332 12.0001 13.3332C11.5001 13.3332 11.0205 13.1159 10.6669 12.7292C10.3128 12.3432 9.83332 12.1265 9.33345 12.1265C8.83359 12.1265 8.35409 12.3432 7.99998 12.7292M2 13.3332H3.11636C3.44248 13.3332 3.60554 13.3332 3.75899 13.2963C3.89504 13.2637 4.0251 13.2098 4.1444 13.1367C4.27895 13.0542 4.39425 12.9389 4.62486 12.7083L13 4.33316C13.5523 3.78087 13.5523 2.88544 13 2.33316C12.4477 1.78087 11.5523 1.78087 11 2.33316L2.62484 10.7083C2.39424 10.9389 2.27894 11.0542 2.19648 11.1888C2.12338 11.3081 2.0695 11.4381 2.03684 11.5742C2 11.7276 2 11.8907 2 12.2168V13.3332Z" stroke="#6B7280" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const EditIconSolid: FC<{ className?: string }> = ({ className }) => {
|
||||
return <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path fillRule="evenodd" clip-rule="evenodd" d="M10.8374 8.63108C11.0412 8.81739 11.0554 9.13366 10.8691 9.33747L10.369 9.88449C10.0142 10.2725 9.52293 10.5001 9.00011 10.5001C8.47746 10.5001 7.98634 10.2727 7.63157 9.8849C7.45561 9.69325 7.22747 9.59515 7.00014 9.59515C6.77271 9.59515 6.54446 9.69335 6.36846 9.88517C6.18177 10.0886 5.86548 10.1023 5.66201 9.91556C5.45853 9.72888 5.44493 9.41259 5.63161 9.20911C5.98678 8.82201 6.47777 8.59515 7.00014 8.59515C7.52251 8.59515 8.0135 8.82201 8.36867 9.20911L8.36924 9.20974C8.54486 9.4018 8.77291 9.50012 9.00011 9.50012C9.2273 9.50012 9.45533 9.40182 9.63095 9.20979L10.131 8.66276C10.3173 8.45895 10.6336 8.44476 10.8374 8.63108Z" fill="#6B7280" />
|
||||
<path fillRule="evenodd" clip-rule="evenodd" d="M7.89651 1.39656C8.50599 0.787085 9.49414 0.787084 10.1036 1.39656C10.7131 2.00604 10.7131 2.99419 10.1036 3.60367L3.82225 9.88504C3.81235 9.89494 3.80254 9.90476 3.79281 9.91451C3.64909 10.0585 3.52237 10.1855 3.3696 10.2791C3.23539 10.3613 3.08907 10.4219 2.93602 10.4587C2.7618 10.5005 2.58242 10.5003 2.37897 10.5001C2.3652 10.5001 2.35132 10.5001 2.33732 10.5001H1.50005C1.22391 10.5001 1.00005 10.2763 1.00005 10.0001V9.16286C1.00005 9.14886 1.00004 9.13497 1.00003 9.1212C0.999836 8.91776 0.999669 8.73838 1.0415 8.56416C1.07824 8.4111 1.13885 8.26479 1.22109 8.13058C1.31471 7.97781 1.44166 7.85109 1.58566 7.70736C1.5954 7.69764 1.60523 7.68783 1.61513 7.67793L7.89651 1.39656Z" fill="#6B7280" />
|
||||
</svg>
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path fillRule="evenodd" clip-rule="evenodd" d="M10.8374 8.63108C11.0412 8.81739 11.0554 9.13366 10.8691 9.33747L10.369 9.88449C10.0142 10.2725 9.52293 10.5001 9.00011 10.5001C8.47746 10.5001 7.98634 10.2727 7.63157 9.8849C7.45561 9.69325 7.22747 9.59515 7.00014 9.59515C6.77271 9.59515 6.54446 9.69335 6.36846 9.88517C6.18177 10.0886 5.86548 10.1023 5.66201 9.91556C5.45853 9.72888 5.44493 9.41259 5.63161 9.20911C5.98678 8.82201 6.47777 8.59515 7.00014 8.59515C7.52251 8.59515 8.0135 8.82201 8.36867 9.20911L8.36924 9.20974C8.54486 9.4018 8.77291 9.50012 9.00011 9.50012C9.2273 9.50012 9.45533 9.40182 9.63095 9.20979L10.131 8.66276C10.3173 8.45895 10.6336 8.44476 10.8374 8.63108Z" fill="#6B7280" />
|
||||
<path fillRule="evenodd" clip-rule="evenodd" d="M7.89651 1.39656C8.50599 0.787085 9.49414 0.787084 10.1036 1.39656C10.7131 2.00604 10.7131 2.99419 10.1036 3.60367L3.82225 9.88504C3.81235 9.89494 3.80254 9.90476 3.79281 9.91451C3.64909 10.0585 3.52237 10.1855 3.3696 10.2791C3.23539 10.3613 3.08907 10.4219 2.93602 10.4587C2.7618 10.5005 2.58242 10.5003 2.37897 10.5001C2.3652 10.5001 2.35132 10.5001 2.33732 10.5001H1.50005C1.22391 10.5001 1.00005 10.2763 1.00005 10.0001V9.16286C1.00005 9.14886 1.00004 9.13497 1.00003 9.1212C0.999836 8.91776 0.999669 8.73838 1.0415 8.56416C1.07824 8.4111 1.13885 8.26479 1.22109 8.13058C1.31471 7.97781 1.44166 7.85109 1.58566 7.70736C1.5954 7.69764 1.60523 7.68783 1.61513 7.67793L7.89651 1.39656Z" fill="#6B7280" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const IconWrapper: FC<{ children: React.ReactNode | string }> = ({ children }) => {
|
||||
return <div className={'rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100'}>
|
||||
{children}
|
||||
</div>
|
||||
return (
|
||||
<div className="rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type IAnswerProps = {
|
||||
interface IAnswerProps {
|
||||
item: ChatItem
|
||||
feedbackDisabled: boolean
|
||||
onFeedback?: FeedbackFunc
|
||||
isResponding?: boolean
|
||||
allToolIcons?: Record<string, string | Emoji>
|
||||
suggestionClick?: (suggestion: string) => void
|
||||
}
|
||||
|
||||
// The component needs to maintain its own state to control whether to display input component
|
||||
@ -69,22 +79,22 @@ const Answer: FC<IAnswerProps> = ({
|
||||
onFeedback,
|
||||
isResponding,
|
||||
allToolIcons,
|
||||
suggestionClick = () => { },
|
||||
}) => {
|
||||
const { id, content, feedback, agent_thoughts, workflowProcess } = item
|
||||
const { id, content, feedback, agent_thoughts, workflowProcess, suggestedQuestions = [] } = item
|
||||
const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
/**
|
||||
* Render feedback results (distinguish between users and administrators)
|
||||
* User reviews cannot be cancelled in Console
|
||||
* @param rating feedback result
|
||||
* @param isUserFeedback Whether it is user's feedback
|
||||
* @returns comp
|
||||
*/
|
||||
* Render feedback results (distinguish between users and administrators)
|
||||
* User reviews cannot be cancelled in Console
|
||||
* @param rating feedback result
|
||||
* @param isUserFeedback Whether it is user's feedback
|
||||
* @returns comp
|
||||
*/
|
||||
const renderFeedbackRating = (rating: MessageRating | undefined) => {
|
||||
if (!rating)
|
||||
return null
|
||||
if (!rating) { return null }
|
||||
|
||||
const isLike = rating === 'like'
|
||||
const ratingIconClassname = isLike ? 'text-primary-600 bg-primary-100 hover:bg-primary-200' : 'text-red-600 bg-red-100 hover:bg-red-200'
|
||||
@ -95,7 +105,7 @@ const Answer: FC<IAnswerProps> = ({
|
||||
content={isLike ? '取消赞同' : '取消反对'}
|
||||
>
|
||||
<div
|
||||
className={'relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800'}
|
||||
className="relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800"
|
||||
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={async () => {
|
||||
await onFeedback?.(id, { rating: null })
|
||||
@ -117,14 +127,16 @@ const Answer: FC<IAnswerProps> = ({
|
||||
const userOperation = () => {
|
||||
return feedback?.rating
|
||||
? null
|
||||
: <div className='flex gap-1'>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.like') as string}>
|
||||
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
|
||||
</Tooltip>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.dislike') as string}>
|
||||
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
|
||||
</Tooltip>
|
||||
</div>
|
||||
: (
|
||||
<div className="flex gap-1">
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.like') as string}>
|
||||
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
|
||||
</Tooltip>
|
||||
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.dislike') as string}>
|
||||
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -135,8 +147,7 @@ const Answer: FC<IAnswerProps> = ({
|
||||
}
|
||||
|
||||
const getImgs = (list?: VisionFile[]) => {
|
||||
if (!list)
|
||||
return []
|
||||
if (!list) { return [] }
|
||||
return list.filter(file => file.type === 'image' && file.belongs_to === 'assistant')
|
||||
}
|
||||
|
||||
@ -145,7 +156,7 @@ const Answer: FC<IAnswerProps> = ({
|
||||
{agent_thoughts?.map((item, index) => (
|
||||
<div key={index}>
|
||||
{item.thought && (
|
||||
<Markdown content={item.thought} />
|
||||
<StreamdownMarkdown content={item.thought} />
|
||||
)}
|
||||
{/* {item.tool} */}
|
||||
{/* perhaps not use tool */}
|
||||
@ -167,15 +178,16 @@ const Answer: FC<IAnswerProps> = ({
|
||||
|
||||
return (
|
||||
<div key={id}>
|
||||
<div className='flex items-start'>
|
||||
<div className="flex items-start">
|
||||
<div className={`${s.answerIcon} w-10 h-10 shrink-0`}>
|
||||
{isResponding
|
||||
&& <div className={s.typeingIcon}>
|
||||
<LoadingAnim type='avatar' />
|
||||
</div>
|
||||
}
|
||||
&& (
|
||||
<div className={s.typeingIcon}>
|
||||
<LoadingAnim type="avatar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${s.answerWrap}`}>
|
||||
<div className={`${s.answerWrap} max-w-[calc(100%-3rem)]`}>
|
||||
<div className={`${s.answer} relative text-sm text-gray-900`}>
|
||||
<div className={`ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl ${workflowProcess && 'min-w-[480px]'}`}>
|
||||
{workflowProcess && (
|
||||
@ -183,17 +195,28 @@ const Answer: FC<IAnswerProps> = ({
|
||||
)}
|
||||
{(isResponding && (isAgentMode ? (!content && (agent_thoughts || []).filter(item => !!item.thought || !!item.tool).length === 0) : !content))
|
||||
? (
|
||||
<div className='flex items-center justify-center w-6 h-5'>
|
||||
<LoadingAnim type='text' />
|
||||
<div className="flex items-center justify-center w-6 h-5">
|
||||
<LoadingAnim type="text" />
|
||||
</div>
|
||||
)
|
||||
: (isAgentMode
|
||||
? agentModeAnswer
|
||||
: (
|
||||
<Markdown content={content} />
|
||||
<StreamdownMarkdown content={content} />
|
||||
))}
|
||||
{suggestedQuestions.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
{suggestedQuestions.map((suggestion, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<Button className="text-sm" type="link" onClick={() => suggestionClick(suggestion)}>{suggestion}</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='absolute top-[-14px] right-[-14px] flex flex-row justify-end gap-1'>
|
||||
<div className="absolute top-[-14px] right-[-14px] flex flex-row justify-end gap-1">
|
||||
{!feedbackDisabled && !item.feedbackDisabled && renderItemOperation()}
|
||||
{/* User feedback must be displayed */}
|
||||
{!feedbackDisabled && renderFeedbackRating(feedback?.rating)}
|
||||
|
||||
@ -15,8 +15,11 @@ import Toast from '@/app/components/base/toast'
|
||||
import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
|
||||
import ImageList from '@/app/components/base/image-uploader/image-list'
|
||||
import { useImageFiles } from '@/app/components/base/image-uploader/hooks'
|
||||
import FileUploaderInAttachmentWrapper from '@/app/components/base/file-uploader-in-attachment'
|
||||
import type { FileEntity, FileUpload } from '@/app/components/base/file-uploader-in-attachment/types'
|
||||
import { getProcessedFiles } from '@/app/components/base/file-uploader-in-attachment/utils'
|
||||
|
||||
export type IChatProps = {
|
||||
export interface IChatProps {
|
||||
chatList: ChatItem[]
|
||||
/**
|
||||
* Whether to display the editing area and rating status
|
||||
@ -33,6 +36,7 @@ export type IChatProps = {
|
||||
isResponding?: boolean
|
||||
controlClearQuery?: number
|
||||
visionConfig?: VisionSettings
|
||||
fileConfig?: FileUpload
|
||||
}
|
||||
|
||||
const Chat: FC<IChatProps> = ({
|
||||
@ -46,15 +50,19 @@ const Chat: FC<IChatProps> = ({
|
||||
isResponding,
|
||||
controlClearQuery,
|
||||
visionConfig,
|
||||
fileConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = Toast
|
||||
const isUseInputMethod = useRef(false)
|
||||
|
||||
const [query, setQuery] = React.useState('')
|
||||
const queryRef = useRef('')
|
||||
|
||||
const handleContentChange = (e: any) => {
|
||||
const value = e.target.value
|
||||
setQuery(value)
|
||||
queryRef.current = value
|
||||
}
|
||||
|
||||
const logError = (message: string) => {
|
||||
@ -62,16 +70,19 @@ const Chat: FC<IChatProps> = ({
|
||||
}
|
||||
|
||||
const valid = () => {
|
||||
const query = queryRef.current
|
||||
if (!query || query.trim() === '') {
|
||||
logError('Message cannot be empty')
|
||||
logError(t('app.errorMessage.valueOfVarRequired'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (controlClearQuery)
|
||||
if (controlClearQuery) {
|
||||
setQuery('')
|
||||
queryRef.current = ''
|
||||
}
|
||||
}, [controlClearQuery])
|
||||
const {
|
||||
files,
|
||||
@ -83,40 +94,53 @@ const Chat: FC<IChatProps> = ({
|
||||
onClear,
|
||||
} = useImageFiles()
|
||||
|
||||
const [attachmentFiles, setAttachmentFiles] = React.useState<FileEntity[]>([])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!valid() || (checkCanSend && !checkCanSend()))
|
||||
return
|
||||
onSend(query, files.filter(file => file.progress !== -1).map(fileItem => ({
|
||||
if (!valid() || (checkCanSend && !checkCanSend())) { return }
|
||||
const imageFiles: VisionFile[] = files.filter(file => file.progress !== -1).map(fileItem => ({
|
||||
type: 'image',
|
||||
transfer_method: fileItem.type,
|
||||
url: fileItem.url,
|
||||
upload_file_id: fileItem.fileId,
|
||||
})))
|
||||
}))
|
||||
const docAndOtherFiles: VisionFile[] = getProcessedFiles(attachmentFiles)
|
||||
const combinedFiles: VisionFile[] = [...imageFiles, ...docAndOtherFiles]
|
||||
onSend(queryRef.current, combinedFiles)
|
||||
if (!files.find(item => item.type === TransferMethod.local_file && !item.fileId)) {
|
||||
if (files.length)
|
||||
onClear()
|
||||
if (!isResponding)
|
||||
if (files.length) { onClear() }
|
||||
if (!isResponding) {
|
||||
setQuery('')
|
||||
queryRef.current = ''
|
||||
}
|
||||
}
|
||||
if (!attachmentFiles.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) { setAttachmentFiles([]) }
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: any) => {
|
||||
if (e.code === 'Enter') {
|
||||
e.preventDefault()
|
||||
// prevent send message when using input method enter
|
||||
if (!e.shiftKey && !isUseInputMethod.current)
|
||||
handleSend()
|
||||
if (!e.shiftKey && !isUseInputMethod.current) { handleSend() }
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: any) => {
|
||||
isUseInputMethod.current = e.nativeEvent.isComposing
|
||||
if (e.code === 'Enter' && !e.shiftKey) {
|
||||
setQuery(query.replace(/\n$/, ''))
|
||||
const result = query.replace(/\n$/, '')
|
||||
setQuery(result)
|
||||
queryRef.current = result
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const suggestionClick = (suggestion: string) => {
|
||||
setQuery(suggestion)
|
||||
queryRef.current = suggestion
|
||||
handleSend()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(!feedbackDisabled && 'px-3.5', 'h-full')}>
|
||||
{/* Chat List */}
|
||||
@ -130,6 +154,7 @@ const Chat: FC<IChatProps> = ({
|
||||
feedbackDisabled={feedbackDisabled}
|
||||
onFeedback={onFeedback}
|
||||
isResponding={isResponding && isLast}
|
||||
suggestionClick={suggestionClick}
|
||||
/>
|
||||
}
|
||||
return (
|
||||
@ -145,7 +170,7 @@ const Chat: FC<IChatProps> = ({
|
||||
</div>
|
||||
{
|
||||
!isHideSendInput && (
|
||||
<div className={cn(!feedbackDisabled && '!left-3.5 !right-3.5', 'absolute z-10 bottom-0 left-0 right-0')}>
|
||||
<div className='fixed z-10 bottom-0 left-1/2 transform -translate-x-1/2 pc:ml-[122px] tablet:ml-[96px] mobile:ml-0 pc:w-[794px] tablet:w-[794px] max-w-full mobile:w-full px-3.5'>
|
||||
<div className='p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto'>
|
||||
{
|
||||
visionConfig?.enabled && (
|
||||
@ -170,9 +195,20 @@ const Chat: FC<IChatProps> = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
fileConfig?.enabled && (
|
||||
<div className={`${visionConfig?.enabled ? 'pl-[52px]' : ''} mb-1`}>
|
||||
<FileUploaderInAttachmentWrapper
|
||||
fileConfig={fileConfig}
|
||||
value={attachmentFiles}
|
||||
onChange={setAttachmentFiles}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Textarea
|
||||
className={`
|
||||
block w-full px-2 pr-[118px] py-[7px] leading-5 max-h-none text-sm text-gray-700 outline-none appearance-none resize-none
|
||||
block w-full px-2 pr-[118px] py-[7px] leading-5 max-h-none text-base text-gray-700 outline-none appearance-none resize-none
|
||||
${visionConfig?.enabled && 'pl-12'}
|
||||
`}
|
||||
value={query}
|
||||
@ -181,8 +217,8 @@ const Chat: FC<IChatProps> = ({
|
||||
onKeyDown={handleKeyDown}
|
||||
autoSize
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 flex items-center h-8">
|
||||
<div className={`${s.count} mr-4 h-5 leading-5 text-sm bg-gray-50 text-gray-500`}>{query.trim().length}</div>
|
||||
<div className="absolute bottom-2 right-6 flex items-center h-8">
|
||||
<div className={`${s.count} mr-3 h-5 leading-5 text-sm bg-gray-50 text-gray-500 px-2 rounded`}>{query.trim().length}</div>
|
||||
<Tooltip
|
||||
selector='send-tip'
|
||||
htmlContent={
|
||||
|
||||
@ -3,7 +3,7 @@ import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import s from './style.module.css'
|
||||
|
||||
export type ILoaidingAnimProps = {
|
||||
export interface ILoaidingAnimProps {
|
||||
type: 'text' | 'avatar'
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import React from 'react'
|
||||
import type { IChatItem } from '../type'
|
||||
import s from '../style.module.css'
|
||||
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import StreamdownMarkdown from '@/app/components/base/streamdown-markdown'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
|
||||
type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'useCurrentUserAvatar'> & {
|
||||
@ -23,7 +23,7 @@ const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar, imgSr
|
||||
{imgSrcs && imgSrcs.length > 0 && (
|
||||
<ImageGallery srcs={imgSrcs} />
|
||||
)}
|
||||
<Markdown content={content} />
|
||||
<StreamdownMarkdown content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@ import type { ThoughtItem, ToolInfoInThought } from '../type'
|
||||
import Tool from './tool'
|
||||
import type { Emoji } from '@/types/tools'
|
||||
|
||||
export type IThoughtProps = {
|
||||
export interface IThoughtProps {
|
||||
thought: ThoughtItem
|
||||
allToolIcons: Record<string, string | Emoji>
|
||||
isFinished: boolean
|
||||
@ -29,8 +29,7 @@ const Thought: FC<IThoughtProps> = ({
|
||||
}) => {
|
||||
const [toolNames, isValueArray]: [string[], boolean] = (() => {
|
||||
try {
|
||||
if (Array.isArray(JSON.parse(thought.tool)))
|
||||
return [JSON.parse(thought.tool), true]
|
||||
if (Array.isArray(JSON.parse(thought.tool))) { return [JSON.parse(thought.tool), true] }
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
isRequest: boolean
|
||||
toolName: string
|
||||
content: string
|
||||
|
||||
@ -13,17 +13,15 @@ import DataSetIcon from '@/app/components/base/icons/public/data-set'
|
||||
import type { Emoji } from '@/types/tools'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
payload: ToolInfoInThought
|
||||
allToolIcons?: Record<string, string | Emoji>
|
||||
}
|
||||
|
||||
const getIcon = (toolName: string, allToolIcons: Record<string, string | Emoji>) => {
|
||||
if (toolName.startsWith('dataset-'))
|
||||
return <DataSetIcon className='shrink-0'></DataSetIcon>
|
||||
if (toolName.startsWith('dataset-')) { return <DataSetIcon className='shrink-0'></DataSetIcon> }
|
||||
const icon = allToolIcons[toolName]
|
||||
if (!icon)
|
||||
return null
|
||||
if (!icon) { return null }
|
||||
return (
|
||||
typeof icon === 'string'
|
||||
? (
|
||||
@ -87,12 +85,14 @@ const Tool: FC<Props> = ({
|
||||
<Panel
|
||||
isRequest={true}
|
||||
toolName={toolName}
|
||||
content={input} />
|
||||
content={input}
|
||||
/>
|
||||
{output && (
|
||||
<Panel
|
||||
isRequest={false}
|
||||
toolName={toolName}
|
||||
content={output as string} />
|
||||
content={output as string}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { VisionFile } from '@/types/app'
|
||||
|
||||
export type LogAnnotation = {
|
||||
export interface LogAnnotation {
|
||||
content: string
|
||||
account: {
|
||||
id: string
|
||||
@ -10,7 +10,7 @@ export type LogAnnotation = {
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type Annotation = {
|
||||
export interface Annotation {
|
||||
id: string
|
||||
authorName: string
|
||||
logAnnotation?: LogAnnotation
|
||||
@ -20,13 +20,13 @@ export type Annotation = {
|
||||
export const MessageRatings = ['like', 'dislike', null] as const
|
||||
export type MessageRating = typeof MessageRatings[number]
|
||||
|
||||
export type MessageMore = {
|
||||
export interface MessageMore {
|
||||
time: string
|
||||
tokens: number
|
||||
latency: number | string
|
||||
}
|
||||
|
||||
export type Feedbacktype = {
|
||||
export interface Feedbacktype {
|
||||
rating: MessageRating
|
||||
content?: string | null
|
||||
}
|
||||
@ -36,14 +36,14 @@ export type SubmitAnnotationFunc = (messageId: string, content: string) => Promi
|
||||
|
||||
export type DisplayScene = 'web' | 'console'
|
||||
|
||||
export type ToolInfoInThought = {
|
||||
export interface ToolInfoInThought {
|
||||
name: string
|
||||
input: string
|
||||
output: string
|
||||
isFinished: boolean
|
||||
}
|
||||
|
||||
export type ThoughtItem = {
|
||||
export interface ThoughtItem {
|
||||
id: string
|
||||
tool: string // plugin or dataset. May has multi.
|
||||
thought: string
|
||||
@ -55,7 +55,7 @@ export type ThoughtItem = {
|
||||
message_files?: VisionFile[]
|
||||
}
|
||||
|
||||
export type CitationItem = {
|
||||
export interface CitationItem {
|
||||
content: string
|
||||
data_source_type: string
|
||||
dataset_name: string
|
||||
@ -70,7 +70,7 @@ export type CitationItem = {
|
||||
word_count: number
|
||||
}
|
||||
|
||||
export type IChatItem = {
|
||||
export interface IChatItem {
|
||||
id: string
|
||||
content: string
|
||||
citation?: CitationItem[]
|
||||
@ -98,12 +98,12 @@ export type IChatItem = {
|
||||
useCurrentUserAvatar?: boolean
|
||||
isOpeningStatement?: boolean
|
||||
suggestedQuestions?: string[]
|
||||
log?: { role: string; text: string }[]
|
||||
log?: { role: string, text: string }[]
|
||||
agent_thoughts?: ThoughtItem[]
|
||||
message_files?: VisionFile[]
|
||||
}
|
||||
|
||||
export type MessageEnd = {
|
||||
export interface MessageEnd {
|
||||
id: string
|
||||
metadata: {
|
||||
retriever_resources?: CitationItem[]
|
||||
@ -117,14 +117,14 @@ export type MessageEnd = {
|
||||
}
|
||||
}
|
||||
|
||||
export type MessageReplace = {
|
||||
export interface MessageReplace {
|
||||
id: string
|
||||
task_id: string
|
||||
answer: string
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
export type AnnotationReply = {
|
||||
export interface AnnotationReply {
|
||||
id: string
|
||||
task_id: string
|
||||
answer: string
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
PencilSquareIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
export type IHeaderProps = {
|
||||
export interface IHeaderProps {
|
||||
title: string
|
||||
isMobile?: boolean
|
||||
onShowSideBar?: () => void
|
||||
@ -35,9 +35,7 @@ const Header: FC<IHeaderProps> = ({
|
||||
</div>
|
||||
{isMobile
|
||||
? (
|
||||
<div className='flex items-center justify-center h-8 w-8 cursor-pointer'
|
||||
onClick={() => onCreateNewChat?.()}
|
||||
>
|
||||
<div className='flex items-center justify-center h-8 w-8 cursor-pointer' onClick={() => onCreateNewChat?.()} >
|
||||
<PencilSquareIcon className="h-4 w-4 text-gray-500" />
|
||||
</div>)
|
||||
: <div></div>}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-use-before-define */
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
@ -12,6 +11,7 @@ import ConfigSence from '@/app/components/config-scence'
|
||||
import Header from '@/app/components/header'
|
||||
import { fetchAppParams, fetchChatList, fetchConversations, generationConversationName, sendChatMessage, updateFeedback } from '@/service'
|
||||
import type { ChatItem, ConversationItem, Feedbacktype, PromptConfig, VisionFile, VisionSettings } from '@/types/app'
|
||||
import type { FileUpload } from '@/app/components/base/file-uploader-in-attachment/types'
|
||||
import { Resolution, TransferMethod, WorkflowRunningStatus } from '@/types/app'
|
||||
import Chat from '@/app/components/chat'
|
||||
import { setLocaleOnClient } from '@/i18n/client'
|
||||
@ -23,7 +23,11 @@ import { API_KEY, APP_ID, APP_INFO, isShowPrompt, promptTemplate } from '@/confi
|
||||
import type { Annotation as AnnotationType } from '@/types/log'
|
||||
import { addFileInfos, sortAgentSorts } from '@/utils/tools'
|
||||
|
||||
const Main: FC = () => {
|
||||
export interface IMainProps {
|
||||
params: any
|
||||
}
|
||||
|
||||
const Main: FC<IMainProps> = () => {
|
||||
const { t } = useTranslation()
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
@ -44,10 +48,10 @@ const Main: FC = () => {
|
||||
detail: Resolution.low,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
})
|
||||
const [fileConfig, setFileConfig] = useState<FileUpload | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (APP_INFO?.title)
|
||||
document.title = `${APP_INFO.title} - Powered by Dify`
|
||||
if (APP_INFO?.title) { document.title = `${APP_INFO.title} - Powered by Dify` }
|
||||
}, [APP_INFO?.title])
|
||||
|
||||
// onData change thought (the produce obj). https://github.com/immerjs/immer/issues/576
|
||||
@ -89,18 +93,17 @@ const Main: FC = () => {
|
||||
setChatList(generateNewChatListWithOpenStatement('', inputs))
|
||||
}
|
||||
const hasSetInputs = (() => {
|
||||
if (!isNewConversation)
|
||||
return true
|
||||
if (!isNewConversation) { return true }
|
||||
|
||||
return isChatStarted
|
||||
})()
|
||||
|
||||
const conversationName = currConversationInfo?.name || t('app.chat.newChatDefaultName') as string
|
||||
const conversationIntroduction = currConversationInfo?.introduction || ''
|
||||
const suggestedQuestions = currConversationInfo?.suggested_questions || []
|
||||
|
||||
const handleConversationSwitch = () => {
|
||||
if (!inited)
|
||||
return
|
||||
if (!inited) { return }
|
||||
|
||||
// update inputs of current conversation
|
||||
let notSyncToStateIntroduction = ''
|
||||
@ -113,6 +116,7 @@ const Main: FC = () => {
|
||||
setExistConversationInfo({
|
||||
name: item?.name || '',
|
||||
introduction: notSyncToStateIntroduction,
|
||||
suggested_questions: suggestedQuestions,
|
||||
})
|
||||
}
|
||||
else {
|
||||
@ -147,8 +151,7 @@ const Main: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
if (isNewConversation && isChatStarted)
|
||||
setChatList(generateNewChatListWithOpenStatement())
|
||||
if (isNewConversation && isChatStarted) { setChatList(generateNewChatListWithOpenStatement()) }
|
||||
}
|
||||
useEffect(handleConversationSwitch, [currConversationId, inited])
|
||||
|
||||
@ -171,16 +174,21 @@ const Main: FC = () => {
|
||||
const [chatList, setChatList, getChatList] = useGetState<ChatItem[]>([])
|
||||
const chatListDomRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
// scroll to bottom
|
||||
if (chatListDomRef.current)
|
||||
chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight
|
||||
// scroll to bottom with page-level scrolling
|
||||
if (chatListDomRef.current) {
|
||||
setTimeout(() => {
|
||||
chatListDomRef.current?.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'end',
|
||||
})
|
||||
}, 50)
|
||||
}
|
||||
}, [chatList, currConversationId])
|
||||
// user can not edit inputs if user had send message
|
||||
const canEditInputs = !chatList.some(item => item.isAnswer === false) && isNewConversation
|
||||
const createNewChat = () => {
|
||||
// if new chat is already exist, do not create new chat
|
||||
if (conversationList.some(item => item.id === '-1'))
|
||||
return
|
||||
if (conversationList.some(item => item.id === '-1')) { return }
|
||||
|
||||
setConversationList(produce(conversationList, (draft) => {
|
||||
draft.unshift({
|
||||
@ -188,6 +196,7 @@ const Main: FC = () => {
|
||||
name: t('app.chat.newChatDefaultName'),
|
||||
inputs: newConversationInputs,
|
||||
introduction: conversationIntroduction,
|
||||
suggested_questions: suggestedQuestions,
|
||||
})
|
||||
}))
|
||||
}
|
||||
@ -196,8 +205,7 @@ const Main: FC = () => {
|
||||
const generateNewChatListWithOpenStatement = (introduction?: string, inputs?: Record<string, any> | null) => {
|
||||
let calculatedIntroduction = introduction || conversationIntroduction || ''
|
||||
const calculatedPromptVariables = inputs || currInputs || null
|
||||
if (calculatedIntroduction && calculatedPromptVariables)
|
||||
calculatedIntroduction = replaceVarWithValues(calculatedIntroduction, promptConfig?.prompt_variables || [], calculatedPromptVariables)
|
||||
if (calculatedIntroduction && calculatedPromptVariables) { calculatedIntroduction = replaceVarWithValues(calculatedIntroduction, promptConfig?.prompt_variables || [], calculatedPromptVariables) }
|
||||
|
||||
const openStatement = {
|
||||
id: `${Date.now()}`,
|
||||
@ -205,9 +213,9 @@ const Main: FC = () => {
|
||||
isAnswer: true,
|
||||
feedbackDisabled: true,
|
||||
isOpeningStatement: isShowPrompt,
|
||||
suggestedQuestions,
|
||||
}
|
||||
if (calculatedIntroduction)
|
||||
return [openStatement]
|
||||
if (calculatedIntroduction) { return [openStatement] }
|
||||
|
||||
return []
|
||||
}
|
||||
@ -221,32 +229,54 @@ const Main: FC = () => {
|
||||
(async () => {
|
||||
try {
|
||||
const [conversationData, appParams] = await Promise.all([fetchConversations(), fetchAppParams()])
|
||||
|
||||
// handle current conversation id
|
||||
const { data: conversations } = conversationData as { data: ConversationItem[] }
|
||||
const { data: conversations, error } = conversationData as { data: ConversationItem[], error: string }
|
||||
if (error) {
|
||||
Toast.notify({ type: 'error', message: error })
|
||||
throw new Error(error)
|
||||
return
|
||||
}
|
||||
const _conversationId = getConversationIdFromStorage(APP_ID)
|
||||
const isNotNewConversation = conversations.some(item => item.id === _conversationId)
|
||||
const currentConversation = conversations.find(item => item.id === _conversationId)
|
||||
const isNotNewConversation = !!currentConversation
|
||||
|
||||
// fetch new conversation info
|
||||
const { user_input_form, opening_statement: introduction, file_upload, system_parameters }: any = appParams
|
||||
const { user_input_form, opening_statement: introduction, file_upload, system_parameters, suggested_questions = [] }: any = appParams
|
||||
setLocaleOnClient(APP_INFO.default_language, true)
|
||||
setNewConversationInfo({
|
||||
name: t('app.chat.newChatDefaultName'),
|
||||
introduction,
|
||||
suggested_questions,
|
||||
})
|
||||
if (isNotNewConversation) {
|
||||
setExistConversationInfo({
|
||||
name: currentConversation.name || t('app.chat.newChatDefaultName'),
|
||||
introduction,
|
||||
suggested_questions,
|
||||
})
|
||||
}
|
||||
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
|
||||
setPromptConfig({
|
||||
prompt_template: promptTemplate,
|
||||
prompt_variables,
|
||||
} as PromptConfig)
|
||||
const outerFileUploadEnabled = !!file_upload?.enabled
|
||||
setVisionConfig({
|
||||
...file_upload?.image,
|
||||
enabled: !!(outerFileUploadEnabled && file_upload?.image?.enabled),
|
||||
image_file_size_limit: system_parameters?.system_parameters || 0,
|
||||
})
|
||||
setFileConfig({
|
||||
enabled: outerFileUploadEnabled,
|
||||
allowed_file_types: file_upload?.allowed_file_types,
|
||||
allowed_file_extensions: file_upload?.allowed_file_extensions,
|
||||
allowed_file_upload_methods: file_upload?.allowed_file_upload_methods,
|
||||
number_limits: file_upload?.number_limits,
|
||||
fileUploadConfig: file_upload?.fileUploadConfig,
|
||||
})
|
||||
setConversationList(conversations as ConversationItem[])
|
||||
|
||||
if (isNotNewConversation)
|
||||
setCurrConversationId(_conversationId, APP_ID, false)
|
||||
if (isNotNewConversation) { setCurrConversationId(_conversationId, APP_ID, false) }
|
||||
|
||||
setInited(true)
|
||||
}
|
||||
@ -270,11 +300,9 @@ const Main: FC = () => {
|
||||
}
|
||||
|
||||
const checkCanSend = () => {
|
||||
if (currConversationId !== '-1')
|
||||
return true
|
||||
if (currConversationId !== '-1') { return true }
|
||||
|
||||
if (!currInputs || !promptConfig?.prompt_variables)
|
||||
return true
|
||||
if (!currInputs || !promptConfig?.prompt_variables) { return true }
|
||||
|
||||
const inputLens = Object.values(currInputs).length
|
||||
const promptVariablesLens = promptConfig.prompt_variables.length
|
||||
@ -309,26 +337,47 @@ const Main: FC = () => {
|
||||
const newListWithAnswer = produce(
|
||||
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
|
||||
(draft) => {
|
||||
if (!draft.find(item => item.id === questionId))
|
||||
draft.push({ ...questionItem })
|
||||
if (!draft.find(item => item.id === questionId)) { draft.push({ ...questionItem }) }
|
||||
|
||||
draft.push({ ...responseItem })
|
||||
})
|
||||
},
|
||||
)
|
||||
setChatList(newListWithAnswer)
|
||||
}
|
||||
|
||||
const transformToServerFile = (fileItem: any) => {
|
||||
return {
|
||||
type: 'image',
|
||||
transfer_method: fileItem.transferMethod,
|
||||
url: fileItem.url,
|
||||
upload_file_id: fileItem.id,
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async (message: string, files?: VisionFile[]) => {
|
||||
if (isResponding) {
|
||||
notify({ type: 'info', message: t('app.errorMessage.waitForResponse') })
|
||||
return
|
||||
}
|
||||
const toServerInputs: Record<string, any> = {}
|
||||
if (currInputs) {
|
||||
Object.keys(currInputs).forEach((key) => {
|
||||
const value = currInputs[key]
|
||||
if (value.supportFileType) { toServerInputs[key] = transformToServerFile(value) }
|
||||
|
||||
else if (value[0]?.supportFileType) { toServerInputs[key] = value.map((item: any) => transformToServerFile(item)) }
|
||||
|
||||
else { toServerInputs[key] = value }
|
||||
})
|
||||
}
|
||||
|
||||
const data: Record<string, any> = {
|
||||
inputs: currInputs,
|
||||
inputs: toServerInputs,
|
||||
query: message,
|
||||
conversation_id: isNewConversation ? null : currConversationId,
|
||||
}
|
||||
|
||||
if (visionConfig?.enabled && files && files?.length > 0) {
|
||||
if (files && files?.length > 0) {
|
||||
data.files = files.map((item) => {
|
||||
if (item.transfer_method === TransferMethod.local_file) {
|
||||
return {
|
||||
@ -346,7 +395,7 @@ const Main: FC = () => {
|
||||
id: questionId,
|
||||
content: message,
|
||||
isAnswer: false,
|
||||
message_files: files,
|
||||
message_files: (files || []).filter((f: any) => f.type === 'image'),
|
||||
}
|
||||
|
||||
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
|
||||
@ -385,16 +434,14 @@ const Main: FC = () => {
|
||||
}
|
||||
else {
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
if (lastThought)
|
||||
lastThought.thought = lastThought.thought + message // need immer setAutoFreeze
|
||||
if (lastThought) { lastThought.thought = lastThought.thought + message } // need immer setAutoFreeze
|
||||
}
|
||||
if (messageId && !hasSetResponseId) {
|
||||
responseItem.id = messageId
|
||||
hasSetResponseId = true
|
||||
}
|
||||
|
||||
if (isFirstMessage && newConversationId)
|
||||
tempNewConversationId = newConversationId
|
||||
if (isFirstMessage && newConversationId) { tempNewConversationId = newConversationId }
|
||||
|
||||
setMessageTaskId(taskId)
|
||||
// has switched to other conversation
|
||||
@ -410,8 +457,7 @@ const Main: FC = () => {
|
||||
})
|
||||
},
|
||||
async onCompleted(hasError?: boolean) {
|
||||
if (hasError)
|
||||
return
|
||||
if (hasError) { return }
|
||||
|
||||
if (getConversationIdChangeBecauseOfNew()) {
|
||||
const { data: allConversations }: any = await fetchConversations()
|
||||
@ -430,8 +476,7 @@ const Main: FC = () => {
|
||||
},
|
||||
onFile(file) {
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
if (lastThought)
|
||||
lastThought.message_files = [...(lastThought as any).message_files, { ...file }]
|
||||
if (lastThought) { lastThought.message_files = [...(lastThought as any).message_files, { ...file }] }
|
||||
|
||||
updateCurrentQA({
|
||||
responseItem,
|
||||
@ -486,13 +531,13 @@ const Main: FC = () => {
|
||||
const newListWithAnswer = produce(
|
||||
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
|
||||
(draft) => {
|
||||
if (!draft.find(item => item.id === questionId))
|
||||
draft.push({ ...questionItem })
|
||||
if (!draft.find(item => item.id === questionId)) { draft.push({ ...questionItem }) }
|
||||
|
||||
draft.push({
|
||||
...responseItem,
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
setChatList(newListWithAnswer)
|
||||
return
|
||||
}
|
||||
@ -501,11 +546,11 @@ const Main: FC = () => {
|
||||
const newListWithAnswer = produce(
|
||||
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
|
||||
(draft) => {
|
||||
if (!draft.find(item => item.id === questionId))
|
||||
draft.push({ ...questionItem })
|
||||
if (!draft.find(item => item.id === questionId)) { draft.push({ ...questionItem }) }
|
||||
|
||||
draft.push({ ...responseItem })
|
||||
})
|
||||
},
|
||||
)
|
||||
setChatList(newListWithAnswer)
|
||||
},
|
||||
onMessageReplace: (messageReplace) => {
|
||||
@ -514,8 +559,7 @@ const Main: FC = () => {
|
||||
(draft) => {
|
||||
const current = draft.find(item => item.id === messageReplace.id)
|
||||
|
||||
if (current)
|
||||
current.content = messageReplace.answer
|
||||
if (current) { current.content = messageReplace.answer }
|
||||
},
|
||||
))
|
||||
},
|
||||
@ -591,8 +635,7 @@ const Main: FC = () => {
|
||||
}
|
||||
|
||||
const renderSidebar = () => {
|
||||
if (!APP_ID || !APP_INFO || !promptConfig)
|
||||
return null
|
||||
if (!APP_ID || !APP_INFO || !promptConfig) { return null }
|
||||
return (
|
||||
<Sidebar
|
||||
list={conversationList}
|
||||
@ -603,11 +646,9 @@ const Main: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
if (appUnavailable)
|
||||
return <AppUnavailable isUnknownReason={isUnknownReason} errMessage={!hasSetAppConfig ? 'Please set APP_ID and API_KEY in config/index.tsx' : ''} />
|
||||
if (appUnavailable) { return <AppUnavailable isUnknownReason={isUnknownReason} errMessage={!hasSetAppConfig ? 'Please set APP_ID and API_KEY in config/index.tsx' : ''} /> }
|
||||
|
||||
if (!APP_ID || !APP_INFO || !promptConfig)
|
||||
return <Loading type='app' />
|
||||
if (!APP_ID || !APP_INFO || !promptConfig) { return <Loading type='app' /> }
|
||||
|
||||
return (
|
||||
<div className='bg-gray-100'>
|
||||
@ -621,10 +662,7 @@ const Main: FC = () => {
|
||||
{/* sidebar */}
|
||||
{!isMobile && renderSidebar()}
|
||||
{isMobile && isShowSidebar && (
|
||||
<div className='fixed inset-0 z-50'
|
||||
style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
|
||||
onClick={hideSidebar}
|
||||
>
|
||||
<div className='fixed inset-0 z-50' style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }} onClick={hideSidebar} >
|
||||
<div className='inline-block' onClick={e => e.stopPropagation()}>
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
@ -646,17 +684,16 @@ const Main: FC = () => {
|
||||
|
||||
{
|
||||
hasSetInputs && (
|
||||
<div className='relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full pb-[66px] mx-auto mb-3.5 overflow-hidden'>
|
||||
<div className='h-full overflow-y-auto' ref={chatListDomRef}>
|
||||
<Chat
|
||||
chatList={chatList}
|
||||
onSend={handleSend}
|
||||
onFeedback={handleFeedback}
|
||||
isResponding={isResponding}
|
||||
checkCanSend={checkCanSend}
|
||||
visionConfig={visionConfig}
|
||||
/>
|
||||
</div>
|
||||
<div className='relative grow pc:w-[794px] max-w-full mobile:w-full pb-[180px] mx-auto mb-3.5' ref={chatListDomRef}>
|
||||
<Chat
|
||||
chatList={chatList}
|
||||
onSend={handleSend}
|
||||
onFeedback={handleFeedback}
|
||||
isResponding={isResponding}
|
||||
checkCanSend={checkCanSend}
|
||||
visionConfig={visionConfig}
|
||||
fileConfig={fileConfig}
|
||||
/>
|
||||
</div>)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import s from './card.module.css'
|
||||
|
||||
type PropType = {
|
||||
interface PropType {
|
||||
children: React.ReactNode
|
||||
text?: string
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ function classNames(...classes: any[]) {
|
||||
|
||||
const MAX_CONVERSATION_LENTH = 20
|
||||
|
||||
export type ISidebarProps = {
|
||||
export interface ISidebarProps {
|
||||
copyRight: string
|
||||
currentId: string
|
||||
onCurrentIdChange: (id: string) => void
|
||||
@ -38,7 +38,8 @@ const Sidebar: FC<ISidebarProps> = ({
|
||||
<div className="flex flex-shrink-0 p-4 !pb-0">
|
||||
<Button
|
||||
onClick={() => { onCurrentIdChange('-1') }}
|
||||
className="group block w-full flex-shrink-0 !justify-start !h-9 text-primary-600 items-center text-sm">
|
||||
className="group block w-full flex-shrink-0 !justify-start !h-9 text-primary-600 items-center text-sm"
|
||||
>
|
||||
<PencilSquareIcon className="mr-2 h-4 w-4" /> {t('app.chat.newChat')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,7 @@ import s from './style.module.css'
|
||||
import { StarIcon } from '@/app/components//welcome/massive-component'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type ITemplateVarPanelProps = {
|
||||
export interface ITemplateVarPanelProps {
|
||||
className?: string
|
||||
header: ReactNode
|
||||
children?: ReactNode | null
|
||||
@ -38,7 +38,7 @@ const TemplateVarPanel: FC<ITemplateVarPanelProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const PanelTitle: FC<{ title: string; className?: string }> = ({
|
||||
export const PanelTitle: FC<{ title: string, className?: string }> = ({
|
||||
title,
|
||||
className,
|
||||
}) => {
|
||||
@ -50,7 +50,7 @@ export const PanelTitle: FC<{ title: string; className?: string }> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const VarOpBtnGroup: FC<{ className?: string; onConfirm: () => void; onCancel: () => void }> = ({
|
||||
export const VarOpBtnGroup: FC<{ className?: string, onConfirm: () => void, onCancel: () => void }> = ({
|
||||
className,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
|
||||
@ -3,6 +3,7 @@ import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TemplateVarPanel, { PanelTitle, VarOpBtnGroup } from '../value-panel'
|
||||
import FileUploaderInAttachmentWrapper from '../base/file-uploader-in-attachment'
|
||||
import s from './style.module.css'
|
||||
import { AppInfoComp, ChatBtn, EditBtn, FootLogo, PromptTemplate } from './massive-component'
|
||||
import type { AppInfo, PromptConfig } from '@/types/app'
|
||||
@ -13,7 +14,7 @@ import { DEFAULT_VALUE_MAX_LEN } from '@/config'
|
||||
// regex to match the {{}} and replace it with a span
|
||||
const regex = /\{\{([^}]+)\}\}/g
|
||||
|
||||
export type IWelcomeProps = {
|
||||
export interface IWelcomeProps {
|
||||
conversationName: string
|
||||
hasSetInputs: boolean
|
||||
isPublicVersion: boolean
|
||||
@ -40,8 +41,7 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
const hasVar = promptConfig.prompt_variables.length > 0
|
||||
const [isFold, setIsFold] = useState<boolean>(true)
|
||||
const [inputs, setInputs] = useState<Record<string, any>>((() => {
|
||||
if (hasSetInputs)
|
||||
return savedInputs
|
||||
if (hasSetInputs) { return savedInputs }
|
||||
|
||||
const res: Record<string, any> = {}
|
||||
if (promptConfig) {
|
||||
@ -67,8 +67,7 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
}, [savedInputs])
|
||||
|
||||
const highLightPromoptTemplate = (() => {
|
||||
if (!promptConfig)
|
||||
return ''
|
||||
if (!promptConfig) { return '' }
|
||||
const res = promptConfig.prompt_template.replace(regex, (match, p1) => {
|
||||
return `<span class='text-gray-800 font-bold'>${inputs?.[p1] ? inputs?.[p1] : match}</span>`
|
||||
})
|
||||
@ -122,6 +121,50 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
onChange={(e) => { setInputs({ ...inputs, [item.key]: e.target.value }) }}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'number' && (
|
||||
<input
|
||||
type="number"
|
||||
className="block w-full p-2 text-gray-900 border border-gray-300 rounded-lg bg-gray-50 sm:text-xs focus:ring-blue-500 focus:border-blue-500 "
|
||||
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
|
||||
value={inputs[item.key]}
|
||||
onChange={(e) => { onInputsChange({ ...inputs, [item.key]: e.target.value }) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
item.type === 'file' && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
fileConfig={{
|
||||
allowed_file_types: item.allowed_file_types,
|
||||
allowed_file_extensions: item.allowed_file_extensions,
|
||||
allowed_file_upload_methods: item.allowed_file_upload_methods!,
|
||||
number_limits: 1,
|
||||
fileUploadConfig: {} as any,
|
||||
}}
|
||||
onChange={(files) => {
|
||||
setInputs({ ...inputs, [item.key]: files[0] })
|
||||
}}
|
||||
value={inputs?.[item.key] || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
item.type === 'file-list' && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
fileConfig={{
|
||||
allowed_file_types: item.allowed_file_types,
|
||||
allowed_file_extensions: item.allowed_file_extensions,
|
||||
allowed_file_upload_methods: item.allowed_file_upload_methods!,
|
||||
number_limits: item.max_length,
|
||||
fileUploadConfig: {} as any,
|
||||
}}
|
||||
onChange={(files) => {
|
||||
setInputs({ ...inputs, [item.key]: files })
|
||||
}}
|
||||
value={inputs?.[item.key] || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -131,7 +174,10 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
const canChat = () => {
|
||||
const inputLens = Object.values(inputs).length
|
||||
const promptVariablesLens = promptConfig.prompt_variables.length
|
||||
const emptyInput = inputLens < promptVariablesLens || Object.values(inputs).filter(v => v === '').length > 0
|
||||
const emptyInput = inputLens < promptVariablesLens || Object.entries(inputs).filter(([k, v]) => {
|
||||
const isRequired = promptConfig.prompt_variables.find(item => item.key === k)?.required ?? true
|
||||
return isRequired && v === ''
|
||||
}).length > 0
|
||||
if (emptyInput) {
|
||||
logError(t('app.errorMessage.valueOfVarRequired'))
|
||||
return false
|
||||
@ -140,8 +186,7 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
}
|
||||
|
||||
const handleChat = () => {
|
||||
if (!canChat())
|
||||
return
|
||||
if (!canChat()) { return }
|
||||
|
||||
onStartChat(inputs)
|
||||
}
|
||||
@ -202,8 +247,7 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
return (
|
||||
<VarOpBtnGroup
|
||||
onConfirm={() => {
|
||||
if (!canChat())
|
||||
return
|
||||
if (!canChat()) { return }
|
||||
|
||||
onInputsChange(inputs)
|
||||
setIsFold(true)
|
||||
@ -260,8 +304,7 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
}
|
||||
|
||||
const renderHasSetInputsPrivate = () => {
|
||||
if (!canEditInputs || !hasVar)
|
||||
return null
|
||||
if (!canEditInputs || !hasVar) { return null }
|
||||
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
@ -284,8 +327,7 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
}
|
||||
|
||||
const renderHasSetInputs = () => {
|
||||
if ((!isPublicVersion && !canEditInputs) || !hasVar)
|
||||
return null
|
||||
if ((!isPublicVersion && !canEditInputs) || !hasVar) { return null }
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -326,7 +368,8 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
<a
|
||||
className='text-gray-500'
|
||||
href={siteInfo.privacy_policy}
|
||||
target='_blank'>{t('app.chat.privacyPolicyMiddle')}</a>
|
||||
target='_blank'
|
||||
>{t('app.chat.privacyPolicyMiddle')}</a>
|
||||
{t('app.chat.privacyPolicyRight')}
|
||||
</div>
|
||||
: <div>
|
||||
|
||||
@ -37,7 +37,7 @@ export const StarIcon = () => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ChatBtn: FC<{ onClick: () => void; className?: string }> = ({
|
||||
export const ChatBtn: FC<{ onClick: () => void, className?: string }> = ({
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
@ -46,7 +46,8 @@ export const ChatBtn: FC<{ onClick: () => void; className?: string }> = ({
|
||||
<Button
|
||||
type='primary'
|
||||
className={cn(className, `space-x-2 flex items-center ${s.customBtn}`)}
|
||||
onClick={onClick}>
|
||||
onClick={onClick}
|
||||
>
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M18 10.5C18 14.366 14.418 17.5 10 17.5C8.58005 17.506 7.17955 17.1698 5.917 16.52L2 17.5L3.338 14.377C2.493 13.267 2 11.934 2 10.5C2 6.634 5.582 3.5 10 3.5C14.418 3.5 18 6.634 18 10.5ZM7 9.5H5V11.5H7V9.5ZM15 9.5H13V11.5H15V9.5ZM9 9.5H11V11.5H9V9.5Z" fill="white" />
|
||||
</svg>
|
||||
@ -55,7 +56,7 @@ export const ChatBtn: FC<{ onClick: () => void; className?: string }> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const EditBtn = ({ className, onClick }: { className?: string; onClick: () => void }) => {
|
||||
export const EditBtn = ({ className, onClick }: { className?: string, onClick: () => void }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
|
||||
@ -16,11 +16,11 @@ import {
|
||||
} from '@/app/components/base/icons/workflow'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
|
||||
type BlockIconProps = {
|
||||
interface BlockIconProps {
|
||||
type: BlockEnum
|
||||
size?: string
|
||||
className?: string
|
||||
toolIcon?: string | { content: string; background: string }
|
||||
toolIcon?: string | { content: string, background: string }
|
||||
}
|
||||
const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = {
|
||||
xs: 'w-4 h-4 rounded-[5px] shadow-xs',
|
||||
|
||||
@ -9,7 +9,7 @@ import './style.css'
|
||||
// load file from local instead of cdn https://github.com/suren-atoyan/monaco-react/issues/482
|
||||
loader.config({ paths: { vs: '/vs' } })
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
value?: string | object
|
||||
onChange?: (value: string) => void
|
||||
title: JSX.Element
|
||||
@ -72,8 +72,7 @@ const CodeEditor: FC<Props> = ({
|
||||
}
|
||||
|
||||
const outPutValue = (() => {
|
||||
if (!isJSONStringifyBeauty)
|
||||
return value as string
|
||||
if (!isJSONStringifyBeauty) { return value as string }
|
||||
try {
|
||||
return JSON.stringify(value as object, null, 2)
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import ToggleExpandBtn from './toggle-expand-btn'
|
||||
import useToggleExpend from './use-toggle-expend'
|
||||
import { Clipboard, ClipboardCheck } from '@/app/components/base/icons/line/files'
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
className?: string
|
||||
title: JSX.Element | string
|
||||
headerRight?: JSX.Element
|
||||
|
||||
@ -4,7 +4,7 @@ import type { FC } from 'react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import cn from 'classnames'
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
className?: string
|
||||
height: number
|
||||
minHeight: number
|
||||
@ -40,14 +40,12 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
|
||||
}, [prevUserSelectStyle])
|
||||
|
||||
const { run: didHandleResize } = useDebounceFn((e) => {
|
||||
if (!isResizing)
|
||||
return
|
||||
if (!isResizing) { return }
|
||||
|
||||
const offset = e.clientY - clientY
|
||||
let newHeight = height + offset
|
||||
setClientY(e.clientY)
|
||||
if (newHeight < minHeight)
|
||||
newHeight = minHeight
|
||||
if (newHeight < minHeight) { newHeight = minHeight }
|
||||
onHeightChange(newHeight)
|
||||
}, {
|
||||
wait: 0,
|
||||
@ -85,7 +83,8 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
|
||||
{!hideResize && (
|
||||
<div
|
||||
className='absolute bottom-0 left-0 w-full flex justify-center h-2 cursor-row-resize'
|
||||
onMouseDown={handleStartResize}>
|
||||
onMouseDown={handleStartResize}
|
||||
>
|
||||
<div className='w-5 h-[3px] rounded-sm bg-gray-300'></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -4,7 +4,7 @@ import React, { useCallback } from 'react'
|
||||
import Expand04 from '@/app/components/base/icons/solid/expand-04'
|
||||
import Collapse04 from '@/app/components/base/icons/line/arrows/collapse-04'
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
isExpand: boolean
|
||||
onExpandChange: (isExpand: boolean) => void
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type Params = {
|
||||
interface Params {
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
hasFooter?: boolean
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import Loading02 from '@/app/components/base/icons/line/loading-02'
|
||||
import CheckCircle from '@/app/components/base/icons/line/check-circle'
|
||||
import type { NodeTracing } from '@/types/app'
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
nodeInfo: NodeTracing
|
||||
hideInfo?: boolean
|
||||
}
|
||||
@ -18,20 +18,15 @@ const NodePanel: FC<Props> = ({ nodeInfo, hideInfo = false }) => {
|
||||
const [collapseState, setCollapseState] = useState<boolean>(true)
|
||||
|
||||
const getTime = (time: number) => {
|
||||
if (time < 1)
|
||||
return `${(time * 1000).toFixed(3)} ms`
|
||||
if (time > 60)
|
||||
return `${parseInt(Math.round(time / 60).toString())} m ${(time % 60).toFixed(3)} s`
|
||||
if (time < 1) { return `${(time * 1000).toFixed(3)} ms` }
|
||||
if (time > 60) { return `${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 `${parseFloat((tokens / 1000).toFixed(3))}K`
|
||||
if (tokens >= 1000000)
|
||||
return `${parseFloat((tokens / 1000000).toFixed(3))}M`
|
||||
if (tokens < 1000) { return tokens }
|
||||
if (tokens >= 1000 && tokens < 1000000) { return `${parseFloat((tokens / 1000).toFixed(3))}K` }
|
||||
if (tokens >= 1000000) { return `${parseFloat((tokens / 1000000).toFixed(3))}M` }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -12,7 +12,7 @@ import Loading02 from '@/app/components/base/icons/line/loading-02'
|
||||
import ChevronRight from '@/app/components/base/icons/line/chevron-right'
|
||||
import { WorkflowRunningStatus } from '@/types/app'
|
||||
|
||||
type WorkflowProcessProps = {
|
||||
interface WorkflowProcessProps {
|
||||
data: WorkflowProcess
|
||||
grayBg?: boolean
|
||||
expand?: boolean
|
||||
@ -30,14 +30,11 @@ const WorkflowProcessItem = ({
|
||||
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
|
||||
|
||||
const background = useMemo(() => {
|
||||
if (running && !collapse)
|
||||
return 'linear-gradient(180deg, #E1E4EA 0%, #EAECF0 100%)'
|
||||
if (running && !collapse) { return 'linear-gradient(180deg, #E1E4EA 0%, #EAECF0 100%)' }
|
||||
|
||||
if (succeeded && !collapse)
|
||||
return 'linear-gradient(180deg, #ECFDF3 0%, #F6FEF9 100%)'
|
||||
if (succeeded && !collapse) { return 'linear-gradient(180deg, #ECFDF3 0%, #F6FEF9 100%)' }
|
||||
|
||||
if (failed && !collapse)
|
||||
return 'linear-gradient(180deg, #FEE4E2 0%, #FEF3F2 100%)'
|
||||
if (failed && !collapse) { return 'linear-gradient(180deg, #FEE4E2 0%, #FEF3F2 100%)' }
|
||||
}, [running, succeeded, failed, collapse])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -3,12 +3,12 @@ import { getLocaleOnServer } from '@/i18n/server'
|
||||
import './styles/globals.css'
|
||||
import './styles/markdown.scss'
|
||||
|
||||
const LocaleLayout = ({
|
||||
const LocaleLayout = async ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const locale = getLocaleOnServer()
|
||||
const locale = await getLocaleOnServer()
|
||||
return (
|
||||
<html lang={locale ?? 'en'} className="h-full">
|
||||
<body className="h-full">
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@source "../../node_modules/streamdown/dist/index.js";
|
||||
|
||||
:root {
|
||||
--max-width: 1100px;
|
||||
--border-radius: 12px;
|
||||
|
||||
@ -8,6 +8,7 @@ export const APP_INFO: AppInfo = {
|
||||
copyright: '',
|
||||
privacy_policy: '',
|
||||
default_language: 'en',
|
||||
disable_session_same_site: false, // set it to true if you want to embed the chatbot in an iframe
|
||||
}
|
||||
|
||||
export const isShowPrompt = false
|
||||
|
||||
67
eslint.config.mjs
Normal file
67
eslint.config.mjs
Normal file
@ -0,0 +1,67 @@
|
||||
import { combine, javascript, typescript, stylistic } from '@antfu/eslint-config'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
|
||||
export default combine(
|
||||
javascript({
|
||||
overrides: {
|
||||
'no-unused-vars': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
}),
|
||||
|
||||
typescript(),
|
||||
|
||||
stylistic({
|
||||
lessOpinionated: true,
|
||||
jsx: false,
|
||||
semi: false,
|
||||
quotes: 'single',
|
||||
overrides: {
|
||||
'style/indent': ['error', 2],
|
||||
'style/quotes': ['error', 'single'],
|
||||
'style/max-statements-per-line': 'off',
|
||||
},
|
||||
}),
|
||||
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'unused-imports/no-unused-vars': 'warn',
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'@typescript-eslint/no-use-before-define': 'off',
|
||||
'ts/no-use-before-define': 'off',
|
||||
'style/brace-style': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/out/**',
|
||||
'**/.next/**',
|
||||
'**/public/**',
|
||||
'**/*.json',
|
||||
'tailwind.config.js',
|
||||
'next.config.js',
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2025,
|
||||
...globals.node,
|
||||
React: 'readable',
|
||||
JSX: 'readable',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -10,10 +10,8 @@ export enum MediaType {
|
||||
const useBreakpoints = () => {
|
||||
const [width, setWidth] = React.useState(globalThis.innerWidth)
|
||||
const media = (() => {
|
||||
if (width <= 640)
|
||||
return MediaType.mobile
|
||||
if (width <= 768)
|
||||
return MediaType.tablet
|
||||
if (width <= 640) { return MediaType.mobile }
|
||||
if (width <= 768) { return MediaType.tablet }
|
||||
return MediaType.pc
|
||||
})()
|
||||
|
||||
|
||||
@ -30,8 +30,7 @@ function useConversation() {
|
||||
// input can be updated by user
|
||||
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any> | null>(null)
|
||||
const resetNewConversationInputs = () => {
|
||||
if (!newConversationInputs)
|
||||
return
|
||||
if (!newConversationInputs) { return }
|
||||
setNewConversationInputs(produce(newConversationInputs, (draft) => {
|
||||
Object.keys(draft).forEach((key) => {
|
||||
draft[key] = ''
|
||||
|
||||
@ -12,6 +12,5 @@ export const getLocaleOnClient = (): Locale => {
|
||||
export const setLocaleOnClient = (locale: Locale, notReload?: boolean) => {
|
||||
Cookies.set(LOCALE_COOKIE_NAME, locale)
|
||||
changeLanguage(locale)
|
||||
if (!notReload)
|
||||
location.reload()
|
||||
if (!notReload) { location.reload() }
|
||||
}
|
||||
|
||||
@ -4,11 +4,21 @@ import { initReactI18next } from 'react-i18next'
|
||||
import commonEn from './lang/common.en'
|
||||
import commonEs from './lang/common.es'
|
||||
import commonZh from './lang/common.zh'
|
||||
import commonVi from './lang/common.vi'
|
||||
import commonJa from './lang/common.ja'
|
||||
import commonFr from './lang/common.fr'
|
||||
import appEn from './lang/app.en'
|
||||
import appEs from './lang/app.es'
|
||||
import appZh from './lang/app.zh'
|
||||
import appVi from './lang/app.vi'
|
||||
import appJa from './lang/app.ja'
|
||||
import appFr from './lang/app.fr'
|
||||
import toolsEn from './lang/tools.en'
|
||||
import toolsZh from './lang/tools.zh'
|
||||
import toolsVi from './lang/tools.vi'
|
||||
import toolsJa from './lang/tools.ja'
|
||||
import toolsFr from './lang/tools.fr'
|
||||
|
||||
import type { Locale } from '.'
|
||||
|
||||
const resources = {
|
||||
@ -34,6 +44,30 @@ const resources = {
|
||||
tools: toolsZh,
|
||||
},
|
||||
},
|
||||
'vi': {
|
||||
translation: {
|
||||
common: commonVi,
|
||||
app: appVi,
|
||||
// tools
|
||||
tools: toolsVi,
|
||||
},
|
||||
},
|
||||
'ja': {
|
||||
translation: {
|
||||
common: commonJa,
|
||||
app: appJa,
|
||||
// tools
|
||||
tools: toolsJa,
|
||||
},
|
||||
},
|
||||
'fr': {
|
||||
translation: {
|
||||
common: commonFr,
|
||||
app: appFr,
|
||||
// tools
|
||||
tools: toolsFr,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
i18n.use(initReactI18next)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export const i18n = {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'es', 'zh-Hans'],
|
||||
locales: ['en', 'es', 'zh-Hans', 'ja', 'fr'],
|
||||
} as const
|
||||
|
||||
export type Locale = typeof i18n['locales'][number]
|
||||
|
||||
36
i18n/lang/app.fr.ts
Normal file
36
i18n/lang/app.fr.ts
Normal file
@ -0,0 +1,36 @@
|
||||
const translation = {
|
||||
common: {
|
||||
welcome: 'Bienvenue sur l\'application',
|
||||
appUnavailable: 'L\'application n\'est pas disponible',
|
||||
appUnkonwError: 'L\'application n\'est pas disponible',
|
||||
},
|
||||
chat: {
|
||||
newChat: 'Nouvelle conversation',
|
||||
newChatDefaultName: 'Nouvelle conversation',
|
||||
openingStatementTitle: 'Phrase d\'ouverture',
|
||||
powerBy: 'Propulsé par',
|
||||
prompt: 'Prompt',
|
||||
privatePromptConfigTitle: 'Param tres de la conversation',
|
||||
publicPromptConfigTitle: 'Prompt initial',
|
||||
configStatusDes: 'Avant de commencer, vous pouvez modifier les paramètres de la conversation',
|
||||
configDisabled:
|
||||
'Les paramètres de la session précédente ont été utilisés pour cette session.',
|
||||
startChat: 'Démarrer la conversation',
|
||||
privacyPolicyLeft:
|
||||
'Veuillez lire la ',
|
||||
privacyPolicyMiddle:
|
||||
'politique de confidentialité ',
|
||||
privacyPolicyRight:
|
||||
' fournie par le développeur de l\'application.',
|
||||
},
|
||||
errorMessage: {
|
||||
valueOfVarRequired: 'La valeur des variables ne peut pas être vide',
|
||||
waitForResponse:
|
||||
'Veuillez attendre que la réponse au message précédent soit terminée.',
|
||||
},
|
||||
variableTable: {
|
||||
optional: 'Facultatif',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
36
i18n/lang/app.ja.ts
Normal file
36
i18n/lang/app.ja.ts
Normal file
@ -0,0 +1,36 @@
|
||||
const translation = {
|
||||
common: {
|
||||
welcome: 'ご利用いただきありがとうございます',
|
||||
appUnavailable: 'アプリは利用できません',
|
||||
appUnkonwError: 'アプリは利用できません',
|
||||
},
|
||||
chat: {
|
||||
newChat: '新しいチャット',
|
||||
newChatDefaultName: '新しい会話',
|
||||
openingStatementTitle: 'オープニングステートメント',
|
||||
powerBy: '提供元',
|
||||
prompt: 'プロンプト',
|
||||
privatePromptConfigTitle: '会話設定',
|
||||
publicPromptConfigTitle: '初期プロンプト',
|
||||
configStatusDes: '開始前に、会話設定を変更できます',
|
||||
configDisabled:
|
||||
'前回のセッション設定がこのセッションで使用されています。',
|
||||
startChat: '開始',
|
||||
privacyPolicyLeft:
|
||||
'ご利用前に、',
|
||||
privacyPolicyMiddle:
|
||||
'プライバシーポリシー',
|
||||
privacyPolicyRight:
|
||||
' をお読みください。',
|
||||
},
|
||||
errorMessage: {
|
||||
valueOfVarRequired: '変数の値は空にできません',
|
||||
waitForResponse:
|
||||
'前のメッセージの応答が完了するまでお待ちください。',
|
||||
},
|
||||
variableTable: {
|
||||
optional: '任意',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
36
i18n/lang/app.vi.ts
Normal file
36
i18n/lang/app.vi.ts
Normal file
@ -0,0 +1,36 @@
|
||||
const translation = {
|
||||
common: {
|
||||
welcome: 'Chào mừng bạn sử dụng',
|
||||
appUnavailable: 'Ứng dụng không khả dụng',
|
||||
appUnkonwError: 'Ứng dụng không khả dụng',
|
||||
},
|
||||
chat: {
|
||||
newChat: 'Cuộc trò chuyện mới',
|
||||
newChatDefaultName: 'Cuộc trò chuyện mới',
|
||||
openingStatementTitle: 'Lời mở đầu',
|
||||
powerBy: 'Được hỗ trợ bởi',
|
||||
prompt: 'Nhắc nhở',
|
||||
privatePromptConfigTitle: 'Cài đặt cuộc trò chuyện',
|
||||
publicPromptConfigTitle: 'Nhắc nhở ban đầu',
|
||||
configStatusDes: 'Trước khi bắt đầu, bạn có thể chỉnh sửa cài đặt cuộc trò chuyện',
|
||||
configDisabled:
|
||||
'Cài đặt của phiên trước đã được sử dụng cho phiên này.',
|
||||
startChat: 'Bắt đầu trò chuyện',
|
||||
privacyPolicyLeft:
|
||||
'Vui lòng đọc ',
|
||||
privacyPolicyMiddle:
|
||||
'chính sách bảo mật',
|
||||
privacyPolicyRight:
|
||||
' được cung cấp bởi nhà phát triển ứng dụng.',
|
||||
},
|
||||
errorMessage: {
|
||||
valueOfVarRequired: 'Giá trị của biến không thể để trống',
|
||||
waitForResponse:
|
||||
'Vui lòng đợi phản hồi từ tin nhắn trước khi gửi tin nhắn mới.',
|
||||
},
|
||||
variableTable: {
|
||||
optional: 'Tùy chọn',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -28,6 +28,16 @@ const translation = {
|
||||
pasteImageLinkInvalid: 'Invalid image link',
|
||||
imageUpload: 'Image Upload',
|
||||
},
|
||||
fileUploader: {
|
||||
uploadFromComputer: 'Local upload',
|
||||
pasteFileLink: 'Paste file link',
|
||||
pasteFileLinkInputPlaceholder: 'Enter URL...',
|
||||
uploadFromComputerReadError: 'File reading failed, please try again.',
|
||||
uploadFromComputerUploadError: 'File upload failed, please upload again.',
|
||||
uploadFromComputerLimit: 'Upload {{type}} cannot exceed {{size}}',
|
||||
pasteFileLinkInvalid: 'Invalid file link',
|
||||
fileExtensionNotSupport: 'File extension not supported',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
@ -28,6 +28,16 @@ const translation = {
|
||||
pasteImageLinkInvalid: 'Enlace de imagen no válido',
|
||||
imageUpload: 'Subir imagen',
|
||||
},
|
||||
fileUploader: {
|
||||
uploadFromComputer: 'Carga local',
|
||||
pasteFileLink: 'Pegar enlace de archivo',
|
||||
uploadFromComputerReadError: 'Error en la lectura del archivo, inténtelo de nuevo.',
|
||||
uploadFromComputerUploadError: 'Error en la carga del archivo, vuelva a cargarlo.',
|
||||
pasteFileLinkInvalid: 'Enlace de archivo no válido',
|
||||
fileExtensionNotSupport: 'Extensión de archivo no compatible',
|
||||
pasteFileLinkInputPlaceholder: 'Introduzca la URL...',
|
||||
uploadFromComputerLimit: 'El archivo de carga no puede exceder {{size}}',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
33
i18n/lang/common.fr.ts
Normal file
33
i18n/lang/common.fr.ts
Normal file
@ -0,0 +1,33 @@
|
||||
const translation = {
|
||||
api: {
|
||||
success: 'Succès',
|
||||
saved: 'Enregistré',
|
||||
create: 'Créé',
|
||||
},
|
||||
operation: {
|
||||
confirm: 'Confirmer',
|
||||
cancel: 'Annuler',
|
||||
clear: 'Effacer',
|
||||
save: 'Enregistrer',
|
||||
edit: 'Éditer',
|
||||
refresh: 'Redémarrer',
|
||||
search: 'Rechercher',
|
||||
send: 'Envoyer',
|
||||
lineBreak: 'Saut de ligne',
|
||||
like: 'like',
|
||||
dislike: 'dislike',
|
||||
ok: 'D\'accord',
|
||||
},
|
||||
imageUploader: {
|
||||
uploadFromComputer: 'Télécharger depuis l\'ordinateur',
|
||||
uploadFromComputerReadError: 'Édition de l\'image échouée, veuillez essayer à nouveau.',
|
||||
uploadFromComputerUploadError: 'Édition de l\'image échouée, veuillez télécharger à nouveau.',
|
||||
uploadFromComputerLimit: 'Les images téléchargées ne peuvent pas dépasser {{size}} Mo',
|
||||
pasteImageLink: 'Coller le lien de l\'image',
|
||||
pasteImageLinkInputPlaceholder: 'Coller le lien de l\'image ici',
|
||||
pasteImageLinkInvalid: 'Lien d\'image invalide',
|
||||
imageUpload: 'Téléchargement d\'image',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
43
i18n/lang/common.ja.ts
Normal file
43
i18n/lang/common.ja.ts
Normal file
@ -0,0 +1,43 @@
|
||||
const translation = {
|
||||
api: {
|
||||
success: '成功',
|
||||
saved: '保存しました',
|
||||
create: '作成しました',
|
||||
},
|
||||
operation: {
|
||||
confirm: '確認',
|
||||
cancel: 'キャンセル',
|
||||
clear: 'クリア',
|
||||
save: '保存',
|
||||
edit: '編集',
|
||||
refresh: '再起動',
|
||||
search: '検索',
|
||||
send: '送信',
|
||||
lineBreak: '改行',
|
||||
like: 'いいね',
|
||||
dislike: 'よくないね',
|
||||
ok: 'OK',
|
||||
},
|
||||
imageUploader: {
|
||||
uploadFromComputer: 'コンピューターからアップロード',
|
||||
uploadFromComputerReadError: '画像の読み込みに失敗しました。もう一度お試しください。',
|
||||
uploadFromComputerUploadError: '画像のアップロードに失敗しました。もう一度アップロードしてください。',
|
||||
uploadFromComputerLimit: 'アップロードする画像は{{size}} MBを超えてはいけません',
|
||||
pasteImageLink: '画像リンクを貼り付け',
|
||||
pasteImageLinkInputPlaceholder: 'ここに画像リンクを貼り付けてください',
|
||||
pasteImageLinkInvalid: '無効な画像リンクです',
|
||||
imageUpload: '画像アップロード',
|
||||
},
|
||||
fileUploader: {
|
||||
uploadFromComputer: 'ローカルアップロード',
|
||||
pasteFileLink: 'ファイルリンクの貼り付け',
|
||||
pasteFileLinkInputPlaceholder: 'URLを入力...',
|
||||
uploadFromComputerLimit: 'アップロードファイルは{{size}}を超えてはなりません',
|
||||
uploadFromComputerUploadError: 'ファイルのアップロードに失敗しました。再度アップロードしてください。',
|
||||
uploadFromComputerReadError: 'ファイルの読み取りに失敗しました。もう一度やり直してください。',
|
||||
fileExtensionNotSupport: 'ファイル拡張子はサポートされていません',
|
||||
pasteFileLinkInvalid: '無効なファイルリンク',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
43
i18n/lang/common.vi.ts
Normal file
43
i18n/lang/common.vi.ts
Normal file
@ -0,0 +1,43 @@
|
||||
const translation = {
|
||||
api: {
|
||||
success: 'Thành công',
|
||||
saved: 'Đã lưu',
|
||||
create: 'Đã tạo',
|
||||
},
|
||||
operation: {
|
||||
confirm: 'Xác nhận',
|
||||
cancel: 'Hủy',
|
||||
clear: 'Xóa',
|
||||
save: 'Lưu',
|
||||
edit: 'Chỉnh sửa',
|
||||
refresh: 'Khởi động lại',
|
||||
search: 'Tìm kiếm',
|
||||
send: 'Gửi',
|
||||
lineBreak: 'Xuống dòng',
|
||||
like: 'thích',
|
||||
dislike: 'không thích',
|
||||
ok: 'OK',
|
||||
},
|
||||
imageUploader: {
|
||||
uploadFromComputer: 'Tải lên từ máy tính',
|
||||
uploadFromComputerReadError: 'Đọc ảnh thất bại, vui lòng thử lại.',
|
||||
uploadFromComputerUploadError: 'Tải ảnh lên thất bại, vui lòng tải lại.',
|
||||
uploadFromComputerLimit: 'Ảnh tải lên không được vượt quá {{size}} MB',
|
||||
pasteImageLink: 'Dán liên kết ảnh',
|
||||
pasteImageLinkInputPlaceholder: 'Dán liên kết ảnh vào đây',
|
||||
pasteImageLinkInvalid: 'Liên kết ảnh không hợp lệ',
|
||||
imageUpload: 'Tải ảnh lên',
|
||||
},
|
||||
fileUploader: {
|
||||
uploadFromComputer: 'Tải lên cục bộ',
|
||||
pasteFileLink: 'Dán liên kết tệp',
|
||||
pasteFileLinkInputPlaceholder: 'Nhập URL...',
|
||||
uploadFromComputerLimit: 'Tải lên tệp không được vượt quá {{size}}',
|
||||
fileExtensionNotSupport: 'Phần mở rộng tệp không được hỗ trợ',
|
||||
pasteFileLinkInvalid: 'Liên kết tệp không hợp lệ',
|
||||
uploadFromComputerUploadError: 'Tải lên tệp không thành công, vui lòng tải lên lại.',
|
||||
uploadFromComputerReadError: 'Đọc tệp không thành công, vui lòng thử lại.',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -28,6 +28,16 @@ const translation = {
|
||||
pasteImageLinkInvalid: '图片链接无效',
|
||||
imageUpload: '图片上传',
|
||||
},
|
||||
fileUploader: {
|
||||
uploadFromComputer: '从本地上传',
|
||||
pasteFileLink: '粘贴文件链接',
|
||||
pasteFileLinkInputPlaceholder: '输入文件链接',
|
||||
uploadFromComputerReadError: '文件读取失败,请重新选择。',
|
||||
uploadFromComputerUploadError: '文件上传失败,请重新上传。',
|
||||
uploadFromComputerLimit: '上传 {{type}} 不能超过 {{size}}',
|
||||
pasteFileLinkInvalid: '文件链接无效',
|
||||
fileExtensionNotSupport: '文件类型不支持',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
103
i18n/lang/tools.fr.ts
Normal file
103
i18n/lang/tools.fr.ts
Normal file
@ -0,0 +1,103 @@
|
||||
const translation = {
|
||||
title: 'Outils',
|
||||
createCustomTool: 'Créer un outil personnalisé',
|
||||
type: {
|
||||
all: 'Tous',
|
||||
builtIn: 'Intégré',
|
||||
custom: 'Personnalisé',
|
||||
},
|
||||
contribute: {
|
||||
line1: 'Je suis intéressé pour ',
|
||||
line2: ' contribuer à des outils de Dify.',
|
||||
viewGuide: 'Voir le guide',
|
||||
},
|
||||
author: 'Par',
|
||||
auth: {
|
||||
unauthorized: 'Autoriser',
|
||||
authorized: 'Autorisé',
|
||||
setup: 'Configurer l\'autorisation pour utiliser',
|
||||
setupModalTitle: 'Configurer l\'autorisation',
|
||||
setupModalTitleDescription: 'Aprèss avoir configuré les informations d\'identification, tous les membres de l\'espace de travail pourront utiliser cet outil pour orchestrer les applications.',
|
||||
},
|
||||
includeToolNum: '{{num}} outils inclus',
|
||||
addTool: 'Ajouter un outil',
|
||||
createTool: {
|
||||
title: 'Créer un outil personnalisé',
|
||||
editAction: 'Configurer',
|
||||
editTitle: 'Éditer l\'outil personnalisé',
|
||||
name: 'Nom',
|
||||
toolNamePlaceHolder: 'Saisissez le nom de l\'outil',
|
||||
schema: 'Schéma',
|
||||
schemaPlaceHolder: 'Saisissez votre schéma OpenAPI ici',
|
||||
viewSchemaSpec: 'Voir la spécification OpenAPI-Swagger',
|
||||
importFromUrl: 'Importer depuis une URL',
|
||||
importFromUrlPlaceHolder: 'https://...',
|
||||
urlError: 'Veuillez saisir une URL valide',
|
||||
examples: 'Exemples',
|
||||
exampleOptions: {
|
||||
json: 'Météo (JSON)',
|
||||
yaml: 'Pet Store (YAML)',
|
||||
blankTemplate: 'Modèle vide',
|
||||
},
|
||||
availableTools: {
|
||||
title: 'Outils disponibles',
|
||||
name: 'Nom',
|
||||
description: 'Description',
|
||||
method: 'Méthode',
|
||||
path: 'Chemin',
|
||||
action: 'Actions',
|
||||
test: 'Test',
|
||||
},
|
||||
authMethod: {
|
||||
title: 'Méthode d\'autorisation',
|
||||
type: 'Type d\'autorisation',
|
||||
types: {
|
||||
none: 'Aucun',
|
||||
api_key: 'Clé API',
|
||||
},
|
||||
key: 'Clé',
|
||||
value: 'Valeur',
|
||||
},
|
||||
privacyPolicy: 'Politique de confidentialité',
|
||||
privacyPolicyPlaceholder: 'Veuillez saisir la politique de confidentialité',
|
||||
},
|
||||
test: {
|
||||
title: 'Test',
|
||||
parametersValue: 'Paramètres & Valeurs',
|
||||
parameters: 'Paramètres',
|
||||
value: 'Valeurs',
|
||||
testResult: 'Résultats du test',
|
||||
testResultPlaceholder: 'Le résultat du test sera affiché ici',
|
||||
},
|
||||
thought: {
|
||||
using: 'En cours d\'utilisation',
|
||||
used: 'Utilisé',
|
||||
requestTitle: 'Demande ',
|
||||
responseTitle: 'Réponse de ',
|
||||
},
|
||||
setBuiltInTools: {
|
||||
info: 'Informations',
|
||||
setting: 'Paramétrage',
|
||||
toolDescription: 'Description de l\'outil',
|
||||
parameters: 'paramètres',
|
||||
string: 'chaine de caractères',
|
||||
number: 'nombre',
|
||||
required: 'Requis',
|
||||
infoAndSetting: 'Informations & Paramétrage',
|
||||
},
|
||||
noCustomTool: {
|
||||
title: 'Aucun outil personnalisé !',
|
||||
content: 'Ajoutez et gérez vos outils personnalisé pour créer des applications d\'apprentissage automatique.',
|
||||
createTool: 'Créer un outil',
|
||||
},
|
||||
noSearchRes: {
|
||||
title: 'Désolé, pas de résultats!',
|
||||
content: 'Nous n\'avons pas trouvé d\'outils qui correspondent à votre recherche.',
|
||||
reset: 'Réinitialiser la recherche',
|
||||
},
|
||||
builtInPromptTitle: 'Suggestion',
|
||||
toolRemoved: 'Outil supprimé',
|
||||
notAuthorized: 'Outil non autorisé',
|
||||
}
|
||||
|
||||
export default translation
|
||||
103
i18n/lang/tools.ja.ts
Normal file
103
i18n/lang/tools.ja.ts
Normal file
@ -0,0 +1,103 @@
|
||||
const translation = {
|
||||
title: 'ツール',
|
||||
createCustomTool: 'カスタムツールの作成',
|
||||
type: {
|
||||
all: 'すべて',
|
||||
builtIn: '内蔵',
|
||||
custom: 'カスタム',
|
||||
},
|
||||
contribute: {
|
||||
line1: '興味があります ',
|
||||
line2: 'Difyにツールを貢献すること。',
|
||||
viewGuide: 'ガイドを見る',
|
||||
},
|
||||
author: '作成者',
|
||||
auth: {
|
||||
unauthorized: '認証が必要',
|
||||
authorized: '認証済み',
|
||||
setup: '使用するために認証を設定',
|
||||
setupModalTitle: '認証設定',
|
||||
setupModalTitleDescription: '資格情報を設定すると、ワークスペース内のすべてのメンバーがアプリケーションを編成する際にこのツールを使用できるようになります。',
|
||||
},
|
||||
includeToolNum: '{{num}} のツールが含まれています',
|
||||
addTool: 'ツールを追加',
|
||||
createTool: {
|
||||
title: 'カスタムツールの作成',
|
||||
editAction: '設定',
|
||||
editTitle: 'カスタムツールの編集',
|
||||
name: '名前',
|
||||
toolNamePlaceHolder: 'ツール名を入力してください',
|
||||
schema: 'スキーマ',
|
||||
schemaPlaceHolder: 'ここにOpenAPIスキーマを入力してください',
|
||||
viewSchemaSpec: 'OpenAPI-Swagger仕様を見る',
|
||||
importFromUrl: 'URLからインポート',
|
||||
importFromUrlPlaceHolder: 'https://...',
|
||||
urlError: '有効なURLを入力してください',
|
||||
examples: '例',
|
||||
exampleOptions: {
|
||||
json: '天気予報(JSON)',
|
||||
yaml: 'ペットストア(YAML)',
|
||||
blankTemplate: '空のテンプレート',
|
||||
},
|
||||
availableTools: {
|
||||
title: '利用可能なツール',
|
||||
name: '名前',
|
||||
description: '説明',
|
||||
method: 'メソッド',
|
||||
path: 'パス',
|
||||
action: 'アクション',
|
||||
test: 'テスト',
|
||||
},
|
||||
authMethod: {
|
||||
title: '認証方法',
|
||||
type: '認証タイプ',
|
||||
types: {
|
||||
none: 'なし',
|
||||
api_key: 'APIキー',
|
||||
},
|
||||
key: 'キー',
|
||||
value: '値',
|
||||
},
|
||||
privacyPolicy: 'プライバシーポリシー',
|
||||
privacyPolicyPlaceholder: 'プライバシーポリシーを入力してください',
|
||||
},
|
||||
test: {
|
||||
title: 'テスト',
|
||||
parametersValue: 'パラメータと値',
|
||||
parameters: 'パラメータ',
|
||||
value: '値',
|
||||
testResult: 'テスト結果',
|
||||
testResultPlaceholder: 'テスト結果はここに表示されます',
|
||||
},
|
||||
thought: {
|
||||
using: '使用中',
|
||||
used: '使用済み',
|
||||
requestTitle: 'リクエスト先',
|
||||
responseTitle: 'レスポンス元',
|
||||
},
|
||||
setBuiltInTools: {
|
||||
info: '情報',
|
||||
setting: '設定',
|
||||
toolDescription: 'ツールの説明',
|
||||
parameters: 'パラメータ',
|
||||
string: '文字列',
|
||||
number: '数値',
|
||||
required: '必須',
|
||||
infoAndSetting: '情報と設定',
|
||||
},
|
||||
noCustomTool: {
|
||||
title: 'カスタムツールがありません!',
|
||||
content: 'ここでカスタムツールを追加および管理して、AIアプリを構築します。',
|
||||
createTool: 'ツールの作成',
|
||||
},
|
||||
noSearchRes: {
|
||||
title: '申し訳ありません、結果が見つかりません!',
|
||||
content: '検索条件に一致するツールは見つかりませんでした。',
|
||||
reset: '検索をリセット',
|
||||
},
|
||||
builtInPromptTitle: 'プロンプト',
|
||||
toolRemoved: 'ツールが削除されました',
|
||||
notAuthorized: 'ツールが認証されていません',
|
||||
}
|
||||
|
||||
export default translation
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user