Compare commits

...

86 Commits

Author SHA1 Message Date
510ac2afaa Merge pull request #201 from langgenius/fix/next-security
fix: nextjs security update
2025-12-12 11:20:44 +08:00
b26ffc7f1a chore: next security 2025-12-12 11:11:22 +08:00
123d55694a Merge pull request #200 from langgenius/fix/new-react-security-issue
fix: react security issue
2025-12-12 10:03:39 +08:00
0d220f5a54 fix: react security issue 2025-12-12 09:36:49 +08:00
ee5ed029bb Merge pull request #199 from langgenius/fix/CVE-2025-55182
fix: CVE-2025-55182
2025-12-09 14:36:50 +08:00
6b0302e093 fix: CVE-2025-55182 2025-12-09 13:43:01 +08:00
55a77ea86d Merge pull request #182 from lyzno1/feature/streamdown-and-fixes
feat: Streamdown integration and UI/UX improvements
2025-09-16 10:27:39 +08:00
c4b8029702 Merge remote-tracking branch 'upstream/main' into feature/streamdown-and-fixes 2025-09-16 10:23:50 +08:00
aabdcdb3df Merge pull request #183 from lyzno1/chore/add-pnpm-lock
chore: add pnpm-lock.yaml for dependency locking
2025-09-16 10:19:38 +08:00
71a13ba418 Merge pull request #178 from zhiheng-yu/feat/staged-docker-build
feat(docker): Docker build with multi-stage
2025-09-16 10:14:07 +08:00
6a8f4e6db1 Merge pull request #125 from AllForNothing/fix/same-site
fix: add new setting item to disable/enable same site for session_id cookie
2025-09-16 10:09:59 +08:00
9b1288fc96 Merge branch 'main' into chore/add-pnpm-lock 2025-09-16 10:08:24 +08:00
518ec4fb9d Merge pull request #123 from vvatelot/i18n/french
i18n(fr): Add french translations
2025-09-16 10:07:21 +08:00
12e52b5d7e Merge pull request #181 from lyzno1/chore/eslint-v9-migration
chore: eslint v9 migration
2025-09-16 10:05:21 +08:00
238611f547 Merge pull request #143 from Laurel-rao/main
修复 iphone 手机点击聊天框放大的问题
2025-09-16 10:04:49 +08:00
d28e2c29fc chore: add pnpm-lock.yaml 2025-09-15 11:10:07 +08:00
34bb34f890 [Error] Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.
error (intercept-console-error.js:57)
	getRootForUpdatedFiber (react-dom-client.development.js:3899)
	dispatchSetStateInternal (react-dom-client.development.js:8249)
	dispatchSetState (react-dom-client.development.js:8209)
	(anonymous function) (react-tooltip.min.mjs:18:14416)
2025-09-15 10:58:08 +08:00
18a4229464 Error: Route "/api/messages/[messageId]/feedbacks" used params.messageId. params should be awaited before using its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at POST (app/api/messages/[messageId]/feedbacks/route.ts:12:11)
  10 |     rating,
  11 |   } = body
> 12 |   const { messageId } = params
     |           ^
  13 |   const { user } = getInfo(request)
  14 |   const { data } = await client.messageFeedback(messageId, rating, user)
  15 |   return NextResponse.json(data)
 POST /api/messages/36fd2d18-909b-4bb9-b46d-6e7f72a705e4/feedbacks 200 in 557ms
