Compare commits

..

45 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 10:55:10 +08:00
2b1882a5e3 chore: update eslint 2025-09-15 10:55:03 +08:00
d3909927af Merge pull request #179 from Eric-Guo/main
Fix file upload (not image) in chat-flow.
2025-09-10 15:51:04 +08:00
17007b2014 Allow file upload in chat. 2025-09-08 16:15:46 +08:00
e91a1f6194 @tailwindcss/line-clamp include by default. 2025-09-08 14:56:12 +08:00
74a656fda2 Initial GPT-5-high generated cursor rules 2025-09-08 14:51:22 +08:00
8f02afed98 Bump some outdated package version 2025-09-08 14:39:47 +08:00
dc1659463e feat(docker): Docker build with multi-stage
This will significantly reduce the size of the final image used for deployment.
2025-09-04 15:11:13 +08:00
b35f0effe5 Merge pull request #169 from LeeeeeeM/fix/add-pre-answer
fix: suggestion is not rendered
2025-06-03 15:25:06 +08:00
60a33804cc fix: suggestion is not rendered 2025-05-30 09:59:27 +08:00
7495bf44a2 fix: fix option check
fix: fix option check
2025-05-27 18:26:23 +08:00
d009b00012 fix: fix option check 2025-05-27 11:33:48 +08:00
1249ea88c9 Merge pull request #157 from langgenius/fix/file-input-caused-page-crash
feat:  support file and filelist input form
2025-04-15 18:51:08 +08:00
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
103 changed files with 10962 additions and 594 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

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

