Compare commits

..

82 Commits

Author SHA1 Message Date
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
fcd6a0215d fix: optional copywriting i18n 2024-07-31 11:54:30 +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
884e72b4f0 Merge pull request #64 from langgenius/feat/support-workflow
Feat: support workflow
2024-04-24 10:14:30 +08:00
f7ff288ff1 feat: add spanish language 2023-12-07 21:20:21 +01:00
120 changed files with 13173 additions and 608 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 yarn.lock
.yarnrc.yml .yarnrc.yml
# mcp
.serena
# pmpm # pmpm
pnpm-lock.yaml 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, "prettier.enable": false,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": "explicit"
}, },
"[python]": { "[python]": {
"editor.formatOnType": true "editor.formatOnType": true
@ -29,4 +29,4 @@
"i18n/lang", "i18n/lang",
"app/api/messages" "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 WORKDIR /app
COPY . . COPY . .
RUN yarn install --frozen-lockfile
RUN yarn install FROM deps AS builder
WORKDIR /app
COPY . .
RUN yarn build 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 EXPOSE 3000
CMD ["node", "server.js"]
CMD ["yarn","start"]

View File

@ -4,11 +4,15 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
## Config App ## Config App
Create a file named `.env.local` in the current directory and copy the contents from `.env.example`. Setting the following content: 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= 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= 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= 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 ## 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. 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' import { client, getInfo } from '@/app/api/utils/common'
export async function POST(request: NextRequest) { 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 { NextResponse } from 'next/server'
import { client, getInfo } from '@/app/api/utils/common' import { client, getInfo } from '@/app/api/utils/common'
export async function POST(request: NextRequest, { params }: { export async function POST(request: NextRequest, { params }: {
params: { conversationId: string } params: Promise<{ conversationId: string }>
}) { }) {
const body = await request.json() const body = await request.json()
const { const {
auto_generate, auto_generate,
name, name,
} = body } = body
const { conversationId } = params const { conversationId } = await params
const { user } = getInfo(request) const { user } = getInfo(request)
// auto generate name // 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 { NextResponse } from 'next/server'
import { client, getInfo, setSession } from '@/app/api/utils/common' import { client, getInfo, setSession } from '@/app/api/utils/common'
@ -11,7 +9,11 @@ export async function GET(request: NextRequest) {
return NextResponse.json(data, { return NextResponse.json(data, {
headers: setSession(sessionId), 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' import { client, getInfo } from '@/app/api/utils/common'
export async function POST(request: NextRequest) { 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 { NextResponse } from 'next/server'
import { client, getInfo } from '@/app/api/utils/common' import { client, getInfo } from '@/app/api/utils/common'
export async function POST(request: NextRequest, { params }: { export async function POST(request: NextRequest, { params }: {
params: { messageId: string } params: Promise<{ messageId: string }>
}) { }) {
const body = await request.json() const body = await request.json()
const { const {
rating, rating,
} = body } = body
const { messageId } = params const { messageId } = await params
const { user } = getInfo(request) const { user } = getInfo(request)
const { data } = await client.messageFeedback(messageId, rating, user) const { data } = await client.messageFeedback(messageId, rating, user)
return NextResponse.json(data) 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 { NextResponse } from 'next/server'
import { client, getInfo, setSession } from '@/app/api/utils/common' 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 { NextResponse } from 'next/server'
import { client, getInfo, setSession } from '@/app/api/utils/common' 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 { ChatClient } from 'dify-client'
import { v4 } from 'uuid' 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}:` const userPrefix = `user_${APP_ID}:`
@ -15,6 +15,9 @@ export const getInfo = (request: NextRequest) => {
} }
export const setSession = (sessionId: string) => { 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}` } return { 'Set-Cookie': `session_id=${sessionId}` }
} }

View File

@ -3,26 +3,25 @@ import type { FC } from 'react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type IAppUnavailableProps = { interface IAppUnavailableProps {
isUnknwonReason: boolean isUnknownReason: boolean
errMessage?: string errMessage?: string
} }
const AppUnavailable: FC<IAppUnavailableProps> = ({ const AppUnavailable: FC<IAppUnavailableProps> = ({
isUnknwonReason, isUnknownReason,
errMessage, errMessage,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
let message = errMessage let message = errMessage
if (!errMessage) if (!errMessage) { message = (isUnknownReason ? t('app.common.appUnkonwError') : t('app.common.appUnavailable')) as string }
message = (isUnknwonReason ? t('app.common.appUnkonwError') : t('app.common.appUnavailable')) as string
return ( return (
<div className='flex items-center justify-center w-screen h-screen'> <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' <h1 className='mr-5 h-[50px] leading-[50px] pr-5 text-[24px] font-medium'
style={{ style={{
borderRight: '1px solid rgba(0,0,0,.3)', 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 className='text-sm'>{message}</div>
</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 classNames from 'classnames'
import style from './style.module.css' import style from './style.module.css'
export type AppIconProps = { export interface AppIconProps {
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large' size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large'
rounded?: boolean rounded?: boolean
icon?: string icon?: string

View File

@ -1,7 +1,7 @@
import { forwardRef, useEffect, useRef } from 'react' import { forwardRef, useEffect, useRef } from 'react'
import cn from 'classnames' import cn from 'classnames'
type IProps = { interface IProps {
placeholder?: string placeholder?: string
value: string value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
@ -36,19 +36,16 @@ const AutoHeightTextarea = forwardRef(
let hasFocus = false let hasFocus = false
const runId = setInterval(() => { const runId = setInterval(() => {
hasFocus = doFocus() hasFocus = doFocus()
if (hasFocus) if (hasFocus) { clearInterval(runId) }
clearInterval(runId)
}, 100) }, 100)
} }
} }
useEffect(() => { useEffect(() => {
if (autoFocus) if (autoFocus) { focus() }
focus()
}, []) }, [])
useEffect(() => { useEffect(() => {
if (controlFocus) if (controlFocus) { focus() }
focus()
}, [controlFocus]) }, [controlFocus])
return ( return (

View File

@ -2,7 +2,7 @@ import type { FC, MouseEventHandler } from 'react'
import React from 'react' import React from 'react'
import Spinner from '@/app/components/base/spinner' import Spinner from '@/app/components/base/spinner'
export type IButtonProps = { export interface IButtonProps {
type?: string type?: string
className?: string className?: string
disabled?: boolean disabled?: boolean
@ -21,6 +21,9 @@ const Button: FC<IButtonProps> = ({
}) => { }) => {
let style = 'cursor-pointer' let style = 'cursor-pointer'
switch (type) { 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': 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' 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 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 { generate } from './utils'
import type { AbstractNode } from './utils' import type { AbstractNode } from './utils'
export type IconData = { export interface IconData {
name: string name: string
icon: AbstractNode icon: AbstractNode
} }
export type IconBaseProps = { export interface IconBaseProps {
data: IconData data: IconData
className?: string className?: string
onClick?: React.MouseEventHandler<SVGElement> 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' import React from 'react'
export type AbstractNode = { export interface AbstractNode {
name: string name: string
attributes: { attributes: {
[key: string]: string [key: string]: string
@ -8,7 +8,7 @@ export type AbstractNode = {
children?: AbstractNode[] children?: AbstractNode[]
} }
export type Attrs = { export interface Attrs {
[key: string]: string [key: string]: string
} }

View File

@ -5,7 +5,7 @@ import cn from 'classnames'
import s from './style.module.css' import s from './style.module.css'
import ImagePreview from '@/app/components/base/image-uploader/image-preview' import ImagePreview from '@/app/components/base/image-uploader/image-preview'
type Props = { interface Props {
srcs: string[] srcs: string[]
} }
@ -32,12 +32,15 @@ const ImageGallery: FC<Props> = ({
}) => { }) => {
const [imagePreviewUrl, setImagePreviewUrl] = useState('') const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const imgNum = srcs.length const validSrcs = srcs.filter(src => src && src.trim() !== '')
const imgNum = validSrcs.length
const imgStyle = getWidthStyle(imgNum) const imgStyle = getWidthStyle(imgNum)
if (imgNum === 0) { return null }
return ( return (
<div className={cn(s[`img-${imgNum}`], 'flex flex-wrap')}> <div className={cn(s[`img-${imgNum}`], 'flex flex-wrap')}>
{/* TODO: support preview */} {validSrcs.map((src, index) => (
{srcs.map((src, index) => (
<img <img
key={index} key={index}
className={s.item} className={s.item}
@ -65,9 +68,9 @@ export const ImageGalleryTest = () => {
const imgGallerySrcs = (() => { const imgGallerySrcs = (() => {
const srcs = [] const srcs = []
for (let i = 0; i < 6; i++) for (let i = 0; i < 6; i++)
// srcs.push('https://placekitten.com/640/360') // srcs.push('https://placekitten.com/640/360')
// srcs.push('https://placekitten.com/360/640') // srcs.push('https://placekitten.com/360/640')
srcs.push('https://placekitten.com/360/360') { srcs.push('https://placekitten.com/360/360') }
return srcs return srcs
})() })()

View File

@ -13,7 +13,7 @@ import {
import Upload03 from '@/app/components/base/icons/line/upload-03' import Upload03 from '@/app/components/base/icons/line/upload-03'
import type { ImageFile, VisionSettings } from '@/types/app' import type { ImageFile, VisionSettings } from '@/types/app'
type UploadOnlyFromLocalProps = { interface UploadOnlyFromLocalProps {
onUpload: (imageFile: ImageFile) => void onUpload: (imageFile: ImageFile) => void
disabled?: boolean disabled?: boolean
limit?: number limit?: number
@ -39,7 +39,7 @@ const UploadOnlyFromLocal: FC<UploadOnlyFromLocalProps> = ({
) )
} }
type UploaderButtonProps = { interface UploaderButtonProps {
methods: VisionSettings['transfer_methods'] methods: VisionSettings['transfer_methods']
onUpload: (imageFile: ImageFile) => void onUpload: (imageFile: ImageFile) => void
disabled?: boolean disabled?: boolean
@ -62,8 +62,7 @@ const UploaderButton: FC<UploaderButtonProps> = ({
} }
const handleToggle = () => { const handleToggle = () => {
if (disabled) if (disabled) { return }
return
setOpen(v => !v) setOpen(v => !v)
} }
@ -115,7 +114,7 @@ const UploaderButton: FC<UploaderButtonProps> = ({
) )
} }
type ChatImageUploaderProps = { interface ChatImageUploaderProps {
settings: VisionSettings settings: VisionSettings
onUpload: (imageFile: ImageFile) => void onUpload: (imageFile: ImageFile) => void
disabled?: boolean disabled?: boolean

View File

@ -5,7 +5,7 @@ import Button from '@/app/components/base/button'
import type { ImageFile } from '@/types/app' import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
type ImageLinkInputProps = { interface ImageLinkInputProps {
onUpload: (imageFile: ImageFile) => void onUpload: (imageFile: ImageFile) => void
} }
const regex = /^(https?|ftp):\/\// const regex = /^(https?|ftp):\/\//

View File

@ -10,7 +10,7 @@ import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
import ImagePreview from '@/app/components/base/image-uploader/image-preview' import ImagePreview from '@/app/components/base/image-uploader/image-preview'
type ImageListProps = { interface ImageListProps {
list: ImageFile[] list: ImageFile[]
readonly?: boolean readonly?: boolean
onRemove?: (imageFileId: string) => void onRemove?: (imageFileId: string) => void
@ -31,12 +31,10 @@ const ImageList: FC<ImageListProps> = ({
const [imagePreviewUrl, setImagePreviewUrl] = useState('') const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const handleImageLinkLoadSuccess = (item: ImageFile) => { const handleImageLinkLoadSuccess = (item: ImageFile) => {
if (item.type === TransferMethod.remote_url && onImageLinkLoadSuccess && item.progress !== -1) if (item.type === TransferMethod.remote_url && onImageLinkLoadSuccess && item.progress !== -1) { onImageLinkLoadSuccess(item._id) }
onImageLinkLoadSuccess(item._id)
} }
const handleImageLinkLoadError = (item: ImageFile) => { const handleImageLinkLoadError = (item: ImageFile) => {
if (item.type === TransferMethod.remote_url && onImageLinkLoadError) if (item.type === TransferMethod.remote_url && onImageLinkLoadError) { onImageLinkLoadError(item._id) }
onImageLinkLoadError(item._id)
} }
return ( return (

View File

@ -2,7 +2,7 @@ import type { FC } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import XClose from '@/app/components/base/icons/line/x-close' import XClose from '@/app/components/base/icons/line/x-close'
type ImagePreviewProps = { interface ImagePreviewProps {
url: string url: string
onCancel: () => void onCancel: () => void
} }

View File

@ -8,7 +8,7 @@ import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
type UploaderProps = { interface UploaderProps {
children: (hovering: boolean) => JSX.Element children: (hovering: boolean) => JSX.Element
onUpload: (imageFile: ImageFile) => void onUpload: (imageFile: ImageFile) => void
limit?: number limit?: number
@ -28,8 +28,7 @@ const Uploader: FC<UploaderProps> = ({
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) if (!file) { return }
return
if (limit && file.size > limit * 1024 * 1024) { if (limit && file.size > limit * 1024 * 1024) {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: limit }) }) notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: limit }) })

View File

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

View File

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

View File

@ -17,7 +17,7 @@ import {
import type { OffsetOptions, Placement } from '@floating-ui/react' import type { OffsetOptions, Placement } from '@floating-ui/react'
type PortalToFollowElemOptions = { interface PortalToFollowElemOptions {
/* /*
* top, bottom, left, right * top, bottom, left, right
* start, end. Default is middle * start, end. Default is middle
@ -85,8 +85,7 @@ const PortalToFollowElemContext = React.createContext<ContextType>(null)
export function usePortalToFollowElemContext() { export function usePortalToFollowElemContext() {
const context = React.useContext(PortalToFollowElemContext) const context = React.useContext(PortalToFollowElemContext)
if (context == null) if (context == null) { throw new Error('PortalToFollowElem components must be wrapped in <PortalToFollowElem />') }
throw new Error('PortalToFollowElem components must be wrapped in <PortalToFollowElem />')
return context return context
} }
@ -106,7 +105,7 @@ export function PortalToFollowElem({
} }
export const PortalToFollowElemTrigger = React.forwardRef< export const PortalToFollowElemTrigger = React.forwardRef<
HTMLElement, HTMLElement,
React.HTMLProps<HTMLElement> & { asChild?: boolean } React.HTMLProps<HTMLElement> & { asChild?: boolean }
>(({ children, asChild = false, ...props }, propRef) => { >(({ children, asChild = false, ...props }, propRef) => {
const context = usePortalToFollowElemContext() const context = usePortalToFollowElemContext()
@ -141,14 +140,13 @@ React.HTMLProps<HTMLElement> & { asChild?: boolean }
PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger' PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
export const PortalToFollowElemContent = React.forwardRef< export const PortalToFollowElemContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLProps<HTMLDivElement> React.HTMLProps<HTMLDivElement>
>(({ style, ...props }, propRef) => { >(({ style, ...props }, propRef) => {
const context = usePortalToFollowElemContext() const context = usePortalToFollowElemContext()
const ref = useMergeRefs([context.refs.setFloating, propRef]) const ref = useMergeRefs([context.refs.setFloating, propRef])
if (!context.open) if (!context.open) { return null }
return null
return ( return (
<FloatingPortal> <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' }, { value: 7, name: 'option7' },
] ]
export type Item = { export interface Item {
value: number | string value: number | string
name: string name: string
} }
export type ISelectProps = { export interface ISelectProps {
className?: string className?: string
items?: Item[] items?: Item[]
defaultValue?: number | string defaultValue?: number | string
@ -45,8 +45,7 @@ const Select: FC<ISelectProps> = ({
useEffect(() => { useEffect(() => {
let defaultSelect = null let defaultSelect = null
const existed = items.find((item: Item) => item.value === defaultValue) const existed = items.find((item: Item) => item.value === defaultValue)
if (existed) if (existed) { defaultSelect = existed }
defaultSelect = existed
setSelectedItem(defaultSelect) setSelectedItem(defaultSelect)
}, [defaultValue]) }, [defaultValue])
@ -77,23 +76,20 @@ const Select: FC<ISelectProps> = ({
? <Combobox.Input ? <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`} 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) => { onChange={(event) => {
if (!disabled) if (!disabled) { setQuery(event.target.value) }
setQuery(event.target.value)
}} }}
displayValue={(item: Item) => item?.name} displayValue={(item: Item) => item?.name}
/> />
: <Combobox.Button onClick={ : <Combobox.Button onClick={
() => { () => {
if (!disabled) if (!disabled) { setOpen(!open) }
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`}> } 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} {selectedItem?.name}
</Combobox.Button>} </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={ <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) if (!disabled) { setOpen(!open) }
setOpen(!open)
} }
}> }>
{open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />} {open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
@ -147,8 +143,7 @@ const SimpleSelect: FC<ISelectProps> = ({
useEffect(() => { useEffect(() => {
let defaultSelect = null let defaultSelect = null
const existed = items.find((item: Item) => item.value === defaultValue) const existed = items.find((item: Item) => item.value === defaultValue)
if (existed) if (existed) { defaultSelect = existed }
defaultSelect = existed
setSelectedItem(defaultSelect) setSelectedItem(defaultSelect)
}, [defaultValue]) }, [defaultValue])

View File

@ -1,7 +1,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React from 'react'
type Props = { interface Props {
loading?: boolean loading?: boolean
className?: string className?: string
children?: React.ReactNode | 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' } from '@heroicons/react/20/solid'
import { createContext, useContext } from 'use-context-selector' import { createContext, useContext } from 'use-context-selector'
export type IToastProps = { export interface IToastProps {
type?: 'success' | 'error' | 'warning' | 'info' type?: 'success' | 'error' | 'warning' | 'info'
duration?: number duration?: number
message: string message: string
children?: ReactNode children?: ReactNode
onClose?: () => void onClose?: () => void
} }
type IToastContext = { interface IToastContext {
notify: (props: IToastProps) => void notify: (props: IToastProps) => void
} }
const defaultDuring = 3000 const defaultDuring = 3000
@ -33,8 +33,7 @@ const Toast = ({
children, children,
}: IToastProps) => { }: IToastProps) => {
// sometimes message is react node array. Not handle it. // sometimes message is react node array. Not handle it.
if (typeof message !== 'string') if (typeof message !== 'string') { return null }
return null
return <div className={classNames( return <div className={classNames(
'fixed rounded-md p-4 my-4 mx-8 z-50', '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} />) root.render(<Toast type={type} message={message} duration={duration} />)
document.body.appendChild(holder) document.body.appendChild(holder)
setTimeout(() => { setTimeout(() => {
if (holder) if (holder) { holder.remove() }
holder.remove()
}, duration || defaultDuring) }, duration || defaultDuring)
} }
} }

View File

@ -2,7 +2,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import React, { useState } from 'react' import React, { useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
export type TooltipProps = { export interface TooltipProps {
position?: 'top' | 'right' | 'bottom' | 'left' position?: 'top' | 'right' | 'bottom' | 'left'
triggerMethod?: 'hover' | 'click' triggerMethod?: 'hover' | 'click'
popupContent: React.ReactNode 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> <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', position = 'top',
triggerMethod = 'hover', triggerMethod = 'hover',
popupContent, popupContent,

View File

@ -1,11 +1,10 @@
'use client' 'use client'
import classNames from 'classnames' import classNames from 'classnames'
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React, { useState } from 'react'
import { Tooltip as ReactTooltip } from 'react-tooltip' // fixed version to 5.8.3 https://github.com/ReactTooltip/react-tooltip/issues/972 import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import 'react-tooltip/dist/react-tooltip.css'
type TooltipProps = { interface TooltipProps {
selector: string selector: string
content?: string content?: string
htmlContent?: React.ReactNode htmlContent?: React.ReactNode
@ -15,6 +14,10 @@ type TooltipProps = {
children: React.ReactNode 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> = ({ const Tooltip: FC<TooltipProps> = ({
selector, selector,
content, content,
@ -24,22 +27,31 @@ const Tooltip: FC<TooltipProps> = ({
className, className,
clickable, clickable,
}) => { }) => {
const [open, setOpen] = useState(false)
const triggerMethod = clickable ? 'click' : 'hover'
return ( return (
<div className='tooltip-container'> <PortalToFollowElem
{React.cloneElement(children as React.ReactElement, { open={open}
'data-tooltip-id': selector, onOpenChange={setOpen}
}) placement={position}
} offset={10}
<ReactTooltip >
id={selector} <PortalToFollowElemTrigger
content={content} data-selector={selector}
className={classNames('!bg-white !text-xs !font-normal !text-gray-700 !shadow-lg !opacity-100', className)} onClick={() => triggerMethod === 'click' && setOpen(v => !v)}
place={position} onMouseEnter={() => triggerMethod === 'hover' && setOpen(true)}
clickable={clickable} onMouseLeave={() => triggerMethod === 'hover' && setOpen(false)}
> >
{htmlContent && htmlContent} {children}
</ReactTooltip> </PortalToFollowElemTrigger>
</div> <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' 'use client'
import type { FC } from 'react' 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 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 { 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 Tooltip from '@/app/components/base/tooltip'
import WorkflowProcess from '@/app/components/workflow/workflow-process' import WorkflowProcess from '@/app/components/workflow/workflow-process'
import { Markdown } from '@/app/components/base/markdown' import { randomString } from '@/utils/string'
import type { Emoji } from '@/types/tools' 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 }) => ( function OperationBtn({ innerContent, onClick, className }: { innerContent: React.ReactNode, onClick?: () => void, className?: string }) {
<div return (
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 ?? ''}`} <div
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }} 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 ?? ''}`}
onClick={onClick && onClick} 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> {innerContent}
) </div>
)
}
const OpeningStatementIcon: FC<{ className?: string }> = ({ className }) => ( 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"> <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 }) => { 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 }) => { 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}> return (
<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 width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
</svg> <path d="M14 11.9998L13.3332 12.7292C12.9796 13.1159 12.5001 13.3332 12.0001 13.3332C11.5001 13.3332 11.0205 13.1159 10.6669 12.7292C10.3128 12.3432 9.83332 12.1265 9.33345 12.1265C8.83359 12.1265 8.35409 12.3432 7.99998 12.7292M2 13.3332H3.11636C3.44248 13.3332 3.60554 13.3332 3.75899 13.2963C3.89504 13.2637 4.0251 13.2098 4.1444 13.1367C4.27895 13.0542 4.39425 12.9389 4.62486 12.7083L13 4.33316C13.5523 3.78087 13.5523 2.88544 13 2.33316C12.4477 1.78087 11.5523 1.78087 11 2.33316L2.62484 10.7083C2.39424 10.9389 2.27894 11.0542 2.19648 11.1888C2.12338 11.3081 2.0695 11.4381 2.03684 11.5742C2 11.7276 2 11.8907 2 12.2168V13.3332Z" stroke="#6B7280" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
} }
export const EditIconSolid: FC<{ className?: string }> = ({ className }) => { 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}> return (
<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" /> <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="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" /> <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" />
</svg> <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 }) => { 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'}> return (
{children} <div className="rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100">
</div> {children}
</div>
)
} }
type IAnswerProps = { interface IAnswerProps {
item: ChatItem item: ChatItem
feedbackDisabled: boolean feedbackDisabled: boolean
onFeedback?: FeedbackFunc onFeedback?: FeedbackFunc
isResponsing?: boolean isResponding?: boolean
allToolIcons?: Record<string, string | Emoji> allToolIcons?: Record<string, string | Emoji>
suggestionClick?: (suggestion: string) => void
} }
// The component needs to maintain its own state to control whether to display input component // The component needs to maintain its own state to control whether to display input component
@ -67,24 +77,24 @@ const Answer: FC<IAnswerProps> = ({
item, item,
feedbackDisabled = false, feedbackDisabled = false,
onFeedback, onFeedback,
isResponsing, isResponding,
allToolIcons, 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 isAgentMode = !!agent_thoughts && agent_thoughts.length > 0
const { t } = useTranslation() const { t } = useTranslation()
/** /**
* Render feedback results (distinguish between users and administrators) * Render feedback results (distinguish between users and administrators)
* User reviews cannot be cancelled in Console * User reviews cannot be cancelled in Console
* @param rating feedback result * @param rating feedback result
* @param isUserFeedback Whether it is user's feedback * @param isUserFeedback Whether it is user's feedback
* @returns comp * @returns comp
*/ */
const renderFeedbackRating = (rating: MessageRating | undefined) => { const renderFeedbackRating = (rating: MessageRating | undefined) => {
if (!rating) if (!rating) { return null }
return null
const isLike = rating === 'like' 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' 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 ? '取消赞同' : '取消反对'} content={isLike ? '取消赞同' : '取消反对'}
> >
<div <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)' }} style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
onClick={async () => { onClick={async () => {
await onFeedback?.(id, { rating: null }) await onFeedback?.(id, { rating: null })
@ -117,14 +127,16 @@ const Answer: FC<IAnswerProps> = ({
const userOperation = () => { const userOperation = () => {
return feedback?.rating return feedback?.rating
? null ? null
: <div className='flex gap-1'> : (
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.like') as string}> <div className="flex gap-1">
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })} <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.like') as string}>
</Tooltip> {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.dislike') as string}> </Tooltip>
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })} <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.dislike') as string}>
</Tooltip> {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
</div> </Tooltip>
</div>
)
} }
return ( return (
@ -135,8 +147,7 @@ const Answer: FC<IAnswerProps> = ({
} }
const getImgs = (list?: VisionFile[]) => { const getImgs = (list?: VisionFile[]) => {
if (!list) if (!list) { return [] }
return []
return list.filter(file => file.type === 'image' && file.belongs_to === 'assistant') return list.filter(file => file.type === 'image' && file.belongs_to === 'assistant')
} }
@ -145,7 +156,7 @@ const Answer: FC<IAnswerProps> = ({
{agent_thoughts?.map((item, index) => ( {agent_thoughts?.map((item, index) => (
<div key={index}> <div key={index}>
{item.thought && ( {item.thought && (
<Markdown content={item.thought} /> <StreamdownMarkdown content={item.thought} />
)} )}
{/* {item.tool} */} {/* {item.tool} */}
{/* perhaps not use tool */} {/* perhaps not use tool */}
@ -153,7 +164,7 @@ const Answer: FC<IAnswerProps> = ({
<Thought <Thought
thought={item} thought={item}
allToolIcons={allToolIcons || {}} allToolIcons={allToolIcons || {}}
isFinished={!!item.observation || !isResponsing} isFinished={!!item.observation || !isResponding}
/> />
)} )}
@ -167,33 +178,45 @@ const Answer: FC<IAnswerProps> = ({
return ( return (
<div key={id}> <div key={id}>
<div className='flex items-start'> <div className="flex items-start">
<div className={`${s.answerIcon} w-10 h-10 shrink-0`}> <div className={`${s.answerIcon} w-10 h-10 shrink-0`}>
{isResponsing {isResponding
&& <div className={s.typeingIcon}> && (
<LoadingAnim type='avatar' /> <div className={s.typeingIcon}>
</div> <LoadingAnim type="avatar" />
} </div>
)}
</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={`${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]'}`}> <div className={`ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl ${workflowProcess && 'min-w-[480px]'}`}>
{workflowProcess && ( {workflowProcess && (
<WorkflowProcess data={workflowProcess} hideInfo /> <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'> <div className="flex items-center justify-center w-6 h-5">
<LoadingAnim type='text' /> <LoadingAnim type="text" />
</div> </div>
) )
: (isAgentMode : (isAgentMode
? agentModeAnswer ? 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>
<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()} {!feedbackDisabled && !item.feedbackDisabled && renderItemOperation()}
{/* User feedback must be displayed */} {/* User feedback must be displayed */}
{!feedbackDisabled && renderFeedbackRating(feedback?.rating)} {!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 ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
import ImageList from '@/app/components/base/image-uploader/image-list' import ImageList from '@/app/components/base/image-uploader/image-list'
import { useImageFiles } from '@/app/components/base/image-uploader/hooks' 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[] chatList: ChatItem[]
/** /**
* Whether to display the editing area and rating status * Whether to display the editing area and rating status
@ -30,9 +33,10 @@ export type IChatProps = {
checkCanSend?: () => boolean checkCanSend?: () => boolean
onSend?: (message: string, files: VisionFile[]) => void onSend?: (message: string, files: VisionFile[]) => void
useCurrentUserAvatar?: boolean useCurrentUserAvatar?: boolean
isResponsing?: boolean isResponding?: boolean
controlClearQuery?: number controlClearQuery?: number
visionConfig?: VisionSettings visionConfig?: VisionSettings
fileConfig?: FileUpload
} }
const Chat: FC<IChatProps> = ({ const Chat: FC<IChatProps> = ({
@ -43,18 +47,22 @@ const Chat: FC<IChatProps> = ({
checkCanSend, checkCanSend,
onSend = () => { }, onSend = () => { },
useCurrentUserAvatar, useCurrentUserAvatar,
isResponsing, isResponding,
controlClearQuery, controlClearQuery,
visionConfig, visionConfig,
fileConfig,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { notify } = Toast const { notify } = Toast
const isUseInputMethod = useRef(false) const isUseInputMethod = useRef(false)
const [query, setQuery] = React.useState('') const [query, setQuery] = React.useState('')
const queryRef = useRef('')
const handleContentChange = (e: any) => { const handleContentChange = (e: any) => {
const value = e.target.value const value = e.target.value
setQuery(value) setQuery(value)
queryRef.current = value
} }
const logError = (message: string) => { const logError = (message: string) => {
@ -62,16 +70,19 @@ const Chat: FC<IChatProps> = ({
} }
const valid = () => { const valid = () => {
const query = queryRef.current
if (!query || query.trim() === '') { if (!query || query.trim() === '') {
logError('Message cannot be empty') logError(t('app.errorMessage.valueOfVarRequired'))
return false return false
} }
return true return true
} }
useEffect(() => { useEffect(() => {
if (controlClearQuery) if (controlClearQuery) {
setQuery('') setQuery('')
queryRef.current = ''
}
}, [controlClearQuery]) }, [controlClearQuery])
const { const {
files, files,
@ -83,40 +94,53 @@ const Chat: FC<IChatProps> = ({
onClear, onClear,
} = useImageFiles() } = useImageFiles()
const [attachmentFiles, setAttachmentFiles] = React.useState<FileEntity[]>([])
const handleSend = () => { const handleSend = () => {
if (!valid() || (checkCanSend && !checkCanSend())) if (!valid() || (checkCanSend && !checkCanSend())) { return }
return const imageFiles: VisionFile[] = files.filter(file => file.progress !== -1).map(fileItem => ({
onSend(query, files.filter(file => file.progress !== -1).map(fileItem => ({
type: 'image', type: 'image',
transfer_method: fileItem.type, transfer_method: fileItem.type,
url: fileItem.url, url: fileItem.url,
upload_file_id: fileItem.fileId, 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.find(item => item.type === TransferMethod.local_file && !item.fileId)) {
if (files.length) if (files.length) { onClear() }
onClear() if (!isResponding) {
if (!isResponsing)
setQuery('') setQuery('')
queryRef.current = ''
}
} }
if (!attachmentFiles.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) { setAttachmentFiles([]) }
} }
const handleKeyUp = (e: any) => { const handleKeyUp = (e: any) => {
if (e.code === 'Enter') { if (e.code === 'Enter') {
e.preventDefault() e.preventDefault()
// prevent send message when using input method enter // prevent send message when using input method enter
if (!e.shiftKey && !isUseInputMethod.current) if (!e.shiftKey && !isUseInputMethod.current) { handleSend() }
handleSend()
} }
} }
const handleKeyDown = (e: any) => { const handleKeyDown = (e: any) => {
isUseInputMethod.current = e.nativeEvent.isComposing isUseInputMethod.current = e.nativeEvent.isComposing
if (e.code === 'Enter' && !e.shiftKey) { if (e.code === 'Enter' && !e.shiftKey) {
setQuery(query.replace(/\n$/, '')) const result = query.replace(/\n$/, '')
setQuery(result)
queryRef.current = result
e.preventDefault() e.preventDefault()
} }
} }
const suggestionClick = (suggestion: string) => {
setQuery(suggestion)
queryRef.current = suggestion
handleSend()
}
return ( return (
<div className={cn(!feedbackDisabled && 'px-3.5', 'h-full')}> <div className={cn(!feedbackDisabled && 'px-3.5', 'h-full')}>
{/* Chat List */} {/* Chat List */}
@ -129,7 +153,8 @@ const Chat: FC<IChatProps> = ({
item={item} item={item}
feedbackDisabled={feedbackDisabled} feedbackDisabled={feedbackDisabled}
onFeedback={onFeedback} onFeedback={onFeedback}
isResponsing={isResponsing && isLast} isResponding={isResponding && isLast}
suggestionClick={suggestionClick}
/> />
} }
return ( return (
@ -145,7 +170,7 @@ const Chat: FC<IChatProps> = ({
</div> </div>
{ {
!isHideSendInput && ( !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'> <div className='p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto'>
{ {
visionConfig?.enabled && ( 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 <Textarea
className={` 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'} ${visionConfig?.enabled && 'pl-12'}
`} `}
value={query} value={query}
@ -181,8 +217,8 @@ const Chat: FC<IChatProps> = ({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoSize autoSize
/> />
<div className="absolute bottom-2 right-2 flex items-center h-8"> <div className="absolute bottom-2 right-6 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={`${s.count} mr-3 h-5 leading-5 text-sm bg-gray-50 text-gray-500 px-2 rounded`}>{query.trim().length}</div>
<Tooltip <Tooltip
selector='send-tip' selector='send-tip'
htmlContent={ htmlContent={

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import React from 'react' import React from 'react'
import s from './style.module.css' import s from './style.module.css'
export type ILoaidingAnimProps = { export interface ILoaidingAnimProps {
type: 'text' | 'avatar' type: 'text' | 'avatar'
} }

View File

@ -4,7 +4,7 @@ import React from 'react'
import type { IChatItem } from '../type' import type { IChatItem } from '../type'
import s from '../style.module.css' 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' import ImageGallery from '@/app/components/base/image-gallery'
type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'useCurrentUserAvatar'> & { type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'useCurrentUserAvatar'> & {
@ -23,7 +23,7 @@ const Question: FC<IQuestionProps> = ({ id, content, useCurrentUserAvatar, imgSr
{imgSrcs && imgSrcs.length > 0 && ( {imgSrcs && imgSrcs.length > 0 && (
<ImageGallery srcs={imgSrcs} /> <ImageGallery srcs={imgSrcs} />
)} )}
<Markdown content={content} /> <StreamdownMarkdown content={content} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ import type { ThoughtItem, ToolInfoInThought } from '../type'
import Tool from './tool' import Tool from './tool'
import type { Emoji } from '@/types/tools' import type { Emoji } from '@/types/tools'
export type IThoughtProps = { export interface IThoughtProps {
thought: ThoughtItem thought: ThoughtItem
allToolIcons: Record<string, string | Emoji> allToolIcons: Record<string, string | Emoji>
isFinished: boolean isFinished: boolean
@ -29,8 +29,7 @@ const Thought: FC<IThoughtProps> = ({
}) => { }) => {
const [toolNames, isValueArray]: [string[], boolean] = (() => { const [toolNames, isValueArray]: [string[], boolean] = (() => {
try { try {
if (Array.isArray(JSON.parse(thought.tool))) if (Array.isArray(JSON.parse(thought.tool))) { return [JSON.parse(thought.tool), true] }
return [JSON.parse(thought.tool), true]
} }
catch (e) { catch (e) {
} }

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type Props = { interface Props {
isRequest: boolean isRequest: boolean
toolName: string toolName: string
content: 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 type { Emoji } from '@/types/tools'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
type Props = { interface Props {
payload: ToolInfoInThought payload: ToolInfoInThought
allToolIcons?: Record<string, string | Emoji> allToolIcons?: Record<string, string | Emoji>
} }
const getIcon = (toolName: string, allToolIcons: Record<string, string | Emoji>) => { const getIcon = (toolName: string, allToolIcons: Record<string, string | Emoji>) => {
if (toolName.startsWith('dataset-')) if (toolName.startsWith('dataset-')) { return <DataSetIcon className='shrink-0'></DataSetIcon> }
return <DataSetIcon className='shrink-0'></DataSetIcon>
const icon = allToolIcons[toolName] const icon = allToolIcons[toolName]
if (!icon) if (!icon) { return null }
return null
return ( return (
typeof icon === 'string' typeof icon === 'string'
? ( ? (
@ -87,12 +85,14 @@ const Tool: FC<Props> = ({
<Panel <Panel
isRequest={true} isRequest={true}
toolName={toolName} toolName={toolName}
content={input} /> content={input}
/>
{output && ( {output && (
<Panel <Panel
isRequest={false} isRequest={false}
toolName={toolName} toolName={toolName}
content={output as string} /> content={output as string}
/>
)} )}
</div> </div>
)} )}

View File

@ -1,6 +1,6 @@
import type { VisionFile } from '@/types/app' import type { VisionFile } from '@/types/app'
export type LogAnnotation = { export interface LogAnnotation {
content: string content: string
account: { account: {
id: string id: string
@ -10,7 +10,7 @@ export type LogAnnotation = {
created_at: number created_at: number
} }
export type Annotation = { export interface Annotation {
id: string id: string
authorName: string authorName: string
logAnnotation?: LogAnnotation logAnnotation?: LogAnnotation
@ -20,13 +20,13 @@ export type Annotation = {
export const MessageRatings = ['like', 'dislike', null] as const export const MessageRatings = ['like', 'dislike', null] as const
export type MessageRating = typeof MessageRatings[number] export type MessageRating = typeof MessageRatings[number]
export type MessageMore = { export interface MessageMore {
time: string time: string
tokens: number tokens: number
latency: number | string latency: number | string
} }
export type Feedbacktype = { export interface Feedbacktype {
rating: MessageRating rating: MessageRating
content?: string | null content?: string | null
} }
@ -36,14 +36,14 @@ export type SubmitAnnotationFunc = (messageId: string, content: string) => Promi
export type DisplayScene = 'web' | 'console' export type DisplayScene = 'web' | 'console'
export type ToolInfoInThought = { export interface ToolInfoInThought {
name: string name: string
input: string input: string
output: string output: string
isFinished: boolean isFinished: boolean
} }
export type ThoughtItem = { export interface ThoughtItem {
id: string id: string
tool: string // plugin or dataset. May has multi. tool: string // plugin or dataset. May has multi.
thought: string thought: string
@ -55,7 +55,7 @@ export type ThoughtItem = {
message_files?: VisionFile[] message_files?: VisionFile[]
} }
export type CitationItem = { export interface CitationItem {
content: string content: string
data_source_type: string data_source_type: string
dataset_name: string dataset_name: string
@ -70,7 +70,7 @@ export type CitationItem = {
word_count: number word_count: number
} }
export type IChatItem = { export interface IChatItem {
id: string id: string
content: string content: string
citation?: CitationItem[] citation?: CitationItem[]
@ -98,12 +98,12 @@ export type IChatItem = {
useCurrentUserAvatar?: boolean useCurrentUserAvatar?: boolean
isOpeningStatement?: boolean isOpeningStatement?: boolean
suggestedQuestions?: string[] suggestedQuestions?: string[]
log?: { role: string; text: string }[] log?: { role: string, text: string }[]
agent_thoughts?: ThoughtItem[] agent_thoughts?: ThoughtItem[]
message_files?: VisionFile[] message_files?: VisionFile[]
} }
export type MessageEnd = { export interface MessageEnd {
id: string id: string
metadata: { metadata: {
retriever_resources?: CitationItem[] retriever_resources?: CitationItem[]
@ -117,14 +117,14 @@ export type MessageEnd = {
} }
} }
export type MessageReplace = { export interface MessageReplace {
id: string id: string
task_id: string task_id: string
answer: string answer: string
conversation_id: string conversation_id: string
} }
export type AnnotationReply = { export interface AnnotationReply {
id: string id: string
task_id: string task_id: string
answer: string answer: string

View File

@ -5,7 +5,7 @@ import {
PencilSquareIcon, PencilSquareIcon,
} from '@heroicons/react/24/solid' } from '@heroicons/react/24/solid'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
export type IHeaderProps = { export interface IHeaderProps {
title: string title: string
isMobile?: boolean isMobile?: boolean
onShowSideBar?: () => void onShowSideBar?: () => void
@ -35,9 +35,7 @@ const Header: FC<IHeaderProps> = ({
</div> </div>
{isMobile {isMobile
? ( ? (
<div className='flex items-center justify-center h-8 w-8 cursor-pointer' <div className='flex items-center justify-center h-8 w-8 cursor-pointer' onClick={() => onCreateNewChat?.()} >
onClick={() => onCreateNewChat?.()}
>
<PencilSquareIcon className="h-4 w-4 text-gray-500" /> <PencilSquareIcon className="h-4 w-4 text-gray-500" />
</div>) </div>)
: <div></div>} : <div></div>}

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import React, { useEffect, useRef, useState } 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 Header from '@/app/components/header'
import { fetchAppParams, fetchChatList, fetchConversations, generationConversationName, sendChatMessage, updateFeedback } from '@/service' import { fetchAppParams, fetchChatList, fetchConversations, generationConversationName, sendChatMessage, updateFeedback } from '@/service'
import type { ChatItem, ConversationItem, Feedbacktype, PromptConfig, VisionFile, VisionSettings } from '@/types/app' 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 { Resolution, TransferMethod, WorkflowRunningStatus } from '@/types/app'
import Chat from '@/app/components/chat' import Chat from '@/app/components/chat'
import { setLocaleOnClient } from '@/i18n/client' 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 type { Annotation as AnnotationType } from '@/types/log'
import { addFileInfos, sortAgentSorts } from '@/utils/tools' import { addFileInfos, sortAgentSorts } from '@/utils/tools'
const Main: FC = () => { export interface IMainProps {
params: any
}
const Main: FC<IMainProps> = () => {
const { t } = useTranslation() const { t } = useTranslation()
const media = useBreakpoints() const media = useBreakpoints()
const isMobile = media === MediaType.mobile const isMobile = media === MediaType.mobile
@ -33,7 +37,7 @@ const Main: FC = () => {
* app info * app info
*/ */
const [appUnavailable, setAppUnavailable] = useState<boolean>(false) 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 [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
const [inited, setInited] = useState<boolean>(false) const [inited, setInited] = useState<boolean>(false)
// in mobile, show sidebar by click button // in mobile, show sidebar by click button
@ -44,10 +48,10 @@ const Main: FC = () => {
detail: Resolution.low, detail: Resolution.low,
transfer_methods: [TransferMethod.local_file], transfer_methods: [TransferMethod.local_file],
}) })
const [fileConfig, setFileConfig] = useState<FileUpload | undefined>()
useEffect(() => { useEffect(() => {
if (APP_INFO?.title) if (APP_INFO?.title) { document.title = `${APP_INFO.title} - Powered by Dify` }
document.title = `${APP_INFO.title} - Powered by Dify`
}, [APP_INFO?.title]) }, [APP_INFO?.title])
// onData change thought (the produce obj). https://github.com/immerjs/immer/issues/576 // onData change thought (the produce obj). https://github.com/immerjs/immer/issues/576
@ -86,21 +90,20 @@ const Main: FC = () => {
setCurrInputs(inputs) setCurrInputs(inputs)
setChatStarted() setChatStarted()
// parse variables in introduction // parse variables in introduction
setChatList(generateNewChatListWithOpenstatement('', inputs)) setChatList(generateNewChatListWithOpenStatement('', inputs))
} }
const hasSetInputs = (() => { const hasSetInputs = (() => {
if (!isNewConversation) if (!isNewConversation) { return true }
return true
return isChatStarted return isChatStarted
})() })()
const conversationName = currConversationInfo?.name || t('app.chat.newChatDefaultName') as string const conversationName = currConversationInfo?.name || t('app.chat.newChatDefaultName') as string
const conversationIntroduction = currConversationInfo?.introduction || '' const conversationIntroduction = currConversationInfo?.introduction || ''
const suggestedQuestions = currConversationInfo?.suggested_questions || []
const handleConversationSwitch = () => { const handleConversationSwitch = () => {
if (!inited) if (!inited) { return }
return
// update inputs of current conversation // update inputs of current conversation
let notSyncToStateIntroduction = '' let notSyncToStateIntroduction = ''
@ -113,6 +116,7 @@ const Main: FC = () => {
setExistConversationInfo({ setExistConversationInfo({
name: item?.name || '', name: item?.name || '',
introduction: notSyncToStateIntroduction, introduction: notSyncToStateIntroduction,
suggested_questions: suggestedQuestions,
}) })
} }
else { else {
@ -121,10 +125,10 @@ const Main: FC = () => {
} }
// update chat list of current conversation // update chat list of current conversation
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) { if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponding) {
fetchChatList(currConversationId).then((res: any) => { fetchChatList(currConversationId).then((res: any) => {
const { data } = res const { data } = res
const newChatList: ChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs) const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs)
data.forEach((item: any) => { data.forEach((item: any) => {
newChatList.push({ newChatList.push({
@ -147,8 +151,7 @@ const Main: FC = () => {
}) })
} }
if (isNewConversation && isChatStarted) if (isNewConversation && isChatStarted) { setChatList(generateNewChatListWithOpenStatement()) }
setChatList(generateNewChatListWithOpenstatement())
} }
useEffect(handleConversationSwitch, [currConversationId, inited]) useEffect(handleConversationSwitch, [currConversationId, inited])
@ -171,16 +174,21 @@ const Main: FC = () => {
const [chatList, setChatList, getChatList] = useGetState<ChatItem[]>([]) const [chatList, setChatList, getChatList] = useGetState<ChatItem[]>([])
const chatListDomRef = useRef<HTMLDivElement>(null) const chatListDomRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
// scroll to bottom // scroll to bottom with page-level scrolling
if (chatListDomRef.current) if (chatListDomRef.current) {
chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight setTimeout(() => {
chatListDomRef.current?.scrollIntoView({
behavior: 'auto',
block: 'end',
})
}, 50)
}
}, [chatList, currConversationId]) }, [chatList, currConversationId])
// user can not edit inputs if user had send message // 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 = () => { const createNewChat = () => {
// if new chat is already exist, do not create new chat // if new chat is already exist, do not create new chat
if (conversationList.some(item => item.id === '-1')) if (conversationList.some(item => item.id === '-1')) { return }
return
setConversationList(produce(conversationList, (draft) => { setConversationList(produce(conversationList, (draft) => {
draft.unshift({ draft.unshift({
@ -188,26 +196,26 @@ const Main: FC = () => {
name: t('app.chat.newChatDefaultName'), name: t('app.chat.newChatDefaultName'),
inputs: newConversationInputs, inputs: newConversationInputs,
introduction: conversationIntroduction, introduction: conversationIntroduction,
suggested_questions: suggestedQuestions,
}) })
})) }))
} }
// sometime introduction is not applied to state // sometime introduction is not applied to state
const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => { const generateNewChatListWithOpenStatement = (introduction?: string, inputs?: Record<string, any> | null) => {
let caculatedIntroduction = introduction || conversationIntroduction || '' let calculatedIntroduction = introduction || conversationIntroduction || ''
const caculatedPromptVariables = inputs || currInputs || null const calculatedPromptVariables = inputs || currInputs || null
if (caculatedIntroduction && caculatedPromptVariables) if (calculatedIntroduction && calculatedPromptVariables) { calculatedIntroduction = replaceVarWithValues(calculatedIntroduction, promptConfig?.prompt_variables || [], calculatedPromptVariables) }
caculatedIntroduction = replaceVarWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
const openstatement = { const openStatement = {
id: `${Date.now()}`, id: `${Date.now()}`,
content: caculatedIntroduction, content: calculatedIntroduction,
isAnswer: true, isAnswer: true,
feedbackDisabled: true, feedbackDisabled: true,
isOpeningStatement: isShowPrompt, isOpeningStatement: isShowPrompt,
suggestedQuestions,
} }
if (caculatedIntroduction) if (calculatedIntroduction) { return [openStatement] }
return [openstatement]
return [] return []
} }
@ -221,32 +229,54 @@ const Main: FC = () => {
(async () => { (async () => {
try { try {
const [conversationData, appParams] = await Promise.all([fetchConversations(), fetchAppParams()]) const [conversationData, appParams] = await Promise.all([fetchConversations(), fetchAppParams()])
// handle current conversation id // 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 _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 // 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) setLocaleOnClient(APP_INFO.default_language, true)
setNewConversationInfo({ setNewConversationInfo({
name: t('app.chat.newChatDefaultName'), name: t('app.chat.newChatDefaultName'),
introduction, introduction,
suggested_questions,
}) })
if (isNotNewConversation) {
setExistConversationInfo({
name: currentConversation.name || t('app.chat.newChatDefaultName'),
introduction,
suggested_questions,
})
}
const prompt_variables = userInputsFormToPromptVariables(user_input_form) const prompt_variables = userInputsFormToPromptVariables(user_input_form)
setPromptConfig({ setPromptConfig({
prompt_template: promptTemplate, prompt_template: promptTemplate,
prompt_variables, prompt_variables,
} as PromptConfig) } as PromptConfig)
const outerFileUploadEnabled = !!file_upload?.enabled
setVisionConfig({ setVisionConfig({
...file_upload?.image, ...file_upload?.image,
enabled: !!(outerFileUploadEnabled && file_upload?.image?.enabled),
image_file_size_limit: system_parameters?.system_parameters || 0, 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[]) setConversationList(conversations as ConversationItem[])
if (isNotNewConversation) if (isNotNewConversation) { setCurrConversationId(_conversationId, APP_ID, false) }
setCurrConversationId(_conversationId, APP_ID, false)
setInited(true) setInited(true)
} }
@ -255,14 +285,14 @@ const Main: FC = () => {
setAppUnavailable(true) setAppUnavailable(true)
} }
else { else {
setIsUnknwonReason(true) setIsUnknownReason(true)
setAppUnavailable(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 [abortController, setAbortController] = useState<AbortController | null>(null)
const { notify } = Toast const { notify } = Toast
const logError = (message: string) => { const logError = (message: string) => {
@ -270,17 +300,15 @@ const Main: FC = () => {
} }
const checkCanSend = () => { const checkCanSend = () => {
if (currConversationId !== '-1') if (currConversationId !== '-1') { return true }
return true
if (!currInputs || !promptConfig?.prompt_variables) if (!currInputs || !promptConfig?.prompt_variables) { return true }
return true
const inputLens = Object.values(currInputs).length const inputLens = Object.values(currInputs).length
const promptVariablesLens = promptConfig.prompt_variables.length const promptVariablesLens = promptConfig.prompt_variables.length
const emytyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v) const emptyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v)
if (emytyInput) { if (emptyInput) {
logError(t('app.errorMessage.valueOfVarRequired')) logError(t('app.errorMessage.valueOfVarRequired'))
return false return false
} }
@ -291,7 +319,7 @@ const Main: FC = () => {
const [openingSuggestedQuestions, setOpeningSuggestedQuestions] = useState<string[]>([]) const [openingSuggestedQuestions, setOpeningSuggestedQuestions] = useState<string[]>([])
const [messageTaskId, setMessageTaskId] = useState('') const [messageTaskId, setMessageTaskId] = useState('')
const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false) const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false)
const [isResponsingConIsCurrCon, setIsResponsingConCurrCon, getIsResponsingConIsCurrCon] = useGetState(true) const [isRespondingConIsCurrCon, setIsRespondingConCurrCon, getIsRespondingConIsCurrCon] = useGetState(true)
const [userQuery, setUserQuery] = useState('') const [userQuery, setUserQuery] = useState('')
const updateCurrentQA = ({ const updateCurrentQA = ({
@ -309,26 +337,47 @@ const Main: FC = () => {
const newListWithAnswer = produce( const newListWithAnswer = produce(
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId), getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
(draft) => { (draft) => {
if (!draft.find(item => item.id === questionId)) if (!draft.find(item => item.id === questionId)) { draft.push({ ...questionItem }) }
draft.push({ ...questionItem })
draft.push({ ...responseItem }) draft.push({ ...responseItem })
}) },
)
setChatList(newListWithAnswer) 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[]) => { const handleSend = async (message: string, files?: VisionFile[]) => {
if (isResponsing) { if (isResponding) {
notify({ type: 'info', message: t('app.errorMessage.waitForResponse') }) notify({ type: 'info', message: t('app.errorMessage.waitForResponse') })
return 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> = { const data: Record<string, any> = {
inputs: currInputs, inputs: toServerInputs,
query: message, query: message,
conversation_id: isNewConversation ? null : currConversationId, conversation_id: isNewConversation ? null : currConversationId,
} }
if (visionConfig?.enabled && files && files?.length > 0) { if (files && files?.length > 0) {
data.files = files.map((item) => { data.files = files.map((item) => {
if (item.transfer_method === TransferMethod.local_file) { if (item.transfer_method === TransferMethod.local_file) {
return { return {
@ -340,13 +389,13 @@ const Main: FC = () => {
}) })
} }
// qustion // question
const questionId = `question-${Date.now()}` const questionId = `question-${Date.now()}`
const questionItem = { const questionItem = {
id: questionId, id: questionId,
content: message, content: message,
isAnswer: false, isAnswer: false,
message_files: files, message_files: (files || []).filter((f: any) => f.type === 'image'),
} }
const placeholderAnswerId = `answer-placeholder-${Date.now()}` const placeholderAnswerId = `answer-placeholder-${Date.now()}`
@ -374,7 +423,7 @@ const Main: FC = () => {
const prevTempNewConversationId = getCurrConversationId() || '-1' const prevTempNewConversationId = getCurrConversationId() || '-1'
let tempNewConversationId = '' let tempNewConversationId = ''
setResponsingTrue() setRespondingTrue()
sendChatMessage(data, { sendChatMessage(data, {
getAbortController: (abortController) => { getAbortController: (abortController) => {
setAbortController(abortController) setAbortController(abortController)
@ -385,21 +434,19 @@ const Main: FC = () => {
} }
else { else {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1] const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
if (lastThought) if (lastThought) { lastThought.thought = lastThought.thought + message } // need immer setAutoFreeze
lastThought.thought = lastThought.thought + message // need immer setAutoFreeze
} }
if (messageId && !hasSetResponseId) { if (messageId && !hasSetResponseId) {
responseItem.id = messageId responseItem.id = messageId
hasSetResponseId = true hasSetResponseId = true
} }
if (isFirstMessage && newConversationId) if (isFirstMessage && newConversationId) { tempNewConversationId = newConversationId }
tempNewConversationId = newConversationId
setMessageTaskId(taskId) setMessageTaskId(taskId)
// has switched to other conversation // has switched to other conversation
if (prevTempNewConversationId !== getCurrConversationId()) { if (prevTempNewConversationId !== getCurrConversationId()) {
setIsResponsingConCurrCon(false) setIsRespondingConCurrCon(false)
return return
} }
updateCurrentQA({ updateCurrentQA({
@ -410,8 +457,7 @@ const Main: FC = () => {
}) })
}, },
async onCompleted(hasError?: boolean) { async onCompleted(hasError?: boolean) {
if (hasError) if (hasError) { return }
return
if (getConversationIdChangeBecauseOfNew()) { if (getConversationIdChangeBecauseOfNew()) {
const { data: allConversations }: any = await fetchConversations() const { data: allConversations }: any = await fetchConversations()
@ -426,12 +472,11 @@ const Main: FC = () => {
resetNewConversationInputs() resetNewConversationInputs()
setChatNotStarted() setChatNotStarted()
setCurrConversationId(tempNewConversationId, APP_ID, true) setCurrConversationId(tempNewConversationId, APP_ID, true)
setResponsingFalse() setRespondingFalse()
}, },
onFile(file) { onFile(file) {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1] const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
if (lastThought) if (lastThought) { lastThought.message_files = [...(lastThought as any).message_files, { ...file }] }
lastThought.message_files = [...(lastThought as any).message_files, { ...file }]
updateCurrentQA({ updateCurrentQA({
responseItem, responseItem,
@ -465,7 +510,7 @@ const Main: FC = () => {
} }
// has switched to other conversation // has switched to other conversation
if (prevTempNewConversationId !== getCurrConversationId()) { if (prevTempNewConversationId !== getCurrConversationId()) {
setIsResponsingConCurrCon(false) setIsRespondingConCurrCon(false)
return false return false
} }
@ -486,13 +531,13 @@ const Main: FC = () => {
const newListWithAnswer = produce( const newListWithAnswer = produce(
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId), getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
(draft) => { (draft) => {
if (!draft.find(item => item.id === questionId)) if (!draft.find(item => item.id === questionId)) { draft.push({ ...questionItem }) }
draft.push({ ...questionItem })
draft.push({ draft.push({
...responseItem, ...responseItem,
}) })
}) },
)
setChatList(newListWithAnswer) setChatList(newListWithAnswer)
return return
} }
@ -501,11 +546,11 @@ const Main: FC = () => {
const newListWithAnswer = produce( const newListWithAnswer = produce(
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId), getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
(draft) => { (draft) => {
if (!draft.find(item => item.id === questionId)) if (!draft.find(item => item.id === questionId)) { draft.push({ ...questionItem }) }
draft.push({ ...questionItem })
draft.push({ ...responseItem }) draft.push({ ...responseItem })
}) },
)
setChatList(newListWithAnswer) setChatList(newListWithAnswer)
}, },
onMessageReplace: (messageReplace) => { onMessageReplace: (messageReplace) => {
@ -514,13 +559,12 @@ const Main: FC = () => {
(draft) => { (draft) => {
const current = draft.find(item => item.id === messageReplace.id) const current = draft.find(item => item.id === messageReplace.id)
if (current) if (current) { current.content = messageReplace.answer }
current.content = messageReplace.answer
}, },
)) ))
}, },
onError() { onError() {
setResponsingFalse() setRespondingFalse()
// role back placeholder answer // role back placeholder answer
setChatList(produce(getChatList(), (draft) => { setChatList(produce(getChatList(), (draft) => {
draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1) draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
@ -591,8 +635,7 @@ const Main: FC = () => {
} }
const renderSidebar = () => { const renderSidebar = () => {
if (!APP_ID || !APP_INFO || !promptConfig) if (!APP_ID || !APP_INFO || !promptConfig) { return null }
return null
return ( return (
<Sidebar <Sidebar
list={conversationList} list={conversationList}
@ -603,11 +646,9 @@ const Main: FC = () => {
) )
} }
if (appUnavailable) if (appUnavailable) { return <AppUnavailable isUnknownReason={isUnknownReason} errMessage={!hasSetAppConfig ? 'Please set APP_ID and API_KEY in config/index.tsx' : ''} /> }
return <AppUnavailable isUnknwonReason={isUnknwonReason} errMessage={!hasSetAppConfig ? 'Please set APP_ID and API_KEY in config/index.tsx' : ''} />
if (!APP_ID || !APP_INFO || !promptConfig) if (!APP_ID || !APP_INFO || !promptConfig) { return <Loading type='app' /> }
return <Loading type='app' />
return ( return (
<div className='bg-gray-100'> <div className='bg-gray-100'>
@ -621,10 +662,7 @@ const Main: FC = () => {
{/* sidebar */} {/* sidebar */}
{!isMobile && renderSidebar()} {!isMobile && renderSidebar()}
{isMobile && isShowSidebar && ( {isMobile && isShowSidebar && (
<div className='fixed inset-0 z-50' <div className='fixed inset-0 z-50' style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }} onClick={hideSidebar} >
style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
onClick={hideSidebar}
>
<div className='inline-block' onClick={e => e.stopPropagation()}> <div className='inline-block' onClick={e => e.stopPropagation()}>
{renderSidebar()} {renderSidebar()}
</div> </div>
@ -639,24 +677,23 @@ const Main: FC = () => {
siteInfo={APP_INFO} siteInfo={APP_INFO}
promptConfig={promptConfig} promptConfig={promptConfig}
onStartChat={handleStartChat} onStartChat={handleStartChat}
canEidtInpus={canEditInpus} canEditInputs={canEditInputs}
savedInputs={currInputs as Record<string, any>} savedInputs={currInputs as Record<string, any>}
onInputsChange={setCurrInputs} onInputsChange={setCurrInputs}
></ConfigSence> ></ConfigSence>
{ {
hasSetInputs && ( 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='relative grow pc:w-[794px] max-w-full mobile:w-full pb-[180px] mx-auto mb-3.5' ref={chatListDomRef}>
<div className='h-full overflow-y-auto' ref={chatListDomRef}> <Chat
<Chat chatList={chatList}
chatList={chatList} onSend={handleSend}
onSend={handleSend} onFeedback={handleFeedback}
onFeedback={handleFeedback} isResponding={isResponding}
isResponsing={isResponsing} checkCanSend={checkCanSend}
checkCanSend={checkCanSend} visionConfig={visionConfig}
visionConfig={visionConfig} fileConfig={fileConfig}
/> />
</div>
</div>) </div>)
} }
</div> </div>

View File

@ -2,7 +2,7 @@ import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import s from './card.module.css' import s from './card.module.css'
type PropType = { interface PropType {
children: React.ReactNode children: React.ReactNode
text?: string text?: string
} }

View File

@ -16,7 +16,7 @@ function classNames(...classes: any[]) {
const MAX_CONVERSATION_LENTH = 20 const MAX_CONVERSATION_LENTH = 20
export type ISidebarProps = { export interface ISidebarProps {
copyRight: string copyRight: string
currentId: string currentId: string
onCurrentIdChange: (id: string) => void onCurrentIdChange: (id: string) => void
@ -38,7 +38,8 @@ const Sidebar: FC<ISidebarProps> = ({
<div className="flex flex-shrink-0 p-4 !pb-0"> <div className="flex flex-shrink-0 p-4 !pb-0">
<Button <Button
onClick={() => { onCurrentIdChange('-1') }} 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')} <PencilSquareIcon className="mr-2 h-4 w-4" /> {t('app.chat.newChat')}
</Button> </Button>
</div> </div>

View File

@ -7,7 +7,7 @@ import s from './style.module.css'
import { StarIcon } from '@/app/components//welcome/massive-component' import { StarIcon } from '@/app/components//welcome/massive-component'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
export type ITemplateVarPanelProps = { export interface ITemplateVarPanelProps {
className?: string className?: string
header: ReactNode header: ReactNode
children?: ReactNode | null 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, title,
className, 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, className,
onConfirm, onConfirm,
onCancel, onCancel,

View File

@ -3,6 +3,7 @@ import type { FC } from 'react'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import TemplateVarPanel, { PanelTitle, VarOpBtnGroup } from '../value-panel' import TemplateVarPanel, { PanelTitle, VarOpBtnGroup } from '../value-panel'
import FileUploaderInAttachmentWrapper from '../base/file-uploader-in-attachment'
import s from './style.module.css' import s from './style.module.css'
import { AppInfoComp, ChatBtn, EditBtn, FootLogo, PromptTemplate } from './massive-component' import { AppInfoComp, ChatBtn, EditBtn, FootLogo, PromptTemplate } from './massive-component'
import type { AppInfo, PromptConfig } from '@/types/app' 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 // regex to match the {{}} and replace it with a span
const regex = /\{\{([^}]+)\}\}/g const regex = /\{\{([^}]+)\}\}/g
export type IWelcomeProps = { export interface IWelcomeProps {
conversationName: string conversationName: string
hasSetInputs: boolean hasSetInputs: boolean
isPublicVersion: boolean isPublicVersion: boolean
siteInfo: AppInfo siteInfo: AppInfo
promptConfig: PromptConfig promptConfig: PromptConfig
onStartChat: (inputs: Record<string, any>) => void onStartChat: (inputs: Record<string, any>) => void
canEidtInpus: boolean canEditInputs: boolean
savedInputs: Record<string, any> savedInputs: Record<string, any>
onInputsChange: (inputs: Record<string, any>) => void onInputsChange: (inputs: Record<string, any>) => void
} }
@ -32,7 +33,7 @@ const Welcome: FC<IWelcomeProps> = ({
siteInfo, siteInfo,
promptConfig, promptConfig,
onStartChat, onStartChat,
canEidtInpus, canEditInputs,
savedInputs, savedInputs,
onInputsChange, onInputsChange,
}) => { }) => {
@ -40,8 +41,7 @@ const Welcome: FC<IWelcomeProps> = ({
const hasVar = promptConfig.prompt_variables.length > 0 const hasVar = promptConfig.prompt_variables.length > 0
const [isFold, setIsFold] = useState<boolean>(true) const [isFold, setIsFold] = useState<boolean>(true)
const [inputs, setInputs] = useState<Record<string, any>>((() => { const [inputs, setInputs] = useState<Record<string, any>>((() => {
if (hasSetInputs) if (hasSetInputs) { return savedInputs }
return savedInputs
const res: Record<string, any> = {} const res: Record<string, any> = {}
if (promptConfig) { if (promptConfig) {
@ -67,8 +67,7 @@ const Welcome: FC<IWelcomeProps> = ({
}, [savedInputs]) }, [savedInputs])
const highLightPromoptTemplate = (() => { const highLightPromoptTemplate = (() => {
if (!promptConfig) if (!promptConfig) { return '' }
return ''
const res = promptConfig.prompt_template.replace(regex, (match, p1) => { const res = promptConfig.prompt_template.replace(regex, (match, p1) => {
return `<span class='text-gray-800 font-bold'>${inputs?.[p1] ? inputs?.[p1] : match}</span>` return `<span class='text-gray-800 font-bold'>${inputs?.[p1] ? inputs?.[p1] : match}</span>`
}) })
@ -107,7 +106,7 @@ const Welcome: FC<IWelcomeProps> = ({
)} )}
{item.type === 'string' && ( {item.type === 'string' && (
<input <input
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`} placeholder={`${item.name}${!item.required ? `(${t('app.variableTable.optional')})` : ''}`}
value={inputs?.[item.key] || ''} value={inputs?.[item.key] || ''}
onChange={(e) => { setInputs({ ...inputs, [item.key]: e.target.value }) }} onChange={(e) => { setInputs({ ...inputs, [item.key]: e.target.value }) }}
className={'w-full flex-grow py-2 pl-3 pr-3 box-border rounded-lg bg-gray-50'} className={'w-full flex-grow py-2 pl-3 pr-3 box-border rounded-lg bg-gray-50'}
@ -117,11 +116,55 @@ const Welcome: FC<IWelcomeProps> = ({
{item.type === 'paragraph' && ( {item.type === 'paragraph' && (
<textarea <textarea
className="w-full h-[104px] flex-grow py-2 pl-3 pr-3 box-border rounded-lg bg-gray-50" className="w-full h-[104px] flex-grow py-2 pl-3 pr-3 box-border rounded-lg bg-gray-50"
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`} placeholder={`${item.name}${!item.required ? `(${t('app.variableTable.optional')})` : ''}`}
value={inputs?.[item.key] || ''} value={inputs?.[item.key] || ''}
onChange={(e) => { setInputs({ ...inputs, [item.key]: e.target.value }) }} 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>
))} ))}
</div> </div>
@ -131,8 +174,11 @@ const Welcome: FC<IWelcomeProps> = ({
const canChat = () => { const canChat = () => {
const inputLens = Object.values(inputs).length const inputLens = Object.values(inputs).length
const promptVariablesLens = promptConfig.prompt_variables.length const promptVariablesLens = promptConfig.prompt_variables.length
const emytyInput = inputLens < promptVariablesLens || Object.values(inputs).filter(v => v === '').length > 0 const emptyInput = inputLens < promptVariablesLens || Object.entries(inputs).filter(([k, v]) => {
if (emytyInput) { const isRequired = promptConfig.prompt_variables.find(item => item.key === k)?.required ?? true
return isRequired && v === ''
}).length > 0
if (emptyInput) {
logError(t('app.errorMessage.valueOfVarRequired')) logError(t('app.errorMessage.valueOfVarRequired'))
return false return false
} }
@ -140,8 +186,7 @@ const Welcome: FC<IWelcomeProps> = ({
} }
const handleChat = () => { const handleChat = () => {
if (!canChat()) if (!canChat()) { return }
return
onStartChat(inputs) onStartChat(inputs)
} }
@ -202,8 +247,7 @@ const Welcome: FC<IWelcomeProps> = ({
return ( return (
<VarOpBtnGroup <VarOpBtnGroup
onConfirm={() => { onConfirm={() => {
if (!canChat()) if (!canChat()) { return }
return
onInputsChange(inputs) onInputsChange(inputs)
setIsFold(true) setIsFold(true)
@ -217,7 +261,7 @@ const Welcome: FC<IWelcomeProps> = ({
} }
const renderHasSetInputsPublic = () => { const renderHasSetInputsPublic = () => {
if (!canEidtInpus) { if (!canEditInputs) {
return ( return (
<TemplateVarPanel <TemplateVarPanel
isFold={false} isFold={false}
@ -260,8 +304,7 @@ const Welcome: FC<IWelcomeProps> = ({
} }
const renderHasSetInputsPrivate = () => { const renderHasSetInputsPrivate = () => {
if (!canEidtInpus || !hasVar) if (!canEditInputs || !hasVar) { return null }
return null
return ( return (
<TemplateVarPanel <TemplateVarPanel
@ -284,8 +327,7 @@ const Welcome: FC<IWelcomeProps> = ({
} }
const renderHasSetInputs = () => { const renderHasSetInputs = () => {
if ((!isPublicVersion && !canEidtInpus) || !hasVar) if ((!isPublicVersion && !canEditInputs) || !hasVar) { return null }
return null
return ( return (
<div <div
@ -326,7 +368,8 @@ const Welcome: FC<IWelcomeProps> = ({
<a <a
className='text-gray-500' className='text-gray-500'
href={siteInfo.privacy_policy} href={siteInfo.privacy_policy}
target='_blank'>{t('app.chat.privacyPolicyMiddle')}</a> target='_blank'
>{t('app.chat.privacyPolicyMiddle')}</a>
{t('app.chat.privacyPolicyRight')} {t('app.chat.privacyPolicyRight')}
</div> </div>
: <div> : <div>

View File

@ -37,7 +37,7 @@ export const StarIcon = () => (
</svg> </svg>
) )
export const ChatBtn: FC<{ onClick: () => void; className?: string }> = ({ export const ChatBtn: FC<{ onClick: () => void, className?: string }> = ({
className, className,
onClick, onClick,
}) => { }) => {
@ -46,7 +46,8 @@ export const ChatBtn: FC<{ onClick: () => void; className?: string }> = ({
<Button <Button
type='primary' type='primary'
className={cn(className, `space-x-2 flex items-center ${s.customBtn}`)} 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"> <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" /> <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> </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() const { t } = useTranslation()
return ( return (

View File

@ -16,11 +16,11 @@ import {
} from '@/app/components/base/icons/workflow' } from '@/app/components/base/icons/workflow'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
type BlockIconProps = { interface BlockIconProps {
type: BlockEnum type: BlockEnum
size?: string size?: string
className?: string className?: string
toolIcon?: string | { content: string; background: string } toolIcon?: string | { content: string, background: string }
} }
const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = { const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = {
xs: 'w-4 h-4 rounded-[5px] shadow-xs', 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 // load file from local instead of cdn https://github.com/suren-atoyan/monaco-react/issues/482
loader.config({ paths: { vs: '/vs' } }) loader.config({ paths: { vs: '/vs' } })
type Props = { interface Props {
value?: string | object value?: string | object
onChange?: (value: string) => void onChange?: (value: string) => void
title: JSX.Element title: JSX.Element
@ -72,8 +72,7 @@ const CodeEditor: FC<Props> = ({
} }
const outPutValue = (() => { const outPutValue = (() => {
if (!isJSONStringifyBeauty) if (!isJSONStringifyBeauty) { return value as string }
return value as string
try { try {
return JSON.stringify(value as object, null, 2) 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 useToggleExpend from './use-toggle-expend'
import { Clipboard, ClipboardCheck } from '@/app/components/base/icons/line/files' import { Clipboard, ClipboardCheck } from '@/app/components/base/icons/line/files'
type Props = { interface Props {
className?: string className?: string
title: JSX.Element | string title: JSX.Element | string
headerRight?: JSX.Element headerRight?: JSX.Element

View File

@ -4,7 +4,7 @@ import type { FC } from 'react'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import cn from 'classnames' import cn from 'classnames'
type Props = { interface Props {
className?: string className?: string
height: number height: number
minHeight: number minHeight: number
@ -40,14 +40,12 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
}, [prevUserSelectStyle]) }, [prevUserSelectStyle])
const { run: didHandleResize } = useDebounceFn((e) => { const { run: didHandleResize } = useDebounceFn((e) => {
if (!isResizing) if (!isResizing) { return }
return
const offset = e.clientY - clientY const offset = e.clientY - clientY
let newHeight = height + offset let newHeight = height + offset
setClientY(e.clientY) setClientY(e.clientY)
if (newHeight < minHeight) if (newHeight < minHeight) { newHeight = minHeight }
newHeight = minHeight
onHeightChange(newHeight) onHeightChange(newHeight)
}, { }, {
wait: 0, wait: 0,
@ -85,7 +83,8 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({
{!hideResize && ( {!hideResize && (
<div <div
className='absolute bottom-0 left-0 w-full flex justify-center h-2 cursor-row-resize' 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 className='w-5 h-[3px] rounded-sm bg-gray-300'></div>
</div> </div>
)} )}

View File

@ -4,7 +4,7 @@ import React, { useCallback } from 'react'
import Expand04 from '@/app/components/base/icons/solid/expand-04' import Expand04 from '@/app/components/base/icons/solid/expand-04'
import Collapse04 from '@/app/components/base/icons/line/arrows/collapse-04' import Collapse04 from '@/app/components/base/icons/line/arrows/collapse-04'
type Props = { interface Props {
isExpand: boolean isExpand: boolean
onExpandChange: (isExpand: boolean) => void onExpandChange: (isExpand: boolean) => void
} }

View File

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

View File

@ -3,16 +3,13 @@ import type { FC } from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import cn from 'classnames' import cn from 'classnames'
import BlockIcon from './block-icon' 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 AlertCircle from '@/app/components/base/icons/line/alert-circle'
import AlertTriangle from '@/app/components/base/icons/line/alert-triangle' import AlertTriangle from '@/app/components/base/icons/line/alert-triangle'
import Loading02 from '@/app/components/base/icons/line/loading-02' import Loading02 from '@/app/components/base/icons/line/loading-02'
import CheckCircle from '@/app/components/base/icons/line/check-circle' 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' import type { NodeTracing } from '@/types/app'
type Props = { interface Props {
nodeInfo: NodeTracing nodeInfo: NodeTracing
hideInfo?: boolean hideInfo?: boolean
} }
@ -21,20 +18,15 @@ const NodePanel: FC<Props> = ({ nodeInfo, hideInfo = false }) => {
const [collapseState, setCollapseState] = useState<boolean>(true) const [collapseState, setCollapseState] = useState<boolean>(true)
const getTime = (time: number) => { const getTime = (time: number) => {
if (time < 1) if (time < 1) { return `${(time * 1000).toFixed(3)} ms` }
return `${(time * 1000).toFixed(3)} ms` if (time > 60) { return `${parseInt(Math.round(time / 60).toString())} m ${(time % 60).toFixed(3)} s` }
if (time > 60)
return `${parseInt(Math.round(time / 60).toString())} m ${(time % 60).toFixed(3)} s`
return `${time.toFixed(3)} s` return `${time.toFixed(3)} s`
} }
const getTokenCount = (tokens: number) => { const getTokenCount = (tokens: number) => {
if (tokens < 1000) if (tokens < 1000) { return tokens }
return tokens if (tokens >= 1000 && tokens < 1000000) { return `${parseFloat((tokens / 1000).toFixed(3))}K` }
if (tokens >= 1000 && tokens < 1000000) if (tokens >= 1000000) { return `${parseFloat((tokens / 1000000).toFixed(3))}M` }
return `${parseFloat((tokens / 1000).toFixed(3))}K`
if (tokens >= 1000000)
return `${parseFloat((tokens / 1000000).toFixed(3))}M`
} }
useEffect(() => { useEffect(() => {
@ -52,12 +44,6 @@ const NodePanel: FC<Props> = ({ nodeInfo, hideInfo = false }) => {
)} )}
onClick={() => setCollapseState(!collapseState)} 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} /> <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( <div className={cn(
'grow text-gray-700 text-[13px] leading-[16px] font-semibold truncate', 'grow text-gray-700 text-[13px] leading-[16px] font-semibold truncate',
@ -82,48 +68,6 @@ const NodePanel: FC<Props> = ({ nodeInfo, hideInfo = false }) => {
</div> </div>
)} )}
</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>
</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 ChevronRight from '@/app/components/base/icons/line/chevron-right'
import { WorkflowRunningStatus } from '@/types/app' import { WorkflowRunningStatus } from '@/types/app'
type WorkflowProcessProps = { interface WorkflowProcessProps {
data: WorkflowProcess data: WorkflowProcess
grayBg?: boolean grayBg?: boolean
expand?: boolean expand?: boolean
@ -30,14 +30,11 @@ const WorkflowProcessItem = ({
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
const background = useMemo(() => { const background = useMemo(() => {
if (running && !collapse) if (running && !collapse) { return 'linear-gradient(180deg, #E1E4EA 0%, #EAECF0 100%)' }
return 'linear-gradient(180deg, #E1E4EA 0%, #EAECF0 100%)'
if (succeeded && !collapse) if (succeeded && !collapse) { return 'linear-gradient(180deg, #ECFDF3 0%, #F6FEF9 100%)' }
return 'linear-gradient(180deg, #ECFDF3 0%, #F6FEF9 100%)'
if (failed && !collapse) if (failed && !collapse) { return 'linear-gradient(180deg, #FEE4E2 0%, #FEF3F2 100%)' }
return 'linear-gradient(180deg, #FEE4E2 0%, #FEF3F2 100%)'
}, [running, succeeded, failed, collapse]) }, [running, succeeded, failed, collapse])
useEffect(() => { useEffect(() => {

View File

@ -3,12 +3,12 @@ import { getLocaleOnServer } from '@/i18n/server'
import './styles/globals.css' import './styles/globals.css'
import './styles/markdown.scss' import './styles/markdown.scss'
const LocaleLayout = ({ const LocaleLayout = async ({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const locale = getLocaleOnServer() const locale = await getLocaleOnServer()
return ( return (
<html lang={locale ?? 'en'} className="h-full"> <html lang={locale ?? 'en'} className="h-full">
<body className="h-full"> <body className="h-full">

View File

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

View File

@ -7,7 +7,8 @@ export const APP_INFO: AppInfo = {
description: '', description: '',
copyright: '', copyright: '',
privacy_policy: '', 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 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 useBreakpoints = () => {
const [width, setWidth] = React.useState(globalThis.innerWidth) const [width, setWidth] = React.useState(globalThis.innerWidth)
const media = (() => { const media = (() => {
if (width <= 640) if (width <= 640) { return MediaType.mobile }
return MediaType.mobile if (width <= 768) { return MediaType.tablet }
if (width <= 768)
return MediaType.tablet
return MediaType.pc return MediaType.pc
})() })()

View File

@ -30,8 +30,7 @@ function useConversation() {
// input can be updated by user // input can be updated by user
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any> | null>(null) const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any> | null>(null)
const resetNewConversationInputs = () => { const resetNewConversationInputs = () => {
if (!newConversationInputs) if (!newConversationInputs) { return }
return
setNewConversationInputs(produce(newConversationInputs, (draft) => { setNewConversationInputs(produce(newConversationInputs, (draft) => {
Object.keys(draft).forEach((key) => { Object.keys(draft).forEach((key) => {
draft[key] = '' draft[key] = ''

View File

@ -12,6 +12,5 @@ export const getLocaleOnClient = (): Locale => {
export const setLocaleOnClient = (locale: Locale, notReload?: boolean) => { export const setLocaleOnClient = (locale: Locale, notReload?: boolean) => {
Cookies.set(LOCALE_COOKIE_NAME, locale) Cookies.set(LOCALE_COOKIE_NAME, locale)
changeLanguage(locale) changeLanguage(locale)
if (!notReload) if (!notReload) { location.reload() }
location.reload()
} }

View File

@ -2,11 +2,23 @@
import i18n from 'i18next' import i18n from 'i18next'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
import commonEn from './lang/common.en' import commonEn from './lang/common.en'
import commonEs from './lang/common.es'
import commonZh from './lang/common.zh' 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 appEn from './lang/app.en'
import appEs from './lang/app.es'
import appZh from './lang/app.zh' 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 toolsEn from './lang/tools.en'
import toolsZh from './lang/tools.zh' 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 '.' import type { Locale } from '.'
const resources = { const resources = {
@ -18,6 +30,12 @@ const resources = {
tools: toolsEn, tools: toolsEn,
}, },
}, },
'es': {
translation: {
common: commonEs,
app: appEs,
},
},
'zh-Hans': { 'zh-Hans': {
translation: { translation: {
common: commonZh, common: commonZh,
@ -26,6 +44,30 @@ const resources = {
tools: toolsZh, 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) i18n.use(initReactI18next)

View File

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

View File

@ -28,6 +28,9 @@ const translation = {
waitForResponse: waitForResponse:
'Please wait for the response to the previous message to complete.', 'Please wait for the response to the previous message to complete.',
}, },
variableTable: {
optional: 'Optional',
},
} }
export default translation export default translation

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

@ -23,6 +23,9 @@ const translation = {
valueOfVarRequired: '变量值必填', valueOfVarRequired: '变量值必填',
waitForResponse: '请等待上条信息响应完成', waitForResponse: '请等待上条信息响应完成',
}, },
variableTable: {
optional: '可选',
},
} }
export default translation export default translation

View File

@ -28,6 +28,16 @@ const translation = {
pasteImageLinkInvalid: 'Invalid image link', pasteImageLinkInvalid: 'Invalid image link',
imageUpload: 'Image Upload', 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 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

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