2025-09-15 10:57:56 +08:00
0cb8fdcf15 fix: await params in dynamic route for Next.js 15 compatibility 2025-09-15 10:57:38 +08:00
532aba026a fix: filter empty URLs in image gallery to prevent browser reload
- Add validation to filter out empty and whitespace-only image URLs
- Return null when no valid images exist
- Prevents console errors and browser reload issues from empty img src

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 10:57:38 +08:00
7a07e8d56b Fix auto-scroll for page-level scrolling
Replace container scrollTop logic with scrollIntoView API to support page-level scroll bars instead of internal container scrolling.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 10:57:38 +08:00
c907432782 fix: send button ui 2025-09-15 10:56:59 +08:00
672ad29e5d Integrate Streamdown and optimize chat layout
- Replace react-markdown with Streamdown for better streaming support
- Fix input box positioning with proper sidebar offset
- Optimize scrolling behavior with main container handling scroll
- Add max-width constraint for assistant messages
- Ensure proper spacing to prevent input box overlap

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 10:56:59 +08:00
2d426902bd fix: max answer bubble width 2025-09-15 10:56:59 +08:00
3025356fa7 feat: question use streamdown 2025-09-15 10:56:59 +08:00
ba95a431b3 fix: assistant answer pb 2025-09-15 10:56:59 +08:00
9ff81c0306 fix: update cookies() usage for Next.js 15 compatibility
- Make getLocaleOnServer async and await cookies()
- Update LocaleLayout to be async component
- Fix react-tooltip compatibility with React 19

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 10:56:19 +08:00
f809b706a0 Configure husky to use lint-staged
- Update pre-commit hook to run lint-staged instead of full eslint
- Add lint-staged script to package.json
- Only lint staged files during commit for better performance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 10:55:16 +08:00
05dcfcf0ca feat: migrate ESLint to v9 flat config
- Replace .eslintrc.json with eslint.config.mjs
- Simplify configuration using @antfu/eslint-config
- Add necessary ESLint plugin dependencies
- Disable overly strict style rules
- Set package.json type to module for ESM support
- Fix ESLint disable comment format

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 10:55:10 +08:00
2b1882a5e3 chore: update eslint 2025-09-15 10:55:03 +08:00
d3909927af Merge pull request #179 from Eric-Guo/main
Fix file upload (not image) in chat-flow.
2025-09-10 15:51:04 +08:00
17007b2014 Allow file upload in chat. 2025-09-08 16:15:46 +08:00
e91a1f6194 @tailwindcss/line-clamp include by default. 2025-09-08 14:56:12 +08:00
74a656fda2 Initial GPT-5-high generated cursor rules 2025-09-08 14:51:22 +08:00
8f02afed98 Bump some outdated package version 2025-09-08 14:39:47 +08:00
dc1659463e feat(docker): Docker build with multi-stage
This will significantly reduce the size of the final image used for deployment.
2025-09-04 15:11:13 +08:00
b35f0effe5 Merge pull request #169 from LeeeeeeM/fix/add-pre-answer
fix: suggestion is not rendered
2025-06-03 15:25:06 +08:00
60a33804cc fix: suggestion is not rendered 2025-05-30 09:59:27 +08:00
7495bf44a2 fix: fix option check
fix: fix option check
2025-05-27 18:26:23 +08:00
d009b00012 fix: fix option check 2025-05-27 11:33:48 +08:00
1249ea88c9 Merge pull request #157 from langgenius/fix/file-input-caused-page-crash
feat:  support file and filelist input form
2025-04-15 18:51:08 +08:00
d3482db74d chore: can set the write input 2025-04-15 18:41:13 +08:00
beda954867 chore: can use setting config 2025-04-15 18:18:46 +08:00
4ae03c2101 chore: file upload i18n 2025-04-15 17:42:55 +08:00
7216f40bee feat: add single file upload 2025-04-14 18:38:27 +08:00
e9923e8220 fix: string type 2025-04-14 16:33:06 +08:00
9a7e1be35d fix: file type not support 2025-04-14 16:17:52 +08:00
db4e78d796 修复 iphone 手机点击聊天框放大的问题 2025-03-04 14:51:29 +08:00
87c81b99dd Delete .idea/workspace.xml 2024-12-11 18:28:19 +08:00
a8ea35e5c6 fix: add new setting item to disable/enable same site property
fixes #55