@ -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,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 { 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,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 IAppUnavailableProps = { interface IAppUnavailableProps {
isUnknownReason: boolean isUnknownReason: boolean
errMessage?: string errMessage?: string
} }
@ -14,8 +14,7 @@ const AppUnavailable: FC<IAppUnavailableProps> = ({
}) => { }) => {
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 = (isUnknownReason ? 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'>

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

@ -17,7 +17,7 @@ import {
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
type FileFromLinkOrLocalProps = { interface FileFromLinkOrLocalProps {
showFromLink?: boolean showFromLink?: boolean
showFromLocal?: boolean showFromLocal?: boolean
trigger: (open: boolean) => React.ReactNode trigger: (open: boolean) => React.ReactNode
@ -38,8 +38,7 @@ const FileFromLinkOrLocal = ({
const disabled = !!fileConfig.number_limits && files.length >= fileConfig.number_limits const disabled = !!fileConfig.number_limits && files.length >= fileConfig.number_limits
const handleSaveUrl = () => { const handleSaveUrl = () => {
if (!url) if (!url) { return }
return
if (!FILE_URL_REGEX.test(url)) { if (!FILE_URL_REGEX.test(url)) {
setShowError(true) setShowError(true)

View File

@ -1,6 +1,6 @@
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
type FileImageRenderProps = { interface FileImageRenderProps {
imageUrl: string imageUrl: string
className?: string className?: string
alt?: string alt?: string

View File

@ -4,7 +4,7 @@ import type { FileUpload } from './types'
import { FILE_EXTS } from './constants' import { FILE_EXTS } from './constants'
import { SupportUploadFileTypes } from './types' import { SupportUploadFileTypes } from './types'
type FileInputProps = { interface FileInputProps {
fileConfig: FileUpload fileConfig: FileUpload
} }
const FileInput = ({ const FileInput = ({
@ -18,8 +18,7 @@ const FileInput = ({
if (targetFiles) { if (targetFiles) {
if (fileConfig.number_limits) { if (fileConfig.number_limits) {
for (let i = 0; i < targetFiles.length; i++) { for (let i = 0; i < targetFiles.length; i++) {
if (i + 1 + files.length <= fileConfig.number_limits) if (i + 1 + files.length <= fileConfig.number_limits) { handleLocalFileUpload(targetFiles[i]) }
handleLocalFileUpload(targetFiles[i])
} }
} }
else { else {

View File

@ -24,7 +24,7 @@ import cn from '@/utils/classnames'
import ReplayLine from '@/app/components/base/icons/other/ReplayLine' import ReplayLine from '@/app/components/base/icons/other/ReplayLine'
import ImagePreview from '@/app/components/base/image-uploader/image-preview' import ImagePreview from '@/app/components/base/image-uploader/image-preview'
type FileInAttachmentItemProps = { interface FileInAttachmentItemProps {
file: FileEntity file: FileEntity
showDeleteAction?: boolean showDeleteAction?: boolean
showDownloadAction?: boolean showDownloadAction?: boolean

View File

@ -67,7 +67,7 @@ const FILE_TYPE_ICON_MAP = {
color: 'text-[#00B2EA]', color: 'text-[#00B2EA]',
}, },
} }
type FileTypeIconProps = { interface FileTypeIconProps {
type: FileAppearanceType type: FileAppearanceType
size?: 'sm' | 'lg' | 'md' size?: 'sm' | 'lg' | 'md'
className?: string className?: string

View File

@ -148,8 +148,7 @@ export const useFile = (fileConfig: FileUpload) => {
const newFiles = produce(files, (draft) => { const newFiles = produce(files, (draft) => {
const index = draft.findIndex(file => file.id === newFile.id) const index = draft.findIndex(file => file.id === newFile.id)
if (index > -1) if (index > -1) { draft[index] = newFile }
draft[index] = newFile
}) })
setFiles(newFiles) setFiles(newFiles)
}, [fileStore]) }, [fileStore])
@ -198,10 +197,8 @@ export const useFile = (fileConfig: FileUpload) => {
const files = fileStore.getState().files const files = fileStore.getState().files
const file = files.find(file => file.id === fileId) const file = files.find(file => file.id === fileId)
if (file && file.progress < 80 && file.progress >= 0) if (file && file.progress < 80 && file.progress >= 0) { handleUpdateFile({ ...file, progress: file.progress + 20 }) }
handleUpdateFile({ ...file, progress: file.progress + 20 }) else { clearTimeout(timer) }
else
clearTimeout(timer)
}, 200) }, 200)
}, [fileStore, handleUpdateFile]) }, [fileStore, handleUpdateFile])
const handleLoadFileFromLink = useCallback((url: string) => { const handleLoadFileFromLink = useCallback((url: string) => {
@ -235,10 +232,8 @@ export const useFile = (fileConfig: FileUpload) => {
notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') }) notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') })
handleRemoveFile(uploadingFile.id) handleRemoveFile(uploadingFile.id)
} }
if (!checkSizeLimit(newFile.supportFileType, newFile.size)) if (!checkSizeLimit(newFile.supportFileType, newFile.size)) { handleRemoveFile(uploadingFile.id) }
handleRemoveFile(uploadingFile.id) else { handleUpdateFile(newFile) }
else
handleUpdateFile(newFile)
}).catch(() => { }).catch(() => {
notify({ type: 'error', message: t('common.fileUploader.pasteFileLinkInvalid') }) notify({ type: 'error', message: t('common.fileUploader.pasteFileLinkInvalid') })
handleRemoveFile(uploadingFile.id) handleRemoveFile(uploadingFile.id)
@ -263,8 +258,7 @@ export const useFile = (fileConfig: FileUpload) => {
} }
const allowedFileTypes = fileConfig.allowed_file_types const allowedFileTypes = fileConfig.allowed_file_types
const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)) const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom))
if (!checkSizeLimit(fileType, file.size)) if (!checkSizeLimit(fileType, file.size)) { return }
return
const reader = new FileReader() const reader = new FileReader()
const isImage = file.type.startsWith('image') const isImage = file.type.startsWith('image')
@ -344,8 +338,7 @@ export const useFile = (fileConfig: FileUpload) => {
const file = e.dataTransfer.files[0] const file = e.dataTransfer.files[0]
if (file) if (file) { handleLocalFileUpload(file) }
handleLocalFileUpload(file)
}, [handleLocalFileUpload]) }, [handleLocalFileUpload])
return { return {

View File

@ -19,12 +19,12 @@ import Button from '@/app/components/base/button'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
type Option = { interface Option {
value: string value: string
label: string label: string
icon: JSX.Element icon: JSX.Element
} }
type FileUploaderInAttachmentProps = { interface FileUploaderInAttachmentProps {
fileConfig: FileUpload fileConfig: FileUpload
} }
const FileUploaderInAttachment = ({ const FileUploaderInAttachment = ({
@ -71,8 +71,7 @@ const FileUploaderInAttachment = ({
return (open: boolean) => renderButton(option, open) return (open: boolean) => renderButton(option, open)
}, [renderButton]) }, [renderButton])
const renderOption = useCallback((option: Option) => { const renderOption = useCallback((option: Option) => {
if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file)) if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file)) { return renderButton(option) }
return renderButton(option)
if (option.value === TransferMethod.remote_url && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)) { if (option.value === TransferMethod.remote_url && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)) {
return ( return (
@ -109,7 +108,7 @@ const FileUploaderInAttachment = ({
) )
} }
type FileUploaderInAttachmentWrapperProps = { interface FileUploaderInAttachmentWrapperProps {
value?: FileEntity[] value?: FileEntity[]
onChange: (files: FileEntity[]) => void onChange: (files: FileEntity[]) => void
fileConfig: FileUpload fileConfig: FileUpload

View File

@ -11,7 +11,7 @@ import type {
FileEntity, FileEntity,
} from './types' } from './types'
type Shape = { interface Shape {
files: FileEntity[] files: FileEntity[]
setFiles: (files: FileEntity[]) => void setFiles: (files: FileEntity[]) => void
} }
@ -34,8 +34,7 @@ export const FileContext = createContext<FileStore | null>(null)
export function useStore<T>(selector: (state: Shape) => T): T { export function useStore<T>(selector: (state: Shape) => T): T {
const store = useContext(FileContext) const store = useContext(FileContext)
if (!store) if (!store) { throw new Error('Missing FileContext.Provider in the tree') }
throw new Error('Missing FileContext.Provider in the tree')
return useZustandStore(store, selector) return useZustandStore(store, selector)
} }
@ -44,7 +43,7 @@ export const useFileStore = () => {
return useContext(FileContext)! return useContext(FileContext)!
} }
type FileProviderProps = { interface FileProviderProps {
children: React.ReactNode children: React.ReactNode
value?: FileEntity[] value?: FileEntity[]
onChange?: (files: FileEntity[]) => void onChange?: (files: FileEntity[]) => void
@ -56,8 +55,7 @@ export const FileContextProvider = ({
}: FileProviderProps) => { }: FileProviderProps) => {
const storeRef = useRef<FileStore | undefined>(undefined) const storeRef = useRef<FileStore | undefined>(undefined)
if (!storeRef.current) if (!storeRef.current) { storeRef.current = createFileStore(value, onChange) }
storeRef.current = createFileStore(value, onChange)
return ( return (
<FileContext.Provider value={storeRef.current}> <FileContext.Provider value={storeRef.current}>

View File

@ -17,7 +17,7 @@ export enum FileAppearanceTypeEnum {
export type FileAppearanceType = keyof typeof FileAppearanceTypeEnum export type FileAppearanceType = keyof typeof FileAppearanceTypeEnum
export type FileEntity = { export interface FileEntity {
id: string id: string
name: string name: string
size: number size: number
@ -32,7 +32,7 @@ export type FileEntity = {
isRemote?: boolean isRemote?: boolean
} }
export type EnabledOrDisabled = { export interface EnabledOrDisabled {
enabled?: boolean enabled?: boolean
} }
@ -41,7 +41,7 @@ export enum Resolution {
high = 'high', high = 'high',
} }
export type FileUploadConfigResponse = { export interface FileUploadConfigResponse {
batch_count_limit: number batch_count_limit: number
image_file_size_limit?: number | string // default is 10MB image_file_size_limit?: number | string // default is 10MB
file_size_limit: number // default is 15MB file_size_limit: number // default is 15MB
@ -71,7 +71,7 @@ export enum SupportUploadFileTypes {
custom = 'custom', custom = 'custom',
} }
export type FileResponse = { export interface FileResponse {
related_id: string related_id: string
extension: string extension: string
filename: string filename: string

View File

@ -5,7 +5,7 @@ import { FILE_EXTS } from './constants'
import { upload } from '@/service/base' import { upload } from '@/service/base'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
type FileUploadParams = { interface FileUploadParams {
file: File file: File
onProgressCallback: (progress: number) => void onProgressCallback: (progress: number) => void
onSuccessCallback: (res: { id: string }) => void onSuccessCallback: (res: { id: string }) => void
@ -42,21 +42,17 @@ export const fileUpload: FileUpload = ({
export const getFileExtension = (fileName: string, fileMimetype: string, isRemote?: boolean) => { export const getFileExtension = (fileName: string, fileMimetype: string, isRemote?: boolean) => {
let extension = '' let extension = ''
if (fileMimetype) if (fileMimetype) { extension = mime.getExtension(fileMimetype) || '' }
extension = mime.getExtension(fileMimetype) || ''
if (fileName && !extension) { if (fileName && !extension) {
const fileNamePair = fileName.split('.') const fileNamePair = fileName.split('.')
const fileNamePairLength = fileNamePair.length const fileNamePairLength = fileNamePair.length
if (fileNamePairLength > 1) if (fileNamePairLength > 1) { extension = fileNamePair[fileNamePairLength - 1] }
extension = fileNamePair[fileNamePairLength - 1] else { extension = '' }
else
extension = ''
} }
if (isRemote) if (isRemote) { extension = '' }
extension = ''
return extension return extension
} }
@ -64,50 +60,37 @@ export const getFileExtension = (fileName: string, fileMimetype: string, isRemot
export const getFileAppearanceType = (fileName: string, fileMimetype: string) => { export const getFileAppearanceType = (fileName: string, fileMimetype: string) => {
const extension = getFileExtension(fileName, fileMimetype) const extension = getFileExtension(fileName, fileMimetype)
if (extension === 'gif') if (extension === 'gif') { return FileAppearanceTypeEnum.gif }
return FileAppearanceTypeEnum.gif
if (FILE_EXTS.image.includes(extension.toUpperCase())) if (FILE_EXTS.image.includes(extension.toUpperCase())) { return FileAppearanceTypeEnum.image }
return FileAppearanceTypeEnum.image
if (FILE_EXTS.video.includes(extension.toUpperCase())) if (FILE_EXTS.video.includes(extension.toUpperCase())) { return FileAppearanceTypeEnum.video }
return FileAppearanceTypeEnum.video
if (FILE_EXTS.audio.includes(extension.toUpperCase())) if (FILE_EXTS.audio.includes(extension.toUpperCase())) { return FileAppearanceTypeEnum.audio }
return FileAppearanceTypeEnum.audio
if (extension === 'html') if (extension === 'html') { return FileAppearanceTypeEnum.code }
return FileAppearanceTypeEnum.code
if (extension === 'pdf') if (extension === 'pdf') { return FileAppearanceTypeEnum.pdf }
return FileAppearanceTypeEnum.pdf
if (extension === 'md' || extension === 'markdown' || extension === 'mdx') if (extension === 'md' || extension === 'markdown' || extension === 'mdx') { return FileAppearanceTypeEnum.markdown }
return FileAppearanceTypeEnum.markdown
if (extension === 'xlsx' || extension === 'xls') if (extension === 'xlsx' || extension === 'xls') { return FileAppearanceTypeEnum.excel }
return FileAppearanceTypeEnum.excel
if (extension === 'docx' || extension === 'doc') if (extension === 'docx' || extension === 'doc') { return FileAppearanceTypeEnum.word }
return FileAppearanceTypeEnum.word
if (extension === 'pptx' || extension === 'ppt') if (extension === 'pptx' || extension === 'ppt') { return FileAppearanceTypeEnum.ppt }
return FileAppearanceTypeEnum.ppt
if (FILE_EXTS.document.includes(extension.toUpperCase())) if (FILE_EXTS.document.includes(extension.toUpperCase())) { return FileAppearanceTypeEnum.document }
return FileAppearanceTypeEnum.document
return FileAppearanceTypeEnum.custom return FileAppearanceTypeEnum.custom
} }
export const getSupportFileType = (fileName: string, fileMimetype: string, isCustom?: boolean) => { export const getSupportFileType = (fileName: string, fileMimetype: string, isCustom?: boolean) => {
if (isCustom) if (isCustom) { return SupportUploadFileTypes.custom }
return SupportUploadFileTypes.custom
const extension = getFileExtension(fileName, fileMimetype) const extension = getFileExtension(fileName, fileMimetype)
for (const key in FILE_EXTS) { for (const key in FILE_EXTS) {
if ((FILE_EXTS[key]).includes(extension.toUpperCase())) if ((FILE_EXTS[key]).includes(extension.toUpperCase())) { return key }
return key
} }
return '' return ''
@ -144,8 +127,7 @@ export const getFileNameFromUrl = (url: string) => {
} }
export const getSupportFileExtensionList = (allowFileTypes: string[], allowFileExtensions: string[]) => { export const getSupportFileExtensionList = (allowFileTypes: string[], allowFileExtensions: string[]) => {
if (allowFileTypes.includes(SupportUploadFileTypes.custom)) if (allowFileTypes.includes(SupportUploadFileTypes.custom)) { return allowFileExtensions.map(item => item.slice(1).toUpperCase()) }
return allowFileExtensions.map(item => item.slice(1).toUpperCase())
return allowFileTypes.map(type => FILE_EXTS[type]).flat() return allowFileTypes.map(type => FILE_EXTS[type]).flat()
} }
@ -174,11 +156,9 @@ export const getFilesInLogs = (rawData: any) => {
} }
export const fileIsUploaded = (file: FileEntity) => { export const fileIsUploaded = (file: FileEntity) => {
if (file.uploadedId) if (file.uploadedId) { return true }
return true
if (file.transferMethod === TransferMethod.remote_url && file.progress === 100) if (file.transferMethod === TransferMethod.remote_url && file.progress === 100) { return true }
return true
} }
export const downloadFile = (url: string, filename: string) => { export const downloadFile = (url: string, filename: string) => {

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

@ -11,7 +11,7 @@ const Icon = (
ref, ref,
...props ...props
}: React.SVGProps<SVGSVGElement> & { }: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>; ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
}, },
) => <IconBase {...props} ref={ref} data={data as IconData} /> ) => <IconBase {...props} ref={ref} data={data as IconData} />

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}
@ -67,7 +70,7 @@ export const ImageGalleryTest = () => {
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
} }
@ -147,8 +146,7 @@ React.HTMLProps<HTMLDivElement>
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

@ -1,4 +1,4 @@
type ProgressBarProps = { interface ProgressBarProps {
percent: number percent: number
} }
const ProgressBar = ({ const ProgressBar = ({

View File

@ -1,7 +1,7 @@
import { memo } from 'react' import { memo } from 'react'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
type ProgressCircleProps = { interface ProgressCircleProps {
className?: string className?: string
percentage?: number percentage?: number
size?: number size?: number

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

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}
content={content}
className={classNames('!bg-white !text-xs !font-normal !text-gray-700 !shadow-lg !opacity-100', className)}
place={position}
clickable={clickable}
> >
{htmlContent && htmlContent} <PortalToFollowElemTrigger
</ReactTooltip> data-selector={selector}
onClick={() => triggerMethod === 'click' && setOpen(v => !v)}
onMouseEnter={() => triggerMethod === 'hover' && setOpen(true)}
onMouseLeave={() => triggerMethod === 'hover' && setOpen(false)}
>
{children}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[999]">
<div className={classNames('relative px-3 py-2 text-xs font-normal text-gray-700 bg-white rounded-md shadow-lg', className)}>
{htmlContent ?? content}
{arrow}
</div> </div>
</PortalToFollowElemContent>
</PortalToFollowElem>
) )
} }

View File

@ -1,21 +1,23 @@
'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 }) {
return (
<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 ?? ''}`} className={`relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800 ${className ?? ''}`}
style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }} style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
@ -24,6 +26,7 @@ const OperationBtn = ({ innerContent, onClick, className }: { innerContent: Reac
{innerContent} {innerContent}
</div> </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 (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<path d="M14 11.9998L13.3332 12.7292C12.9796 13.1159 12.5001 13.3332 12.0001 13.3332C11.5001 13.3332 11.0205 13.1159 10.6669 12.7292C10.3128 12.3432 9.83332 12.1265 9.33345 12.1265C8.83359 12.1265 8.35409 12.3432 7.99998 12.7292M2 13.3332H3.11636C3.44248 13.3332 3.60554 13.3332 3.75899 13.2963C3.89504 13.2637 4.0251 13.2098 4.1444 13.1367C4.27895 13.0542 4.39425 12.9389 4.62486 12.7083L13 4.33316C13.5523 3.78087 13.5523 2.88544 13 2.33316C12.4477 1.78087 11.5523 1.78087 11 2.33316L2.62484 10.7083C2.39424 10.9389 2.27894 11.0542 2.19648 11.1888C2.12338 11.3081 2.0695 11.4381 2.03684 11.5742C2 11.7276 2 11.8907 2 12.2168V13.3332Z" stroke="#6B7280" strokeLinecap="round" strokeLinejoin="round" /> <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> </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 (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<path fillRule="evenodd" clip-rule="evenodd" d="M10.8374 8.63108C11.0412 8.81739 11.0554 9.13366 10.8691 9.33747L10.369 9.88449C10.0142 10.2725 9.52293 10.5001 9.00011 10.5001C8.47746 10.5001 7.98634 10.2727 7.63157 9.8849C7.45561 9.69325 7.22747 9.59515 7.00014 9.59515C6.77271 9.59515 6.54446 9.69335 6.36846 9.88517C6.18177 10.0886 5.86548 10.1023 5.66201 9.91556C5.45853 9.72888 5.44493 9.41259 5.63161 9.20911C5.98678 8.82201 6.47777 8.59515 7.00014 8.59515C7.52251 8.59515 8.0135 8.82201 8.36867 9.20911L8.36924 9.20974C8.54486 9.4018 8.77291 9.50012 9.00011 9.50012C9.2273 9.50012 9.45533 9.40182 9.63095 9.20979L10.131 8.66276C10.3173 8.45895 10.6336 8.44476 10.8374 8.63108Z" fill="#6B7280" /> <path fillRule="evenodd" clip-rule="evenodd" d="M10.8374 8.63108C11.0412 8.81739 11.0554 9.13366 10.8691 9.33747L10.369 9.88449C10.0142 10.2725 9.52293 10.5001 9.00011 10.5001C8.47746 10.5001 7.98634 10.2727 7.63157 9.8849C7.45561 9.69325 7.22747 9.59515 7.00014 9.59515C6.77271 9.59515 6.54446 9.69335 6.36846 9.88517C6.18177 10.0886 5.86548 10.1023 5.66201 9.91556C5.45853 9.72888 5.44493 9.41259 5.63161 9.20911C5.98678 8.82201 6.47777 8.59515 7.00014 8.59515C7.52251 8.59515 8.0135 8.82201 8.36867 9.20911L8.36924 9.20974C8.54486 9.4018 8.77291 9.50012 9.00011 9.50012C9.2273 9.50012 9.45533 9.40182 9.63095 9.20979L10.131 8.66276C10.3173 8.45895 10.6336 8.44476 10.8374 8.63108Z" fill="#6B7280" />
<path fillRule="evenodd" clip-rule="evenodd" d="M7.89651 1.39656C8.50599 0.787085 9.49414 0.787084 10.1036 1.39656C10.7131 2.00604 10.7131 2.99419 10.1036 3.60367L3.82225 9.88504C3.81235 9.89494 3.80254 9.90476 3.79281 9.91451C3.64909 10.0585 3.52237 10.1855 3.3696 10.2791C3.23539 10.3613 3.08907 10.4219 2.93602 10.4587C2.7618 10.5005 2.58242 10.5003 2.37897 10.5001C2.3652 10.5001 2.35132 10.5001 2.33732 10.5001H1.50005C1.22391 10.5001 1.00005 10.2763 1.00005 10.0001V9.16286C1.00005 9.14886 1.00004 9.13497 1.00003 9.1212C0.999836 8.91776 0.999669 8.73838 1.0415 8.56416C1.07824 8.4111 1.13885 8.26479 1.22109 8.13058C1.31471 7.97781 1.44166 7.85109 1.58566 7.70736C1.5954 7.69764 1.60523 7.68783 1.61513 7.67793L7.89651 1.39656Z" fill="#6B7280" /> <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> </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 (
<div className="rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100">
{children} {children}
</div> </div>
)
} }
type IAnswerProps = { interface IAnswerProps {
item: ChatItem item: ChatItem
feedbackDisabled: boolean feedbackDisabled: boolean
onFeedback?: FeedbackFunc onFeedback?: FeedbackFunc
isResponding?: 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
@ -69,8 +79,9 @@ const Answer: FC<IAnswerProps> = ({
onFeedback, onFeedback,
isResponding, 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()
@ -83,8 +94,7 @@ const Answer: FC<IAnswerProps> = ({
* @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,7 +127,8 @@ const Answer: FC<IAnswerProps> = ({
const userOperation = () => { const userOperation = () => {
return feedback?.rating return feedback?.rating
? null ? null
: <div className='flex gap-1'> : (
<div className="flex gap-1">
<Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.like') as string}> <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('common.operation.like') as string}>
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })} {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
</Tooltip> </Tooltip>
@ -125,6 +136,7 @@ const Answer: FC<IAnswerProps> = ({
{OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })} {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
</Tooltip> </Tooltip>
</div> </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 */}
@ -167,15 +178,16 @@ 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`}>
{isResponding {isResponding
&& <div className={s.typeingIcon}> && (
<LoadingAnim type='avatar' /> <div className={s.typeingIcon}>
<LoadingAnim type="avatar" />
</div> </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 && (
@ -183,17 +195,28 @@ const Answer: FC<IAnswerProps> = ({
)} )}
{(isResponding && (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 className='absolute top-[-14px] right-[-14px] flex flex-row justify-end gap-1'> </div>
)}
</div>
<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
@ -33,6 +36,7 @@ export type IChatProps = {
isResponding?: boolean isResponding?: boolean
controlClearQuery?: number controlClearQuery?: number
visionConfig?: VisionSettings visionConfig?: VisionSettings
fileConfig?: FileUpload
} }
const Chat: FC<IChatProps> = ({ const Chat: FC<IChatProps> = ({
@ -46,15 +50,19 @@ const Chat: FC<IChatProps> = ({
isResponding, 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 (!isResponding)
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 */}
@ -130,6 +154,7 @@ const Chat: FC<IChatProps> = ({
feedbackDisabled={feedbackDisabled} feedbackDisabled={feedbackDisabled}
onFeedback={onFeedback} onFeedback={onFeedback}
isResponding={isResponding && 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,7 @@ 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'
export type IMainProps = { export interface IMainProps {
params: any params: any
} }
@ -48,10 +48,10 @@ const Main: FC<IMainProps> = () => {
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
@ -93,18 +93,17 @@ const Main: FC<IMainProps> = () => {
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 = ''
@ -117,6 +116,7 @@ const Main: FC<IMainProps> = () => {
setExistConversationInfo({ setExistConversationInfo({
name: item?.name || '', name: item?.name || '',
introduction: notSyncToStateIntroduction, introduction: notSyncToStateIntroduction,
suggested_questions: suggestedQuestions,
}) })
} }
else { else {
@ -151,8 +151,7 @@ const Main: FC<IMainProps> = () => {
}) })
} }
if (isNewConversation && isChatStarted) if (isNewConversation && isChatStarted) { setChatList(generateNewChatListWithOpenStatement()) }
setChatList(generateNewChatListWithOpenStatement())
} }
useEffect(handleConversationSwitch, [currConversationId, inited]) useEffect(handleConversationSwitch, [currConversationId, inited])
@ -175,16 +174,21 @@ const Main: FC<IMainProps> = () => {
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 canEditInputs = !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({
@ -192,6 +196,7 @@ const Main: FC<IMainProps> = () => {
name: t('app.chat.newChatDefaultName'), name: t('app.chat.newChatDefaultName'),
inputs: newConversationInputs, inputs: newConversationInputs,
introduction: conversationIntroduction, introduction: conversationIntroduction,
suggested_questions: suggestedQuestions,
}) })
})) }))
} }
@ -200,8 +205,7 @@ const Main: FC<IMainProps> = () => {
const generateNewChatListWithOpenStatement = (introduction?: string, inputs?: Record<string, any> | null) => { const generateNewChatListWithOpenStatement = (introduction?: string, inputs?: Record<string, any> | null) => {
let calculatedIntroduction = introduction || conversationIntroduction || '' let calculatedIntroduction = introduction || conversationIntroduction || ''
const calculatedPromptVariables = inputs || currInputs || null const calculatedPromptVariables = inputs || currInputs || null
if (calculatedIntroduction && calculatedPromptVariables) if (calculatedIntroduction && calculatedPromptVariables) { calculatedIntroduction = replaceVarWithValues(calculatedIntroduction, promptConfig?.prompt_variables || [], calculatedPromptVariables) }
calculatedIntroduction = replaceVarWithValues(calculatedIntroduction, promptConfig?.prompt_variables || [], calculatedPromptVariables)
const openStatement = { const openStatement = {
id: `${Date.now()}`, id: `${Date.now()}`,
@ -209,9 +213,9 @@ const Main: FC<IMainProps> = () => {
isAnswer: true, isAnswer: true,
feedbackDisabled: true, feedbackDisabled: true,
isOpeningStatement: isShowPrompt, isOpeningStatement: isShowPrompt,
suggestedQuestions,
} }
if (calculatedIntroduction) if (calculatedIntroduction) { return [openStatement] }
return [openStatement]
return [] return []
} }
@ -226,35 +230,53 @@ const Main: FC<IMainProps> = () => {
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, error } = conversationData as { data: ConversationItem[]; error: string } const { data: conversations, error } = conversationData as { data: ConversationItem[], error: string }
if (error) { if (error) {
Toast.notify({ type: 'error', message: error }) Toast.notify({ type: 'error', message: error })
throw new Error(error) throw new Error(error)
return 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)
} }
@ -278,11 +300,9 @@ const Main: FC<IMainProps> = () => {
} }
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
@ -317,11 +337,11 @@ const Main: FC<IMainProps> = () => {
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)
} }
@ -343,14 +363,11 @@ const Main: FC<IMainProps> = () => {
if (currInputs) { if (currInputs) {
Object.keys(currInputs).forEach((key) => { Object.keys(currInputs).forEach((key) => {
const value = currInputs[key] const value = currInputs[key]
if (value.supportFileType) if (value.supportFileType) { toServerInputs[key] = transformToServerFile(value) }
toServerInputs[key] = transformToServerFile(value)
else if (value[0]?.supportFileType) else if (value[0]?.supportFileType) { toServerInputs[key] = value.map((item: any) => transformToServerFile(item)) }
toServerInputs[key] = value.map((item: any) => transformToServerFile(item))
else else { toServerInputs[key] = value }
toServerInputs[key] = value
}) })
} }
@ -360,7 +377,7 @@ const Main: FC<IMainProps> = () => {
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 {
@ -378,7 +395,7 @@ const Main: FC<IMainProps> = () => {
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()}`
@ -417,16 +434,14 @@ const Main: FC<IMainProps> = () => {
} }
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
@ -442,8 +457,7 @@ const Main: FC<IMainProps> = () => {
}) })
}, },
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()
@ -462,8 +476,7 @@ const Main: FC<IMainProps> = () => {
}, },
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,
@ -518,13 +531,13 @@ const Main: FC<IMainProps> = () => {
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
} }
@ -533,11 +546,11 @@ const Main: FC<IMainProps> = () => {
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) => {
@ -546,8 +559,7 @@ const Main: FC<IMainProps> = () => {
(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
}, },
)) ))
}, },
@ -623,8 +635,7 @@ const Main: FC<IMainProps> = () => {
} }
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}
@ -635,11 +646,9 @@ const Main: FC<IMainProps> = () => {
) )
} }
if (appUnavailable) if (appUnavailable) { return <AppUnavailable isUnknownReason={isUnknownReason} errMessage={!hasSetAppConfig ? 'Please set APP_ID and API_KEY in config/index.tsx' : ''} /> }
return <AppUnavailable isUnknownReason={isUnknownReason} 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'>
@ -653,10 +662,7 @@ const Main: FC<IMainProps> = () => {
{/* 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>
@ -678,8 +684,7 @@ const Main: FC<IMainProps> = () => {
{ {
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}
@ -687,8 +692,8 @@ const Main: FC<IMainProps> = () => {
isResponding={isResponding} isResponding={isResponding}
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

@ -14,7 +14,7 @@ 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
@ -37,13 +37,11 @@ const Welcome: FC<IWelcomeProps> = ({
savedInputs, savedInputs,
onInputsChange, onInputsChange,
}) => { }) => {
console.log(promptConfig)
const { t } = useTranslation() const { t } = useTranslation()
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) {
@ -69,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>`
}) })
@ -177,7 +174,10 @@ 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 emptyInput = inputLens < promptVariablesLens || Object.values(inputs).filter(v => v === '').length > 0 const emptyInput = inputLens < promptVariablesLens || Object.entries(inputs).filter(([k, v]) => {
const isRequired = promptConfig.prompt_variables.find(item => item.key === k)?.required ?? true
return isRequired && v === ''
}).length > 0
if (emptyInput) { if (emptyInput) {
logError(t('app.errorMessage.valueOfVarRequired')) logError(t('app.errorMessage.valueOfVarRequired'))
return false return false
@ -186,8 +186,7 @@ const Welcome: FC<IWelcomeProps> = ({
} }
const handleChat = () => { const handleChat = () => {
if (!canChat()) if (!canChat()) { return }
return
onStartChat(inputs) onStartChat(inputs)
} }
@ -248,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)
@ -306,8 +304,7 @@ const Welcome: FC<IWelcomeProps> = ({
} }
const renderHasSetInputsPrivate = () => { const renderHasSetInputsPrivate = () => {
if (!canEditInputs || !hasVar) if (!canEditInputs || !hasVar) { return null }
return null
return ( return (
<TemplateVarPanel <TemplateVarPanel
@ -330,8 +327,7 @@ const Welcome: FC<IWelcomeProps> = ({
} }
const renderHasSetInputs = () => { const renderHasSetInputs = () => {
if ((!isPublicVersion && !canEditInputs) || !hasVar) if ((!isPublicVersion && !canEditInputs) || !hasVar) { return null }
return null
return ( return (
<div <div
@ -372,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

@ -9,7 +9,7 @@ import Loading02 from '@/app/components/base/icons/line/loading-02'
import CheckCircle from '@/app/components/base/icons/line/check-circle' import CheckCircle from '@/app/components/base/icons/line/check-circle'
import type { NodeTracing } from '@/types/app' import type { NodeTracing } from '@/types/app'
type Props = { interface Props {
nodeInfo: NodeTracing nodeInfo: NodeTracing
hideInfo?: boolean hideInfo?: boolean
} }
@ -18,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(() => {

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

@ -8,6 +8,7 @@ export const APP_INFO: AppInfo = {
copyright: '', copyright: '',
privacy_policy: '', privacy_policy: '',
default_language: 'en', 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

@ -6,15 +6,18 @@ 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 commonVi from './lang/common.vi'
import commonJa from './lang/common.ja' 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 appEs from './lang/app.es'
import appZh from './lang/app.zh' import appZh from './lang/app.zh'
import appVi from './lang/app.vi' import appVi from './lang/app.vi'
import appJa from './lang/app.ja' 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 toolsVi from './lang/tools.vi'
import toolsJa from './lang/tools.ja' import toolsJa from './lang/tools.ja'
import toolsFr from './lang/tools.fr'
import type { Locale } from '.' import type { Locale } from '.'
@ -57,6 +60,14 @@ const resources = {
tools: toolsJa, 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', 'es', 'zh-Hans', 'ja'], 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]

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

View File

@ -33,4 +33,4 @@ const translation = {
}, },
} }
export default translation; 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

103
i18n/lang/tools.fr.ts Normal file
View File

@ -0,0 +1,103 @@
const translation = {
title: 'Outils',
createCustomTool: 'Créer un outil personnalisé',
type: {
all: 'Tous',
builtIn: 'Intégré',
custom: 'Personnalisé',
},
contribute: {
line1: 'Je suis intéressé pour ',
line2: ' contribuer à des outils de Dify.',
viewGuide: 'Voir le guide',
},
author: 'Par',
auth: {
unauthorized: 'Autoriser',
authorized: 'Autorisé',
setup: 'Configurer l\'autorisation pour utiliser',
setupModalTitle: 'Configurer l\'autorisation',
setupModalTitleDescription: 'Aprèss avoir configuré les informations d\'identification, tous les membres de l\'espace de travail pourront utiliser cet outil pour orchestrer les applications.',
},
includeToolNum: '{{num}} outils inclus',
addTool: 'Ajouter un outil',
createTool: {
title: 'Créer un outil personnalisé',
editAction: 'Configurer',
editTitle: 'Éditer l\'outil personnalisé',
name: 'Nom',
toolNamePlaceHolder: 'Saisissez le nom de l\'outil',
schema: 'Schéma',
schemaPlaceHolder: 'Saisissez votre schéma OpenAPI ici',
viewSchemaSpec: 'Voir la spécification OpenAPI-Swagger',
importFromUrl: 'Importer depuis une URL',
importFromUrlPlaceHolder: 'https://...',
urlError: 'Veuillez saisir une URL valide',
examples: 'Exemples',
exampleOptions: {
json: 'Météo (JSON)',
yaml: 'Pet Store (YAML)',
blankTemplate: 'Modèle vide',
},
availableTools: {
title: 'Outils disponibles',
name: 'Nom',
description: 'Description',
method: 'Méthode',
path: 'Chemin',
action: 'Actions',
test: 'Test',
},
authMethod: {
title: 'Méthode d\'autorisation',
type: 'Type d\'autorisation',
types: {
none: 'Aucun',
api_key: 'Clé API',
},
key: 'Clé',
value: 'Valeur',
},
privacyPolicy: 'Politique de confidentialité',
privacyPolicyPlaceholder: 'Veuillez saisir la politique de confidentialité',
},
test: {
title: 'Test',
parametersValue: 'Paramètres & Valeurs',
parameters: 'Paramètres',
value: 'Valeurs',
testResult: 'Résultats du test',
testResultPlaceholder: 'Le résultat du test sera affiché ici',
},
thought: {
using: 'En cours d\'utilisation',
used: 'Utilisé',
requestTitle: 'Demande ',
responseTitle: 'Réponse de ',
},
setBuiltInTools: {
info: 'Informations',
setting: 'Paramétrage',
toolDescription: 'Description de l\'outil',
parameters: 'paramètres',
string: 'chaine de caractères',
number: 'nombre',
required: 'Requis',
infoAndSetting: 'Informations & Paramétrage',
},
noCustomTool: {
title: 'Aucun outil personnalisé !',
content: 'Ajoutez et gérez vos outils personnalisé pour créer des applications d\'apprentissage automatique.',
createTool: 'Créer un outil',
},
noSearchRes: {
title: 'Désolé, pas de résultats!',
content: 'Nous n\'avons pas trouvé d\'outils qui correspondent à votre recherche.',
reset: 'Réinitialiser la recherche',
},
builtInPromptTitle: 'Suggestion',
toolRemoved: 'Outil supprimé',
notAuthorized: 'Outil non autorisé',
}
export default translation

View File

@ -100,4 +100,4 @@ const translation = {
notAuthorized: 'Công cụ chưa được ủy quyền', notAuthorized: 'Công cụ chưa được ủy quyền',
} }
export default translation; export default translation

View File

@ -6,19 +6,20 @@ import { match } from '@formatjs/intl-localematcher'
import type { Locale } from '.' import type { Locale } from '.'
import { i18n } from '.' import { i18n } from '.'
export const getLocaleOnServer = (): Locale => { export const getLocaleOnServer = async (): Promise<Locale> => {
// @ts-expect-error locales are readonly // @ts-expect-error locales are readonly
const locales: string[] = i18n.locales const locales: string[] = i18n.locales
let languages: string[] | undefined let languages: string[] | undefined
// get locale from cookie // get locale from cookie
const localeCookie = cookies().get('locale') const localeCookie = (await cookies()).get('locale')
languages = localeCookie?.value ? [localeCookie.value] : [] languages = localeCookie?.value ? [localeCookie.value] : []
if (!languages.length) { if (!languages.length) {
// Negotiator expects plain object so we need to transform headers // Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {} const negotiatorHeaders: Record<string, string> = {}
headers().forEach((value, key) => (negotiatorHeaders[key] = value)) const headersList = await headers()
headersList.forEach((value, key) => (negotiatorHeaders[key] = value))
// Use negotiator and intl-localematcher to get best locale // Use negotiator and intl-localematcher to get best locale
languages = new Negotiator({ headers: negotiatorHeaders }).languages() languages = new Negotiator({ headers: negotiatorHeaders }).languages()
} }

View File

@ -16,6 +16,7 @@ const nextConfig = {
// https://nextjs.org/docs/api-reference/next.config.js/ignoring-typescript-errors // https://nextjs.org/docs/api-reference/next.config.js/ignoring-typescript-errors
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
output: 'standalone',
} }
module.exports = nextConfig module.exports = nextConfig

View File

@ -7,78 +7,83 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"fix": "next lint --fix", "fix": "eslint . --fix",
"eslint-fix": "eslint . --fix", "eslint-fix": "eslint . --fix",
"prepare": "husky install ./.husky" "lint-staged": "lint-staged",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@floating-ui/react": "^0.26.2", "@floating-ui/react": "^0.26.25",
"@formatjs/intl-localematcher": "^0.2.32", "@formatjs/intl-localematcher": "^0.5.6",
"@headlessui/react": "^1.7.13", "@headlessui/react": "2.2.1",
"@heroicons/react": "^2.0.16", "@heroicons/react": "^2.0.16",
"@mdx-js/loader": "^2.3.0", "@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^2.3.0", "@mdx-js/react": "^3.1.0",
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@remixicon/react": "^4.6.0", "@remixicon/react": "^4.6.0",
"@tailwindcss/line-clamp": "^0.4.2", "@types/node": "~18.19.0",
"@types/node": "18.15.0", "@types/react": "~18.3.23",
"@types/react": "18.0.28", "@types/react-dom": "~18.3.7",
"@types/react-dom": "18.0.11",
"@types/react-syntax-highlighter": "^15.5.6", "@types/react-syntax-highlighter": "^15.5.6",
"ahooks": "^3.7.5", "ahooks": "^3.8.4",
"axios": "^1.3.5", "axios": "^1.3.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"classnames": "^2.3.2", "classnames": "^2.5.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"dify-client": "^2.3.1", "dify-client": "^2.3.1",
"eslint": "8.36.0", "eslint-config-next": "~14.2.32",
"eslint-config-next": "13.4.0",
"eventsource-parser": "^1.0.0", "eventsource-parser": "^1.0.0",
"husky": "^8.0.3", "husky": "^9.1.7",
"i18next": "^22.4.13", "i18next": "^23.16.4",
"i18next-resources-to-backend": "^1.1.3", "i18next-resources-to-backend": "^1.2.1",
"immer": "^9.0.19", "immer": "^9.0.19",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"katex": "^0.16.7", "katex": "^0.16.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mime": "^4.0.7", "mime": "^4.0.7",
"negotiator": "^0.6.3", "negotiator": "^0.6.3",
"next": "^14.0.4", "next": "^15.5.9",
"rc-textarea": "^1.5.3", "rc-textarea": "^1.5.3",
"react": "18.2.0", "react": "~19.1.4",
"react-dom": "18.2.0", "react-dom": "~19.1.4",
"react-error-boundary": "^4.0.2", "react-error-boundary": "^4.0.2",
"react-headless-pagination": "^1.1.4", "react-headless-pagination": "^1.1.4",
"react-i18next": "^12.2.0", "react-i18next": "^12.2.0",
"react-markdown": "^8.0.6", "react-markdown": "^8.0.6",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"react-tooltip": "5.8.3", "react-tooltip": "~5.29.1",
"rehype-katex": "^6.0.2", "rehype-katex": "^7.0.1",
"remark-breaks": "^3.0.2", "remark-breaks": "^4.0.0",
"remark-gfm": "^3.0.1", "remark-gfm": "^4.0.0",
"remark-math": "^5.1.1", "remark-math": "^6.0.0",
"sass": "^1.61.0", "sass": "^1.61.0",
"scheduler": "^0.23.0", "scheduler": "^0.23.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"swr": "^2.1.0", "streamdown": "^1.2.0",
"swr": "^2.3.0",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"typescript": "4.9.5", "use-context-selector": "^2.0.0",
"use-context-selector": "^1.4.1", "uuid": "^10.0.0",
"uuid": "^9.0.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "0.36.0", "@antfu/eslint-config": "~5.2.2",
"@faker-js/faker": "^7.6.0", "@eslint-react/eslint-plugin": "^1.53.0",
"@faker-js/faker": "^9.0.3",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@types/js-cookie": "^3.0.3", "@types/js-cookie": "^3.0.6",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/negotiator": "^0.6.1", "@types/negotiator": "^0.6.3",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.20",
"eslint-plugin-react-hooks": "^4.6.0", "eslint": "~9.35.0",
"lint-staged": "^13.2.2", "eslint-plugin-format": "^1.0.1",
"postcss": "^8.4.21", "eslint-plugin-react-hooks": "^5.1.0",
"tailwindcss": "^3.2.7" "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"lint-staged": "^15.2.10",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "5.9.2"
}, },
"lint-staged": { "lint-staged": {
"**/*.js?(x)": [ "**/*.js?(x)": [

10029
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,7 @@ const baseOptions = {
redirect: 'follow', redirect: 'follow',
} }
export type WorkflowStartedResponse = { export interface WorkflowStartedResponse {
task_id: string task_id: string
workflow_run_id: string workflow_run_id: string
event: string event: string
@ -34,7 +34,7 @@ export type WorkflowStartedResponse = {
} }
} }
export type WorkflowFinishedResponse = { export interface WorkflowFinishedResponse {
task_id: string task_id: string
workflow_run_id: string workflow_run_id: string
event: string event: string
@ -52,7 +52,7 @@ export type WorkflowFinishedResponse = {
} }
} }
export type NodeStartedResponse = { export interface NodeStartedResponse {
task_id: string task_id: string
workflow_run_id: string workflow_run_id: string
event: string event: string
@ -68,7 +68,7 @@ export type NodeStartedResponse = {
} }
} }
export type NodeFinishedResponse = { export interface NodeFinishedResponse {
task_id: string task_id: string
workflow_run_id: string workflow_run_id: string
event: string event: string
@ -93,7 +93,7 @@ export type NodeFinishedResponse = {
} }
} }
export type IOnDataMoreInfo = { export interface IOnDataMoreInfo {
conversationId?: string conversationId?: string
taskId?: string taskId?: string
messageId: string messageId: string
@ -114,7 +114,7 @@ export type IOnWorkflowFinished = (workflowFinished: WorkflowFinishedResponse) =
export type IOnNodeStarted = (nodeStarted: NodeStartedResponse) => void export type IOnNodeStarted = (nodeStarted: NodeStartedResponse) => void
export type IOnNodeFinished = (nodeFinished: NodeFinishedResponse) => void export type IOnNodeFinished = (nodeFinished: NodeFinishedResponse) => void
type IOtherOptions = { interface IOtherOptions {
isPublicAPI?: boolean isPublicAPI?: boolean
bodyStringify?: boolean bodyStringify?: boolean
needAllResponseContent?: boolean needAllResponseContent?: boolean
@ -152,8 +152,7 @@ const handleStream = (
onNodeStarted?: IOnNodeStarted, onNodeStarted?: IOnNodeStarted,
onNodeFinished?: IOnNodeFinished, onNodeFinished?: IOnNodeFinished,
) => { ) => {
if (!response.ok) if (!response.ok) { throw new Error('Network response was not ok') }
throw new Error('Network response was not ok')
const reader = response.body?.getReader() const reader = response.body?.getReader()
const decoder = new TextDecoder('utf-8') const decoder = new TextDecoder('utf-8')
@ -241,8 +240,7 @@ const handleStream = (
onCompleted?.(true) onCompleted?.(true)
return return
} }
if (!hasError) if (!hasError) { read() }
read()
}) })
} }
read() read()
@ -262,17 +260,14 @@ const baseFetch = (url: string, fetchOptions: any, { needAllResponseContent }: I
Object.keys(params).forEach(key => Object.keys(params).forEach(key =>
paramsArray.push(`${key}=${encodeURIComponent(params[key])}`), paramsArray.push(`${key}=${encodeURIComponent(params[key])}`),
) )
if (urlWithPrefix.search(/\?/) === -1) if (urlWithPrefix.search(/\?/) === -1) { urlWithPrefix += `?${paramsArray.join('&')}` }
urlWithPrefix += `?${paramsArray.join('&')}`
else else { urlWithPrefix += `&${paramsArray.join('&')}` }
urlWithPrefix += `&${paramsArray.join('&')}`
delete options.params delete options.params
} }
if (body) if (body) { options.body = JSON.stringify(body) }
options.body = JSON.stringify(body)
// Handle timeout // Handle timeout
return Promise.race([ return Promise.race([
@ -344,16 +339,13 @@ export const upload = (fetchOptions: any): Promise<any> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = options.xhr const xhr = options.xhr
xhr.open(options.method, options.url) xhr.open(options.method, options.url)
for (const key in options.headers) for (const key in options.headers) { xhr.setRequestHeader(key, options.headers[key]) }
xhr.setRequestHeader(key, options.headers[key])
xhr.withCredentials = true xhr.withCredentials = true
xhr.onreadystatechange = function () { xhr.onreadystatechange = function () {
if (xhr.readyState === 4) { if (xhr.readyState === 4) {
if (xhr.status === 200) if (xhr.status === 200) { resolve({ id: xhr.response }) }
resolve({ id: xhr.response }) else { reject(xhr) }
else
reject(xhr)
} }
} }
xhr.upload.onprogress = options.onprogress xhr.upload.onprogress = options.onprogress
@ -386,8 +378,7 @@ export const ssePost = (
const urlWithPrefix = `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}` const urlWithPrefix = `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}`
const { body } = options const { body } = options
if (body) if (body) { options.body = JSON.stringify(body) }
options.body = JSON.stringify(body)
globalThis.fetch(urlWithPrefix, options) globalThis.fetch(urlWithPrefix, options)
.then((res: any) => { .then((res: any) => {
@ -410,7 +401,8 @@ export const ssePost = (
}, () => { }, () => {
onCompleted?.() onCompleted?.()
}, onThought, onMessageEnd, onMessageReplace, onFile, onWorkflowStarted, onWorkflowFinished, onNodeStarted, onNodeFinished) }, onThought, onMessageEnd, onMessageReplace, onFile, onWorkflowStarted, onWorkflowFinished, onNodeStarted, onNodeFinished)
}).catch((e) => { })
.catch((e) => {
Toast.notify({ type: 'error', message: e }) Toast.notify({ type: 'error', message: e })
onError?.(e) onError?.(e)
}) })

View File

@ -53,7 +53,7 @@ export const fetchAppParams = async () => {
return get('parameters') return get('parameters')
} }
export const updateFeedback = async ({ url, body }: { url: string; body: Feedbacktype }) => { export const updateFeedback = async ({ url, body }: { url: string, body: Feedbacktype }) => {
return post(url, { body }) return post(url, { body })
} }

View File

@ -61,6 +61,5 @@ module.exports = {
}, },
plugins: [ plugins: [
require('@tailwindcss/typography'), require('@tailwindcss/typography'),
require('@tailwindcss/line-clamp'),
], ],
} }

View File

@ -2,7 +2,7 @@ import type { Annotation } from './log'
import type { Locale } from '@/i18n' import type { Locale } from '@/i18n'
import type { ThoughtItem } from '@/app/components/chat/type' import type { ThoughtItem } from '@/app/components/chat/type'
export type PromptVariable = { export interface PromptVariable {
key: string key: string
name: string name: string
type: string type: string
@ -15,19 +15,19 @@ export type PromptVariable = {
allowed_file_upload_methods?: TransferMethod[] allowed_file_upload_methods?: TransferMethod[]
} }
export type PromptConfig = { export interface PromptConfig {
prompt_template: string prompt_template: string
prompt_variables: PromptVariable[] prompt_variables: PromptVariable[]
} }
export type TextTypeFormItem = { export interface TextTypeFormItem {
label: string label: string
variable: string variable: string
required: boolean required: boolean
max_length: number max_length: number
} }
export type SelectTypeFormItem = { export interface SelectTypeFormItem {
label: string label: string
variable: string variable: string
required: boolean required: boolean
@ -39,26 +39,26 @@ export type SelectTypeFormItem = {
export type UserInputFormItem = { export type UserInputFormItem = {
'text-input': TextTypeFormItem 'text-input': TextTypeFormItem
} | { } | {
'select': SelectTypeFormItem select: SelectTypeFormItem
} | { } | {
'paragraph': TextTypeFormItem paragraph: TextTypeFormItem
} }
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 Feedbacktype = { export interface Feedbacktype {
rating: MessageRating rating: MessageRating
content?: string | null content?: string | null
} }
export type MessageMore = { export interface MessageMore {
time: string time: string
tokens: number tokens: number
latency: number | string latency: number | string
} }
export type IChatItem = { export interface IChatItem {
id: string id: string
content: string content: string
/** /**
@ -85,7 +85,7 @@ 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[]
} }
@ -96,21 +96,23 @@ export type ChatItem = IChatItem & {
workflowProcess?: WorkflowProcess workflowProcess?: WorkflowProcess
} }
export type ResponseHolder = {} export interface ResponseHolder {}
export type ConversationItem = { export interface ConversationItem {
id: string id: string
name: string name: string
inputs: Record<string, any> | null inputs: Record<string, any> | null
introduction: string introduction: string
suggested_questions?: string[]
} }
export type AppInfo = { export interface AppInfo {
title: string title: string
description: string description: string
default_language: Locale default_language: Locale
copyright?: string copyright?: string
privacy_policy?: string privacy_policy?: string
disable_session_same_site?: boolean
} }
export enum Resolution { export enum Resolution {
@ -124,7 +126,7 @@ export enum TransferMethod {
remote_url = 'remote_url', remote_url = 'remote_url',
} }
export type VisionSettings = { export interface VisionSettings {
enabled: boolean enabled: boolean
number_limits: number number_limits: number
detail: Resolution detail: Resolution
@ -132,7 +134,7 @@ export type VisionSettings = {
image_file_size_limit?: number | string image_file_size_limit?: number | string
} }
export type ImageFile = { export interface ImageFile {
type: TransferMethod type: TransferMethod
_id: string _id: string
fileId: string fileId: string
@ -143,7 +145,7 @@ export type ImageFile = {
deleted?: boolean deleted?: boolean
} }
export type VisionFile = { export interface VisionFile {
id?: string id?: string
type: string type: string
transfer_method: TransferMethod transfer_method: TransferMethod
@ -167,7 +169,7 @@ export enum BlockEnum {
Tool = 'tool', Tool = 'tool',
} }
export type NodeTracing = { export interface NodeTracing {
id: string id: string
index: number index: number
predecessor_node_id: string predecessor_node_id: string
@ -212,7 +214,7 @@ export enum WorkflowRunningStatus {
Stopped = 'stopped', Stopped = 'stopped',
} }
export type WorkflowProcess = { export interface WorkflowProcess {
status: WorkflowRunningStatus status: WorkflowRunningStatus
tracing: NodeTracing[] tracing: NodeTracing[]
expand?: boolean // for UI expand?: boolean // for UI

View File

@ -1,5 +1,5 @@
export type TypeWithI18N<T = string> = { export interface TypeWithI18N<T = string> {
'en_US': T en_US: T
'zh_Hans': T zh_Hans: T
[key: string]: T [key: string]: T
} }

View File

@ -1,4 +1,4 @@
export type LogAnnotation = { export interface LogAnnotation {
content: string content: string
account: { account: {
id: string id: string
@ -8,7 +8,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

View File

@ -9,10 +9,10 @@ export enum AuthType {
apiKey = 'api_key', apiKey = 'api_key',
} }
export type Credential = { export interface Credential {
'auth_type': AuthType auth_type: AuthType
'api_key_header'?: string api_key_header?: string
'api_key_value'?: string api_key_value?: string
} }
export enum CollectionType { export enum CollectionType {
@ -21,12 +21,12 @@ export enum CollectionType {
custom = 'api', custom = 'api',
} }
export type Emoji = { export interface Emoji {
background: string background: string
content: string content: string
} }
export type Collection = { export interface Collection {
id: string id: string
name: string name: string
author: string author: string
@ -39,7 +39,7 @@ export type Collection = {
allow_delete: boolean allow_delete: boolean
} }
export type ToolParameter = { export interface ToolParameter {
name: string name: string
label: TypeWithI18N label: TypeWithI18N
human_description: TypeWithI18N human_description: TypeWithI18N
@ -52,14 +52,14 @@ export type ToolParameter = {
}[] }[]
} }
export type Tool = { export interface Tool {
name: string name: string
label: TypeWithI18N label: TypeWithI18N
description: any description: any
parameters: ToolParameter[] parameters: ToolParameter[]
} }
export type ToolCredential = { export interface ToolCredential {
name: string name: string
label: TypeWithI18N label: TypeWithI18N
help: TypeWithI18N help: TypeWithI18N
@ -73,7 +73,7 @@ export type ToolCredential = {
}[] }[]
} }
export type CustomCollectionBackend = { export interface CustomCollectionBackend {
provider: string provider: string
original_provider?: string original_provider?: string
credentials: Credential credentials: Credential
@ -84,7 +84,7 @@ export type CustomCollectionBackend = {
tools?: ParamItem[] tools?: ParamItem[]
} }
export type ParamItem = { export interface ParamItem {
name: string name: string
label: TypeWithI18N label: TypeWithI18N
human_description: TypeWithI18N human_description: TypeWithI18N
@ -99,7 +99,7 @@ export type ParamItem = {
}[] }[]
} }
export type CustomParamSchema = { export interface CustomParamSchema {
operation_id: string // name operation_id: string // name
summary: string summary: string
server_url: string server_url: string

View File

@ -4,8 +4,7 @@
* @example formatNumber(1234567.89) will return '1,234,567.89' * @example formatNumber(1234567.89) will return '1,234,567.89'
*/ */
export const formatNumber = (num: number | string) => { export const formatNumber = (num: number | string) => {
if (!num) if (!num) { return num }
return num
const parts = num.toString().split('.') const parts = num.toString().split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',') parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return parts.join('.') return parts.join('.')
@ -18,8 +17,7 @@ export const formatNumber = (num: number | string) => {
* @example formatFileSize(1024 * 1024) will return '1.00MB' * @example formatFileSize(1024 * 1024) will return '1.00MB'
*/ */
export const formatFileSize = (fileSize: number) => { export const formatFileSize = (fileSize: number) => {
if (!fileSize) if (!fileSize) { return fileSize }
return fileSize
const units = ['', 'K', 'M', 'G', 'T', 'P'] const units = ['', 'K', 'M', 'G', 'T', 'P']
let index = 0 let index = 0
while (fileSize >= 1024 && index < units.length) { while (fileSize >= 1024 && index < units.length) {
@ -35,8 +33,7 @@ export const formatFileSize = (fileSize: number) => {
* @example formatTime(60 * 60) will return '1.00 h' * @example formatTime(60 * 60) will return '1.00 h'
*/ */
export const formatTime = (seconds: number) => { export const formatTime = (seconds: number) => {
if (!seconds) if (!seconds) { return seconds }
return seconds
const units = ['sec', 'min', 'h'] const units = ['sec', 'min', 'h']
let index = 0 let index = 0
while (seconds >= 60 && index < units.length) { while (seconds >= 60 && index < units.length) {
@ -46,7 +43,7 @@ export const formatTime = (seconds: number) => {
return `${seconds.toFixed(2)} ${units[index]}` return `${seconds.toFixed(2)} ${units[index]}`
} }
export const downloadFile = ({ data, fileName }: { data: Blob; fileName: string }) => { export const downloadFile = ({ data, fileName }: { data: Blob, fileName: string }) => {
const url = window.URL.createObjectURL(data) const url = window.URL.createObjectURL(data)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url

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