Signed-off-by: 孙世军 <1083433931@qq.com>
2024-12-11 18:08:47 +08:00
41406a8596 i18n(fr): Add french translations 2024-12-10 16:09:06 +01:00
9d2d092e9e Merge pull request #95 from yusuke-ten/fix/IMainProps-type
fix IMainProps-type
2024-12-07 22:03:48 +08:00
1f5607221a Merge pull request #93 from sorphwer/fix-openStatement-typo
fix: fix openStatement typo
2024-12-07 22:03:33 +08:00
009674b231 Merge pull request #119 from langgenius/chore/add-show-error-msg
chore: add show api error msg
2024-11-25 13:17:26 +08:00
25ef02d2aa chore: add show api error msg 2024-11-25 13:15:14 +08:00
f6f65cff68 Merge pull request #118 from langgenius/docs/env-description
docs: add more description to env
2024-11-22 11:35:27 +08:00
8c6302d1fc docs: add more description to env 2024-11-22 11:33:10 +08:00
291e9a067b Merge pull request #105 from langgenius/fix/not-support-num-input
fix: not support num input
2024-09-04 18:04:16 +08:00
ac0e3e807d chore: paragrpah form type 2024-09-04 17:55:45 +08:00
b7f703852e fix: not support num input 2024-09-04 17:51:22 +08:00
ef15747e4a Merge pull request #104 from langgenius/fix/i18n-files-problem
fix: i18n problems
2024-09-03 14:54:40 +08:00
f9bd745bb0 fix: i18n problems 2024-09-03 14:52:11 +08:00
e2b37c1a9c Merge pull request #100 from bcat95/patch-5
Update i18next-config.ts
2024-09-03 14:45:52 +08:00
0f490de7ff Merge branch 'main' into patch-5 2024-09-03 14:44:35 +08:00
aaeb440210 Merge pull request #99 from bcat95/patch-1
Create app.vi.ts
2024-09-03 14:40:15 +08:00
b45262add9 Merge pull request #97 from bcat95/patch-3
Create tools.vi.ts
2024-09-03 14:39:53 +08:00
368c6b3dae Merge pull request #94 from yusuke-ten/feat/add-japanese
Add Japanese language settings to i18n
2024-09-03 14:39:28 +08:00
f6fb9c7cea Merge pull request #98 from bcat95/patch-2
Create common.vi.ts
2024-09-03 14:38:07 +08:00
69044eb8a3 Merge pull request #103 from langgenius/fix/not-show-opening-statement
fix: not show opening statement
2024-09-03 14:32:46 +08:00
cafd643c00 fix: not show opening statement 2024-09-03 14:31:05 +08:00
1c12b1dce3 Update i18next-config.ts 2024-08-18 22:43:22 +07:00
94d09ed23b Create tools.vi.ts 2024-08-18 22:41:51 +07:00
5d313f7463 Create common.vi.ts 2024-08-18 22:41:11 +07:00
97203f5ac6 Create app.vi.ts 2024-08-18 22:40:29 +07:00
349e081f1f fix IMainProps-type 2024-08-12 19:10:23 +09:00
7f24387eef Add Japanese language settings to i18n” 2024-08-12 18:44:49 +09:00
5a1c84e79f fix: fix openStatement typo 2024-08-09 21:54:41 +08:00
8d21cbc2da Merge pull request #71 from eltociear/patch-1
docs: update README.md
2024-08-07 18:29:39 +08:00
7bb19ed8ec Merge pull request #90 from langgenius/chore/hide-workflow-run-detail
chore: hide workflow run detail
2024-08-07 18:04:19 +08:00
5a85f0d427 chore: hide workflow run detail 2024-08-07 18:01:00 +08:00
96bd12af44 Merge pull request #38 from Saul-BT/feat/spanish-language
feat: add spanish language
2024-08-07 17:14:47 +08:00
484a5dc102 Merge pull request #81 from yoyocircle/main
fix: typos
2024-08-07 17:08:50 +08:00
10eb176f72 Merge pull request #84 from langgenius/fix/optional-i18n
fix: optional copywriting i18n
2024-07-31 11:56:15 +08:00
f6b4b4a361 fix: typos 2024-07-17 03:59:08 +00:00
df0ae34be1 docs: update README.md
trucated -> truncated
2024-05-09 15:15:24 +09:00
f7ff288ff1 feat: add spanish language 2023-12-07 21:20:21 +01:00
118 changed files with 13165 additions and 606 deletions

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

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

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

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

View File

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

@ -47,5 +47,7 @@ package-lock.json
yarn.lock
.yarnrc.yml
# mcp
.serena
# pmpm
pnpm-lock.yaml

1
.husky/pre-commit Normal file
View File

@ -0,0 +1 @@
pnpm lint-staged

View File

@ -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"
]
}
}

View File

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

View File

@ -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=
```
@ -68,7 +72,7 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
## Deploy on Vercel
> ⚠️ If you are using [Vercel Hobby](https://vercel.com/pricing), your message will be trucated due to the limitation of vercel.
> ⚠️ If you are using [Vercel Hobby](https://vercel.com/pricing), your message will be truncated due to the limitation of vercel.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,26 +3,25 @@ import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
type IAppUnavailableProps = {
isUnknwonReason: boolean
interface IAppUnavailableProps {
isUnknownReason: boolean
errMessage?: string
}
const AppUnavailable: FC<IAppUnavailableProps> = ({
isUnknwonReason,
isUnknownReason,
errMessage,
}) => {
const { t } = useTranslation()
let message = errMessage
if (!errMessage)
message = (isUnknwonReason ? 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'>
<h1 className='mr-5 h-[50px] leading-[50px] pr-5 text-[24px] font-medium'
style={{
borderRight: '1px solid rgba(0,0,0,.3)',
}}>{(errMessage || isUnknwonReason) ? 500 : 404}</h1>
}}>{(errMessage || isUnknownReason) ? 500 : 404}</h1>
<div className='text-sm'>{message}</div>
</div>
)

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

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

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1,129 @@
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiUploadCloud2Line } from '@remixicon/react'
import FileInput from '../file-input'
import { useFile } from '../hooks'
import { useStore } from '../store'
import { FILE_URL_REGEX } from '../constants'
import 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)

View File

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

View File

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

View File

@ -0,0 +1,154 @@
import {
memo,
useState,
} from 'react'
import {
RiDeleteBinLine,
RiDownloadLine,
RiEyeLine,
} from '@remixicon/react'
import FileTypeIcon from './file-type-icon'
import 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)

View File

@ -0,0 +1,91 @@
import { memo } from 'react'
import {
RiFile3Fill,
RiFileCodeFill,
RiFileExcelFill,
RiFileGifFill,
RiFileImageFill,
RiFileMusicFill,
RiFilePdf2Fill,
RiFilePpt2Fill,
RiFileTextFill,
RiFileVideoFill,
RiFileWordFill,
RiMarkdownFill,
} from '@remixicon/react'
import { FileAppearanceTypeEnum } from './types'
import type { FileAppearanceType } from './types'
import cn from '@/utils/classnames'
const FILE_TYPE_ICON_MAP = {
[FileAppearanceTypeEnum.pdf]: {
component: RiFilePdf2Fill,
color: 'text-[#EA3434]',
},
[FileAppearanceTypeEnum.image]: {
component: RiFileImageFill,
color: 'text-[#00B2EA]',
},
[FileAppearanceTypeEnum.video]: {
component: RiFileVideoFill,
color: 'text-[#844FDA]',
},
[FileAppearanceTypeEnum.audio]: {
component: RiFileMusicFill,
color: 'text-[#FF3093]',
},
[FileAppearanceTypeEnum.document]: {
component: RiFileTextFill,
color: 'text-[#6F8BB5]',
},
[FileAppearanceTypeEnum.code]: {
component: RiFileCodeFill,
color: 'text-[#BCC0D1]',
},
[FileAppearanceTypeEnum.markdown]: {
component: RiMarkdownFill,
color: 'text-[#309BEC]',
},
[FileAppearanceTypeEnum.custom]: {
component: RiFile3Fill,
color: 'text-[#BCC0D1]',
},
[FileAppearanceTypeEnum.excel]: {
component: RiFileExcelFill,
color: 'text-[#01AC49]',
},
[FileAppearanceTypeEnum.word]: {
component: RiFileWordFill,
color: 'text-[#2684FF]',
},
[FileAppearanceTypeEnum.ppt]: {
component: RiFilePpt2Fill,
color: 'text-[#FF650F]',
},
[FileAppearanceTypeEnum.gif]: {
component: RiFileGifFill,
color: 'text-[#00B2EA]',
},
}
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)

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

View 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

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

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

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

View File

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

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
import { upload } from '@/service/base'
type ImageUploadParams = {
interface ImageUploadParams {
file: File
onProgressCallback: (progress: number) => void
onSuccessCallback: (res: { id: string }) => void

View File

@ -2,7 +2,7 @@ import React from 'react'
import './style.css'
type ILoadingProps = {
interface ILoadingProps {
type?: 'area' | 'app'
}
const Loading = (

View File

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

View 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

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

@ -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
isResponsing?: boolean
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
@ -67,24 +77,24 @@ const Answer: FC<IAnswerProps> = ({
item,
feedbackDisabled = false,
onFeedback,
isResponsing,
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 */}
@ -153,7 +164,7 @@ const Answer: FC<IAnswerProps> = ({
<Thought
thought={item}
allToolIcons={allToolIcons || {}}
isFinished={!!item.observation || !isResponsing}
isFinished={!!item.observation || !isResponding}
/>
)}
@ -167,33 +178,45 @@ 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`}>
{isResponsing
&& <div className={s.typeingIcon}>
<LoadingAnim type='avatar' />
</div>
}
{isResponding
&& (
<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 && (
<WorkflowProcess data={workflowProcess} hideInfo />
)}
{(isResponsing && (isAgentMode ? (!content && (agent_thoughts || []).filter(item => !!item.thought || !!item.tool).length === 0) : !content))
{(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)}

View File

@ -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
@ -30,9 +33,10 @@ export type IChatProps = {
checkCanSend?: () => boolean
onSend?: (message: string, files: VisionFile[]) => void
useCurrentUserAvatar?: boolean
isResponsing?: boolean
isResponding?: boolean
controlClearQuery?: number
visionConfig?: VisionSettings
fileConfig?: FileUpload
}
const Chat: FC<IChatProps> = ({
@ -43,18 +47,22 @@ const Chat: FC<IChatProps> = ({
checkCanSend,
onSend = () => { },
useCurrentUserAvatar,
isResponsing,
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 (!isResponsing)
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 */}
@ -129,7 +153,8 @@ const Chat: FC<IChatProps> = ({
item={item}
feedbackDisabled={feedbackDisabled}
onFeedback={onFeedback}
isResponsing={isResponsing && isLast}
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={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
@ -33,7 +37,7 @@ const Main: FC = () => {
* app info
*/
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false)
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
const [inited, setInited] = useState<boolean>(false)
// in mobile, show sidebar by click button
@ -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
@ -86,21 +90,20 @@ const Main: FC = () => {
setCurrInputs(inputs)
setChatStarted()
// parse variables in introduction
setChatList(generateNewChatListWithOpenstatement('', inputs))
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 {
@ -121,10 +125,10 @@ const Main: FC = () => {
}
// update chat list of current conversation
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) {
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponding) {
fetchChatList(currConversationId).then((res: any) => {
const { data } = res
const newChatList: ChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs)
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs)
data.forEach((item: any) => {
newChatList.push({
@ -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 canEditInpus = !chatList.some(item => item.isAnswer === false) && isNewConversation
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,26 +196,26 @@ const Main: FC = () => {
name: t('app.chat.newChatDefaultName'),
inputs: newConversationInputs,
introduction: conversationIntroduction,
suggested_questions: suggestedQuestions,
})
}))
}
// sometime introduction is not applied to state
const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => {
let caculatedIntroduction = introduction || conversationIntroduction || ''
const caculatedPromptVariables = inputs || currInputs || null
if (caculatedIntroduction && caculatedPromptVariables)
caculatedIntroduction = replaceVarWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
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) }
const openstatement = {
const openStatement = {
id: `${Date.now()}`,
content: caculatedIntroduction,
content: calculatedIntroduction,
isAnswer: true,
feedbackDisabled: true,
isOpeningStatement: isShowPrompt,
suggestedQuestions,
}
if (caculatedIntroduction)
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)
}
@ -255,14 +285,14 @@ const Main: FC = () => {
setAppUnavailable(true)
}
else {
setIsUnknwonReason(true)
setIsUnknownReason(true)
setAppUnavailable(true)
}
}
})()
}, [])
const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
const [abortController, setAbortController] = useState<AbortController | null>(null)
const { notify } = Toast
const logError = (message: string) => {
@ -270,17 +300,15 @@ 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
const emytyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v)
if (emytyInput) {
const emptyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v)
if (emptyInput) {
logError(t('app.errorMessage.valueOfVarRequired'))
return false
}
@ -291,7 +319,7 @@ const Main: FC = () => {
const [openingSuggestedQuestions, setOpeningSuggestedQuestions] = useState<string[]>([])
const [messageTaskId, setMessageTaskId] = useState('')
const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false)
const [isResponsingConIsCurrCon, setIsResponsingConCurrCon, getIsResponsingConIsCurrCon] = useGetState(true)
const [isRespondingConIsCurrCon, setIsRespondingConCurrCon, getIsRespondingConIsCurrCon] = useGetState(true)
const [userQuery, setUserQuery] = useState('')
const updateCurrentQA = ({
@ -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 (isResponsing) {
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 {
@ -340,13 +389,13 @@ const Main: FC = () => {
})
}
// qustion
// question
const questionId = `question-${Date.now()}`
const questionItem = {
id: questionId,
content: message,
isAnswer: false,
message_files: files,
message_files: (files || []).filter((f: any) => f.type === 'image'),
}
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
@ -374,7 +423,7 @@ const Main: FC = () => {
const prevTempNewConversationId = getCurrConversationId() || '-1'
let tempNewConversationId = ''
setResponsingTrue()
setRespondingTrue()
sendChatMessage(data, {
getAbortController: (abortController) => {
setAbortController(abortController)
@ -385,21 +434,19 @@ 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
if (prevTempNewConversationId !== getCurrConversationId()) {
setIsResponsingConCurrCon(false)
setIsRespondingConCurrCon(false)
return
}
updateCurrentQA({
@ -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()
@ -426,12 +472,11 @@ const Main: FC = () => {
resetNewConversationInputs()
setChatNotStarted()
setCurrConversationId(tempNewConversationId, APP_ID, true)
setResponsingFalse()
setRespondingFalse()
},
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,
@ -465,7 +510,7 @@ const Main: FC = () => {
}
// has switched to other conversation
if (prevTempNewConversationId !== getCurrConversationId()) {
setIsResponsingConCurrCon(false)
setIsRespondingConCurrCon(false)
return false
}
@ -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,13 +559,12 @@ 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 }
},
))
},
onError() {
setResponsingFalse()
setRespondingFalse()
// role back placeholder answer
setChatList(produce(getChatList(), (draft) => {
draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
@ -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 isUnknwonReason={isUnknwonReason} 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>
@ -639,24 +677,23 @@ const Main: FC = () => {
siteInfo={APP_INFO}
promptConfig={promptConfig}
onStartChat={handleStartChat}
canEidtInpus={canEditInpus}
canEditInputs={canEditInputs}
savedInputs={currInputs as Record<string, any>}
onInputsChange={setCurrInputs}
></ConfigSence>
{
hasSetInputs && (
<div className='relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full pb-[66px] mx-auto mb-3.5 overflow-hidden'>
<div className='h-full overflow-y-auto' ref={chatListDomRef}>
<Chat
chatList={chatList}
onSend={handleSend}
onFeedback={handleFeedback}
isResponsing={isResponsing}
checkCanSend={checkCanSend}
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>

View File

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

View File

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

View File

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

View File

@ -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,14 +14,14 @@ 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
siteInfo: AppInfo
promptConfig: PromptConfig
onStartChat: (inputs: Record<string, any>) => void
canEidtInpus: boolean
canEditInputs: boolean
savedInputs: Record<string, any>
onInputsChange: (inputs: Record<string, any>) => void
}
@ -32,7 +33,7 @@ const Welcome: FC<IWelcomeProps> = ({
siteInfo,
promptConfig,
onStartChat,
canEidtInpus,
canEditInputs,
savedInputs,
onInputsChange,
}) => {
@ -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,8 +174,11 @@ const Welcome: FC<IWelcomeProps> = ({
const canChat = () => {
const inputLens = Object.values(inputs).length
const promptVariablesLens = promptConfig.prompt_variables.length
const emytyInput = inputLens < promptVariablesLens || Object.values(inputs).filter(v => v === '').length > 0
if (emytyInput) {
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)
@ -217,7 +261,7 @@ const Welcome: FC<IWelcomeProps> = ({
}
const renderHasSetInputsPublic = () => {
if (!canEidtInpus) {
if (!canEditInputs) {
return (
<TemplateVarPanel
isFold={false}
@ -260,8 +304,7 @@ const Welcome: FC<IWelcomeProps> = ({
}
const renderHasSetInputsPrivate = () => {
if (!canEidtInpus || !hasVar)
return null
if (!canEditInputs || !hasVar) { return null }
return (
<TemplateVarPanel
@ -284,8 +327,7 @@ const Welcome: FC<IWelcomeProps> = ({
}
const renderHasSetInputs = () => {
if ((!isPublicVersion && !canEidtInpus) || !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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
type Params = {
interface Params {
ref: React.RefObject<HTMLDivElement>
hasFooter?: boolean
}

View File

@ -3,16 +3,13 @@ import type { FC } from 'react'
import { useEffect, useState } from 'react'
import cn from 'classnames'
import BlockIcon from './block-icon'
import CodeEditor from './code-editor'
import { CodeLanguage } from '@/types/app'
import AlertCircle from '@/app/components/base/icons/line/alert-circle'
import AlertTriangle from '@/app/components/base/icons/line/alert-triangle'
import Loading02 from '@/app/components/base/icons/line/loading-02'
import CheckCircle from '@/app/components/base/icons/line/check-circle'
import ChevronRight from '@/app/components/base/icons/line/chevron-right'
import type { NodeTracing } from '@/types/app'
type Props = {
interface Props {
nodeInfo: NodeTracing
hideInfo?: boolean
}
@ -21,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(() => {
@ -52,12 +44,6 @@ const NodePanel: FC<Props> = ({ nodeInfo, hideInfo = false }) => {
)}
onClick={() => setCollapseState(!collapseState)}
>
<ChevronRight
className={cn(
'shrink-0 w-3 h-3 mr-1 text-gray-400 transition-all group-hover:text-gray-500',
!collapseState && 'rotate-90',
)}
/>
<BlockIcon size={hideInfo ? 'xs' : 'sm'} className={cn('shrink-0 mr-2', hideInfo && '!mr-1')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} />
<div className={cn(
'grow text-gray-700 text-[13px] leading-[16px] font-semibold truncate',
@ -82,48 +68,6 @@ const NodePanel: FC<Props> = ({ nodeInfo, hideInfo = false }) => {
</div>
)}
</div>
{!collapseState && (
<div className='pb-2'>
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
{nodeInfo.status === 'failed' && (
<div className='px-3 py-[10px] bg-[#fef3f2] rounded-lg border-[0.5px] border-[rbga(0,0,0,0.05)] text-xs leading-[18px] text-[#d92d20] shadow-xs'>{nodeInfo.error}</div>
)}
</div>
{nodeInfo.inputs && (
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
<CodeEditor
readOnly
title={<div>INPUT</div>}
language={CodeLanguage.json}
value={nodeInfo.inputs}
isJSONStringifyBeauty
/>
</div>
)}
{nodeInfo.process_data && (
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
<CodeEditor
readOnly
title={<div>PROCESS DATA</div>}
language={CodeLanguage.json}
value={nodeInfo.process_data}
isJSONStringifyBeauty
/>
</div>
)}
{nodeInfo.outputs && (
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
<CodeEditor
readOnly
title={<div>OUTPUT</div>}
language={CodeLanguage.json}
value={nodeInfo.outputs}
isJSONStringifyBeauty
/>
</div>
)}
</div>
)}
</div>
</div>
)

View File

@ -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(() => {

View File

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

View File

@ -2,6 +2,8 @@
@tailwind components;
@tailwind utilities;
@source "../../node_modules/streamdown/dist/index.js";
:root {
--max-width: 1100px;
--border-radius: 12px;

View File

@ -7,7 +7,8 @@ export const APP_INFO: AppInfo = {
description: '',
copyright: '',
privacy_policy: '',
default_language: 'zh-Hans',
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
View 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',
},
},
},
)

View File

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

View File

@ -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] = ''

View File

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

View File

@ -2,11 +2,23 @@
import i18n from 'i18next'
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 = {
@ -18,6 +30,12 @@ const resources = {
tools: toolsEn,
},
},
'es': {
translation: {
common: commonEs,
app: appEs,
},
},
'zh-Hans': {
translation: {
common: commonZh,
@ -26,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)

View File

@ -1,6 +1,6 @@
export const i18n = {
defaultLocale: 'en',
locales: ['en', 'zh-Hans'],
locales: ['en', 'es', 'zh-Hans', 'ja', 'fr'],
} as const
export type Locale = typeof i18n['locales'][number]

33
i18n/lang/app.es.ts Normal file
View File

@ -0,0 +1,33 @@
const translation = {
common: {
welcome: 'Bienvenido a usar',
appUnavailable: 'App es inaccesible',
appUnkonwError: 'App es inaccesible',
},
chat: {
newChat: 'Nuevo chat',
newChatDefaultName: 'Nueva conversación',
openingStatementTitle: 'Frase de apertura',
powerBy: 'Desarrollado por',
prompt: 'Prompt',
privatePromptConfigTitle: 'Ajustes de conversación',
publicPromptConfigTitle: 'Prompt inicial',
configStatusDes: 'Antes de comenzar, puede modificar la configuración de la conversación',
configDisabled:
'La configuración de la sesión anterior se ha utilizado para esta sesión.',
startChat: 'Comenzar chat',
privacyPolicyLeft:
'Por favor lea la ',
privacyPolicyMiddle:
'política de privacidad',
privacyPolicyRight:
' proporcionada por el desarrollador de la aplicación.',
},
errorMessage: {
valueOfVarRequired: 'El valor de las variables no puede estar vacío',
waitForResponse:
'Por favor espere a que la respuesta al mensaje anterior se complete.',
},
}
export default translation

36
i18n/lang/app.fr.ts Normal file
View 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
View 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
View 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

View File

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

43
i18n/lang/common.es.ts Normal file
View File

@ -0,0 +1,43 @@
const translation = {
api: {
success: 'Éxito',
saved: 'Guardado',
create: 'Creado',
},
operation: {
confirm: 'Confirmar',
cancel: 'Cancelar',
clear: 'Limpiar',
save: 'Guardar',
edit: 'Editar',
refresh: 'Reiniciar',
search: 'Buscar',
send: 'Enviar',
lineBreak: 'Salto de línea',
like: 'Me gusta',
dislike: 'No me gusta',
ok: 'OK',
},
imageUploader: {
uploadFromComputer: 'Subir desde el ordenador',
uploadFromComputerReadError: 'La lectura de la imagen falló, por favor inténtelo de nuevo.',
uploadFromComputerUploadError: 'Error al subir la imagen, por favor inténtelo de nuevo.',
uploadFromComputerLimit: 'Las imágenes subidas no pueden superar los {{size}} MB',
pasteImageLink: 'Pegar enlace de imagen',
pasteImageLinkInputPlaceholder: 'Pegar enlace de imagen aquí',
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
View 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
View 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
View 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

View File

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

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