diff --git a/.dumirc.ts b/.dumirc.ts index 796dd2c5..ecdd74e4 100644 --- a/.dumirc.ts +++ b/.dumirc.ts @@ -5,6 +5,12 @@ export default defineConfig({ themeConfig: { name: '@ant-design/pro-chat', github: homepage, + siteToken: { + demoInheritSiteTheme: true, + }, }, + mfsu: false, + outputPath: 'docs-dist', html2sketch: {}, + extraBabelPlugins: ['antd-style'], }); diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..e80eee72 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,30 @@ +name: Surge PR Preview + +on: + pull_request: + + workflow_dispatch: + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - uses: afc163/surge-preview@v1 + id: preview_step + with: + surge_token: ${{ secrets.SURGE_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + build: | + pnpm i + pnpm run docs:build + dist: docs-dist + + - name: Get the preview_url + run: echo "url => ${{ steps.preview_step.outputs.preview_url }}" diff --git a/.gitignore b/.gitignore index 064cd739..06643c8a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,6 @@ # dependencies **/node_modules -# roadhog-api-doc ignore -/src/utils/request-temp.js -_roadhog-api-doc # production **/dist @@ -40,3 +37,5 @@ screenshot example/.temp/* .eslintcache techUI* +.vercel +bun.lockb diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e526d9..8bde1fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,434 @@ # Changelog -### [Version 0.1.1](https://github.com/arvinxx/pro-chat/compare/v0.1.0...v0.1.1) +## [Version 0.2.0-beta.17](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.16...v0.2.0-beta.17) + +Released on **2023-11-22** + +#### ✨ 新特性 + +- Support useChat and chatRef. + +
+ +
+Improvements and Fixes + +#### What's improved + +- Support useChat and chatRef ([cf98b0f](https://github.com/ant-design/pro-chat/commit/cf98b0f)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.16](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.15...v0.2.0-beta.16) + +Released on **2023-11-14** + +#### 🐛 修复 + +- Fix onPressEnter no work error. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- Fix onPressEnter no work error, closes [#11](https://github.com/ant-design/pro-chat/issues/11) ([1db28f7](https://github.com/ant-design/pro-chat/commit/1db28f7)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.15](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.14...v0.2.0-beta.15) + +Released on **2023-11-08** + +#### ✨ 新特性 + +- Support extra userMeta and params. + +
+ +
+Improvements and Fixes + +#### What's improved + +- Support extra userMeta and params, closes [#10](https://github.com/ant-design/pro-chat/issues/10) ([8475b9a](https://github.com/ant-design/pro-chat/commit/8475b9a)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.14](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.13...v0.2.0-beta.14) + +Released on **2023-11-07** + +#### 🐛 修复 + +- Change action group style problem. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- Change action group style problem ([52c9104](https://github.com/ant-design/pro-chat/commit/52c9104)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.13](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.12...v0.2.0-beta.13) + +Released on **2023-11-07** + +#### ✨ 新特性 + +- Change action & group as pro-editor. + +
+ +
+Improvements and Fixes + +#### What's improved + +- Change action & group as pro-editor ([4f1d5ee](https://github.com/ant-design/pro-chat/commit/4f1d5ee)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.12](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.11...v0.2.0-beta.12) + +Released on **2023-10-26** + +#### 🐛 修复 + +- Fix send auto clear no work error. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- Fix send auto clear no work error, closes [#5](https://github.com/ant-design/pro-chat/issues/5) ([06f6ebf](https://github.com/ant-design/pro-chat/commit/06f6ebf)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.11](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.10...v0.2.0-beta.11) + +Released on **2023-10-26** + +#### ✨ 新特性 + +- Support autocompleteRequest and messageItemExtraRender. + +
+ +
+Improvements and Fixes + +#### What's improved + +- Support autocompleteRequest and messageItemExtraRender, closes [#4](https://github.com/ant-design/pro-chat/issues/4) ([5305e56](https://github.com/ant-design/pro-chat/commit/5305e56)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.10](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.9...v0.2.0-beta.10) + +Released on **2023-10-25** + +#### 🐛 修复 + +- Fix onResetMessage. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- Fix onResetMessage, closes [#3](https://github.com/ant-design/pro-chat/issues/3) ([078f277](https://github.com/ant-design/pro-chat/commit/078f277)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.9](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.8...v0.2.0-beta.9) + +Released on **2023-10-25** + +#### ✨ 新特性 + +- Support onResetMessage funtion. + +
+ +
+Improvements and Fixes + +#### What's improved + +- Support onResetMessage funtion, closes [#2](https://github.com/ant-design/pro-chat/issues/2) ([418230a](https://github.com/ant-design/pro-chat/commit/418230a)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.8](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.7...v0.2.0-beta.8) + +Released on **2023-10-25** + +#### 🐛 修复 + +- Fix avatar. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- Fix avatar ([61a7efe](https://github.com/ant-design/pro-chat/commit/61a7efe)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.7](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.6...v0.2.0-beta.7) + +Released on **2023-10-22** + +#### 🐛 修复 + +- Clean deps. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- Clean deps ([1479fa3](https://github.com/ant-design/pro-chat/commit/1479fa3)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.6](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.5...v0.2.0-beta.6) + +Released on **2023-10-22** + +
+ +
+Improvements and Fixes + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.5](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.4...v0.2.0-beta.5) + +Released on **2023-10-22** + +#### 🐛 修复 + +- Lock father to fix build. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- Lock father to fix build ([f6618e5](https://github.com/ant-design/pro-chat/commit/f6618e5)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.4](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.3...v0.2.0-beta.4) + +Released on **2023-10-22** + +#### ✨ 新特性 + +- 支持非流式请求. + +#### 🐛 修复 + +- Fix import. + +
+ +
+Improvements and Fixes + +#### What's improved + +- 支持非流式请求 ([211fad5](https://github.com/ant-design/pro-chat/commit/211fad5)) + +#### What's fixed + +- Fix import ([ac81b1d](https://github.com/ant-design/pro-chat/commit/ac81b1d)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.3](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.2...v0.2.0-beta.3) + +Released on **2023-10-22** + +#### 🐛 修复 + +- Fix import, fix import. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- Fix import ([565e530](https://github.com/ant-design/pro-chat/commit/565e530)) +- Fix import ([0311e5f](https://github.com/ant-design/pro-chat/commit/0311e5f)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.2](https://github.com/ant-design/pro-chat/compare/v0.2.0-beta.1...v0.2.0-beta.2) + +Released on **2023-10-21** + +#### ✨ 新特性 + +- Add ProChat, ProChat 初步组件化, 支持流式响应. + +
+ +
+Improvements and Fixes + +#### What's improved + +- Add ProChat ([ec20b42](https://github.com/ant-design/pro-chat/commit/ec20b42)) +- ProChat 初步组件化 ([86e8283](https://github.com/ant-design/pro-chat/commit/86e8283)) +- 支持流式响应 ([4e6d720](https://github.com/ant-design/pro-chat/commit/4e6d720)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 0.2.0-beta.1](https://github.com/ant-design/pro-chat/compare/v0.1.1...v0.2.0-beta.1) + +Released on **2023-10-20** + +#### ✨ 新特性 + +- Init basic components. + +
+ +
+Improvements and Fixes + +#### What's improved + +- Init basic components ([09d1284](https://github.com/ant-design/pro-chat/commit/09d1284)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +### [Version 0.1.1](https://github.com/ant-design/pro-chat/compare/v0.1.0...v0.1.1) Released on **2023-10-20** @@ -15,7 +443,7 @@ #### What's fixed -- Only support esm ([072ca61](https://github.com/arvinxx/pro-chat/commit/072ca61)) +- Only support esm ([072ca61](https://github.com/ant-design/pro-chat/commit/072ca61)) diff --git a/README.md b/README.md index b6ed159b..2994e7ac 100644 --- a/README.md +++ b/README.md @@ -41,15 +41,15 @@ -[coverage]: https://codecov.io/gh/arvinxx/pro-chat/branch/master/graph/badge.svg -[codecov-url]: https://codecov.io/gh/arvinxx/pro-chat/branch/master +[coverage]: https://codecov.io/gh/ant-design/pro-chat/branch/master/graph/badge.svg +[codecov-url]: https://codecov.io/gh/ant-design/pro-chat/branch/master -[test-ci]: https://github.com/arvinxx/pro-chat/workflows/Test%20CI/badge.svg -[release-ci]: https://github.com/arvinxx/pro-chat/workflows/Release%20CI/badge.svg -[test-ci-url]: https://github.com/arvinxx/pro-chat/actions?query=workflow%3ATest%20CI -[release-ci-url]: https://github.com/arvinxx/pro-chat/actions?query=workflow%3ARelease%20CI +[test-ci]: https://github.com/ant-design/pro-chat/workflows/Test%20CI/badge.svg +[release-ci]: https://github.com/ant-design/pro-chat/workflows/Release%20CI/badge.svg +[test-ci-url]: https://github.com/ant-design/pro-chat/actions?query=workflow%3ATest%20CI +[release-ci-url]: https://github.com/ant-design/pro-chat/actions?query=workflow%3ARelease%20CI [download-image]: https://img.shields.io/npm/dm/@ant-design/pro-chat.svg?style=flat-square [download-url]: https://npmjs.org/package/@ant-design/pro-chat @@ -126,20 +126,20 @@ This project is [MIT](./LICENSE) licensed. -[contributors-shield]: https://img.shields.io/github/contributors/arvinxx/pro-chat.svg?style=flat -[contributors-url]: https://github.com/arvinxx/pro-chat/graphs/contributors +[contributors-shield]: https://img.shields.io/github/contributors/ant-design/pro-chat.svg?style=flat +[contributors-url]: https://github.com/ant-design/pro-chat/graphs/contributors -[forks-shield]: https://img.shields.io/github/forks/arvinxx/pro-chat.svg?style=flat -[forks-url]: https://github.com/arvinxx/pro-chat/network/members +[forks-shield]: https://img.shields.io/github/forks/ant-design/pro-chat.svg?style=flat +[forks-url]: https://github.com/ant-design/pro-chat/network/members -[stargazers-shield]: https://img.shields.io/github/stars/arvinxx/pro-chat.svg?style=flat -[stargazers-url]: https://github.com/arvinxx/pro-chat/stargazers +[stargazers-shield]: https://img.shields.io/github/stars/ant-design/pro-chat.svg?style=flat +[stargazers-url]: https://github.com/ant-design/pro-chat/stargazers -[issues-shield]: https://img.shields.io/github/issues/arvinxx/pro-chat.svg?style=flat -[issues-url]: https://github.com/arvinxx/pro-chat/issues/new/choose +[issues-shield]: https://img.shields.io/github/issues/ant-design/pro-chat.svg?style=flat +[issues-url]: https://github.com/ant-design/pro-chat/issues/new/choose diff --git a/api/chat.ts b/api/chat.ts new file mode 100644 index 00000000..ee76f553 --- /dev/null +++ b/api/chat.ts @@ -0,0 +1,28 @@ +import { OpenAIStream, StreamingTextResponse } from 'ai'; +import OpenAI from 'openai'; + +export const config = { + runtime: 'edge', +}; + +export default async (req: Request) => { + const openai = new OpenAI(); + const payload = (await req.json()) as OpenAIChatStreamPayload; + const { messages, ...params } = payload; + + const formatMessages = messages.map((m) => ({ + content: m.content, + name: m.name, + role: m.role, + })); + const response = await openai.chat.completions.create( + { + messages: formatMessages, + ...params, + stream: true, + }, + { headers: { Accept: '*/*' } }, + ); + const stream = OpenAIStream(response); + return new StreamingTextResponse(stream); +}; diff --git a/api/package.json b/api/package.json new file mode 100644 index 00000000..09be7f87 --- /dev/null +++ b/api/package.json @@ -0,0 +1,9 @@ +{ + "name": "api", + "version": "0.0.0", + "description": "test openai api for pro chat", + "dependencies": { + "ai": "^2", + "openai": "^4" + } +} diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 00000000..51c21800 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "strict": false, + "declaration": false, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + } +} diff --git a/package.json b/package.json index 59e79e54..47c5c35a 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,18 @@ { "name": "@ant-design/pro-chat", - "version": "0.1.1", + "version": "0.2.0-beta.17", "description": "a solution for ai chat", "keywords": [ "npm", "template" ], - "homepage": "https://github.com/arvinxx/pro-chat", + "homepage": "https://github.com/ant-design/pro-chat", "bugs": { - "url": "https://github.com/arvinxx/pro-chat/issues/new" + "url": "https://github.com/ant-design/pro-chat/issues/new" }, "repository": { "type": "git", - "url": "https://github.com/arvinxx/pro-chat.git" + "url": "https://github.com/ant-design/pro-chat.git" }, "license": "MIT", "author": "arvinxx(arvinx@foxmail.com)", @@ -39,6 +39,7 @@ "release": "semantic-release", "setup": "dumi setup", "start": "dumi dev", + "start-with-api": "vercel dev", "test": "vitest --passWithNoTests", "test:coverage": "vitest run --coverage --passWithNoTests", "test:update": "vitest -u", @@ -57,13 +58,42 @@ "prettier --parser=typescript --write" ] }, + "dependencies": { + "@ant-design/icons": "^5", + "@ant-design/pro-editor": "latest", + "@babel/runtime": "^7", + "ahooks": "^3", + "chroma-js": "^2", + "copy-to-clipboard": "^3", + "dayjs": "^1", + "emoji-regex": "^10", + "fast-deep-equal": "^3", + "immer": "^10", + "lodash-es": "^4", + "lucide-react": "^0.288.0", + "nanoid": "^5", + "polished": "^4", + "react-error-boundary": "^4", + "react-intersection-observer": "^9.5.2", + "react-layout-kit": "^1.7.1", + "shikiji": "^0", + "swr": "^2", + "use-merge-value": "^1", + "zustand": "^4.4.1", + "zustand-utils": "^1.3.1" + }, "devDependencies": { "@commitlint/cli": "^17", "@testing-library/react": "^14", - "@types/react": "^18", + "@types/chroma-js": "^2", + "@types/lodash-es": "^4", + "@types/react": "18.2.31", "@types/react-dom": "^18", "@umijs/lint": "^4", "@vitest/coverage-v8": "latest", + "antd": "^5", + "antd-style": "^3", + "babel-plugin-antd-style": "^1.0.4", "commitlint": "^17", "commitlint-config-gitmoji": "^2", "conventional-changelog-gitmoji-config": "^1", @@ -71,9 +101,10 @@ "dumi": "^2", "dumi-theme-antd-style": "latest", "eslint": "^8", - "father": "^4", + "father": "4.3.1", "husky": "^8", "jsdom": "^22", + "leva": "^0.9.35", "lint-staged": "^15", "prettier": "^3", "prettier-plugin-organize-imports": "^3", @@ -86,6 +117,11 @@ "typescript": "^5", "vitest": "latest" }, + "peerDependencies": { + "antd": "^5", + "antd-style": "^3", + "react": "^18" + }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/src/ActionIcon/index.tsx b/src/ActionIcon/index.tsx new file mode 100644 index 00000000..d4aa8160 --- /dev/null +++ b/src/ActionIcon/index.tsx @@ -0,0 +1,178 @@ +import { Loader2 } from 'lucide-react'; +import { forwardRef, useMemo } from 'react'; + +import Icon, { type IconProps } from '@/Icon'; +import Spotlight from '@/components/Spotlight'; +import { DivProps } from '@/types'; +import { type TooltipProps } from 'antd'; + +import { useStyles } from './style'; + +import { ActionIcon as ProEditorActionIcon } from '@ant-design/pro-editor'; + +export type ActionIconSize = + | 'large' + | 'normal' + | 'small' + | 'site' + | { + blockSize?: number; + borderRadius?: number; + fontSize?: number; + strokeWidth?: number; + }; + +const calcSize = (size?: ActionIconSize) => { + let blockSize: number; + let borderRadius: number; + + switch (size) { + case 'large': { + blockSize = 44; + borderRadius = 8; + break; + } + case 'normal': { + blockSize = 36; + borderRadius = 5; + break; + } + case 'small': { + blockSize = 24; + borderRadius = 5; + break; + } + case 'site': { + blockSize = 34; + borderRadius = 5; + break; + } + default: { + blockSize = size?.blockSize || 36; + borderRadius = size?.borderRadius || 5; + break; + } + } + + return { + blockSize, + borderRadius, + }; +}; + +export interface ActionIconProps extends DivProps { + /** + * @description Whether the icon is active or not + * @default false + */ + active?: boolean; + /** + * @description Change arrow's visible state and change whether the arrow is pointed at the center of target. + * @default false + */ + arrow?: boolean; + color?: IconProps['color']; + fill?: IconProps['fill']; + /** + * @description Glass blur style + * @default 'false' + */ + glass?: boolean; + /** + * @description The icon element to be rendered + * @type LucideIcon + */ + icon?: IconProps['icon']; + /** + * @description Set the loading status of ActionIcon + */ + loading?: boolean; + + /** + * @description The position of the tooltip relative to the target + * @enum ["top","left","right","bottom","topLeft","topRight","bottomLeft","bottomRight","leftTop","leftBottom","rightTop","rightBottom"] + * @default "top" + */ + placement?: TooltipProps['placement']; + + /** + * @description Size of the icon + * @default 'normal' + */ + size?: ActionIconSize; + /** + * @description Whether add spotlight background + * @default false + */ + spotlight?: boolean; + /** + * @description The text shown in the tooltip + */ + title?: string; + /** + * @description Mouse enter delay of tooltip + * @default 0.5 + */ + tooltipDelay?: number; +} + +const ActionIcon = forwardRef( + ( + { + color, + fill, + className, + active, + icon, + size = 'normal', + style, + glass, + spotlight, + onClick, + children, + loading, + ...props + }, + ref, + ) => { + const { styles, cx } = useStyles({ active: Boolean(active), glass: Boolean(glass) }); + + const { blockSize, borderRadius } = useMemo(() => calcSize(size), [size]); + + const content = ( + <> + {icon && ( + + )} + {children} + + ); + + const spin = ( + + ); + + const actionIconBlock = ( +
+ {spotlight && } + {loading ? spin : content} +
+ ); + + return ; + }, +); + +export default ActionIcon; diff --git a/src/ActionIcon/style.ts b/src/ActionIcon/style.ts new file mode 100644 index 00000000..557e8023 --- /dev/null +++ b/src/ActionIcon/style.ts @@ -0,0 +1,47 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles( + ({ css, token, stylish, cx }, { active, glass }: { active: boolean; glass: boolean }) => { + return { + block: cx( + glass && stylish.blur, + css` + cursor: pointer; + + position: relative; + + display: flex; + flex: none; + align-items: center; + justify-content: center; + + color: ${active ? token.colorText : token.colorTextTertiary}; + + background: ${active ? token.colorFillTertiary : 'transparent'}; + + transition: + color 600ms ${token.motionEaseOut}, + scale 400ms ${token.motionEaseOut}, + background-color 100ms ${token.motionEaseOut}; + + &:hover { + color: ${token.colorText}; + background-color: ${token.colorFillSecondary}; + } + + &:active { + color: ${token.colorText}; + background-color: ${token.colorFill}; + } + `, + ), + icon: css` + transition: scale 400ms ${token.motionEaseOut}; + + &:active { + scale: 0.8; + } + `, + }; + }, +); diff --git a/src/ActionIconGroup/index.tsx b/src/ActionIconGroup/index.tsx new file mode 100644 index 00000000..0755b9e7 --- /dev/null +++ b/src/ActionIconGroup/index.tsx @@ -0,0 +1,123 @@ +import ActionIcon, { ActionIconProps } from '@/ActionIcon'; +import Icon from '@/Icon'; +import Spotlight from '@/components/Spotlight'; +import { ActionGroup as ProEditorActionGroup } from '@ant-design/pro-editor'; + +import { DropdownProps } from 'antd'; +import { type LucideIcon } from 'lucide-react'; +import { memo } from 'react'; + +import { DivProps } from '@/types'; + +export interface ActionIconGroupItems { + icon: LucideIcon; + key: string; + label: string; +} + +export interface ActionEvent { + item: ActionIconGroupItems; + key: string; + keyPath: string[]; +} + +export interface ActionIconGroupProps extends DivProps { + /** + * @description The direction of the icons + * @default "row" + */ + direction?: 'row' | 'column'; + /** + * @description The menu items for the dropdown + */ + dropdownMenu?: (ActionIconGroupItems | { type: 'divider' })[]; + /** + * @description The items to be rendered + * @default [] + */ + items?: ActionIconGroupItems[]; + onActionClick?: (action: ActionEvent) => void; + /** + * @description The position of the tooltip relative to the target + * @enum ["top","left","right","bottom","topLeft","topRight","bottomLeft","bottomRight","leftTop","leftBottom","rightTop","rightBottom"] + */ + placement?: ActionIconProps['placement']; + /** + * @description Whether to add a spotlight background + * @default true + */ + spotlight?: boolean; + /** + * @description The type of the group + * @default "block" + */ + type?: 'ghost' | 'block' | 'pure'; +} + +const ActionIconGroup = memo( + ({ + type = 'block', + items = [], + placement, + spotlight = false, + direction = 'row', + dropdownMenu = [], + onActionClick, + className, + style, + }) => { + const tooltipsPlacement = placement || (direction === 'column' ? 'right' : 'top'); + + const mergeDropDownList = dropdownMenu?.map((item: any) => { + return { + ...item, + icon: , + onClick: onActionClick + ? (info: ActionEvent) => + onActionClick({ + item, + key: info.key, + keyPath: info.keyPath, + }) + : undefined, + }; + }); + + return ( + { + return ( + <> + {spotlight && } + {items?.length > 0 && + items.map((item) => ( + onActionClick?.({ item, key: item.key, keyPath: [item.key] }) + : undefined + } + placement={tooltipsPlacement} + size="small" + title={item.label} + /> + ))} + + ); + }} + /> + ); + }, +); + +export default ActionIconGroup; diff --git a/src/BackBottom/demos/index.tsx b/src/BackBottom/demos/index.tsx new file mode 100644 index 00000000..9b6c8520 --- /dev/null +++ b/src/BackBottom/demos/index.tsx @@ -0,0 +1,18 @@ +import { BackBottom } from '@ant-design/pro-chat'; +import { useRef } from 'react'; + +export default () => { + const ref = useRef(null); + return ( +
+
+ {Array.from({ length: 40 }) + .fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') + .map((item: any, index) => ( +

{item}

+ ))} +
+ +
+ ); +}; diff --git a/src/BackBottom/index.md b/src/BackBottom/index.md new file mode 100644 index 00000000..ca7939b6 --- /dev/null +++ b/src/BackBottom/index.md @@ -0,0 +1,14 @@ +--- +nav: 组件 +group: Chat +title: BackBottom +order: 10 +--- + +## Default + + + +## APIs + + diff --git a/src/BackBottom/index.tsx b/src/BackBottom/index.tsx new file mode 100644 index 00000000..2561005b --- /dev/null +++ b/src/BackBottom/index.tsx @@ -0,0 +1,63 @@ +import { useScroll } from 'ahooks'; +import type { Target } from 'ahooks/lib/useScroll'; +import { Button, type BackTopProps } from 'antd'; +import { ListEnd } from 'lucide-react'; +import { + memo, + useEffect, + useRef, + useState, + type CSSProperties, + type MouseEventHandler, +} from 'react'; + +import Icon from '@/Icon'; + +import { useStyles } from './style'; + +export interface BackBottomProps { + className?: string; + onClick?: BackTopProps['onClick']; + style?: CSSProperties; + target: Target; + text?: string; + visibilityHeight?: BackTopProps['visibilityHeight']; +} + +const BackBottom = memo( + ({ visibilityHeight = 240, target, onClick, style, className, text }) => { + const [visible, setVisible] = useState(false); + const { styles, cx } = useStyles(visible); + const ref = useRef(null); + const current = (target as any)?.current; + const scrollHeight = current?.scrollHeight || 0; + const clientHeight = current?.clientHeight || 0; + const scroll = useScroll(target); + + useEffect(() => { + if (scroll?.top) { + setVisible(scroll?.top + clientHeight + visibilityHeight < scrollHeight); + } + }, [scrollHeight, scroll, visibilityHeight]); + + const scrollToBottom: MouseEventHandler = (e) => { + (target as any)?.current?.scrollTo({ behavior: 'smooth', left: 0, top: scrollHeight }); + onClick?.(e); + }; + + return ( + + ); + }, +); + +export default BackBottom; diff --git a/src/BackBottom/style.ts b/src/BackBottom/style.ts new file mode 100644 index 00000000..f515a75b --- /dev/null +++ b/src/BackBottom/style.ts @@ -0,0 +1,19 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles(({ token, css, stylish, cx }, visible: boolean) => + cx( + stylish.blur, + css` + pointer-events: ${visible ? 'all' : 'none'}; + + transform: translateY(${visible ? 0 : '16px'}); + + padding-inline: 12px !important; + + opacity: ${visible ? 1 : 0}; + background: ${token.colorFillSecondary}; + border-color: ${token.colorFillTertiary} !important; + border-radius: 16px !important; + `, + ), +); diff --git a/src/ChatItem/components/Actions.tsx b/src/ChatItem/components/Actions.tsx new file mode 100644 index 00000000..7e4ce296 --- /dev/null +++ b/src/ChatItem/components/Actions.tsx @@ -0,0 +1,25 @@ +import { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { ChatItemProps } from '@/ChatItem'; + +import { useStyles } from '../style'; + +export interface ActionsProps { + actions: ChatItemProps['actions']; + editing?: boolean; + placement?: ChatItemProps['placement']; + type?: ChatItemProps['type']; +} + +const Actions = memo(({ actions, placement, type, editing }) => { + const { styles } = useStyles({ editing, placement, type }); + + return ( + + {actions} + + ); +}); + +export default Actions; diff --git a/src/ChatItem/components/Avatar.tsx b/src/ChatItem/components/Avatar.tsx new file mode 100644 index 00000000..135d8348 --- /dev/null +++ b/src/ChatItem/components/Avatar.tsx @@ -0,0 +1,43 @@ +import { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import A from '@/components/Avatar'; + +import { useStyles } from '../style'; +import type { ChatItemProps } from '../type'; +import Loading from './Loading'; + +export interface AvatarProps { + addon?: ChatItemProps['avatarAddon']; + avatar: ChatItemProps['avatar']; + loading?: ChatItemProps['loading']; + onClick?: ChatItemProps['onAvatarClick']; + placement?: ChatItemProps['placement']; + size?: number; +} + +const Avatar = memo(({ loading, avatar, placement, addon, onClick, size = 40 }) => { + const { styles } = useStyles({ avatarSize: size }); + const avatarContent = ( + + ); + + if (!addon) return avatarContent; + return ( + + {avatarContent} + {addon} + + ); +}); + +export default Avatar; diff --git a/src/ChatItem/components/BorderSpacing.tsx b/src/ChatItem/components/BorderSpacing.tsx new file mode 100644 index 00000000..877f61a8 --- /dev/null +++ b/src/ChatItem/components/BorderSpacing.tsx @@ -0,0 +1,13 @@ +import { memo } from 'react'; + +export interface BorderSpacingProps { + borderSpacing?: number; +} + +const BorderSpacing = memo(({ borderSpacing }) => { + if (!borderSpacing) return null; + + return
; +}); + +export default BorderSpacing; diff --git a/src/ChatItem/components/ErrorContent.tsx b/src/ChatItem/components/ErrorContent.tsx new file mode 100644 index 00000000..8f09f7a4 --- /dev/null +++ b/src/ChatItem/components/ErrorContent.tsx @@ -0,0 +1,26 @@ +import { Alert } from 'antd'; +import { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { ChatItemProps } from '@/ChatItem'; + +import { useStyles } from '../style'; + +export interface ErrorContentProps { + error?: ChatItemProps['error']; + message?: ChatItemProps['errorMessage']; + placement?: ChatItemProps['placement']; +} + +const ErrorContent = memo(({ message, error, placement }) => { + const { styles } = useStyles({ placement }); + + return ( + + + {message} + + ); +}); + +export default ErrorContent; diff --git a/src/ChatItem/components/Loading.tsx b/src/ChatItem/components/Loading.tsx new file mode 100644 index 00000000..7d83517c --- /dev/null +++ b/src/ChatItem/components/Loading.tsx @@ -0,0 +1,27 @@ +import { Loader2 } from 'lucide-react'; +import { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { ChatItemProps } from '@/ChatItem'; +import Icon from '@/Icon'; + +import { useStyles } from '../style'; + +export interface LoadingProps { + loading?: ChatItemProps['loading']; + placement?: ChatItemProps['placement']; +} + +const Loading = memo(({ loading, placement }) => { + const { styles } = useStyles({ placement }); + + if (!loading) return null; + + return ( + + + + ); +}); + +export default Loading; diff --git a/src/ChatItem/components/MessageContent.tsx b/src/ChatItem/components/MessageContent.tsx new file mode 100644 index 00000000..0e0724fd --- /dev/null +++ b/src/ChatItem/components/MessageContent.tsx @@ -0,0 +1,70 @@ +import { useResponsive } from 'antd-style'; +import { memo, type ReactNode } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { ChatItemProps } from '@/ChatItem'; +import EditableMessage from '@/EditableMessage'; + +import { useStyles } from '../style'; + +export interface MessageContentProps { + editing?: ChatItemProps['editing']; + message?: ReactNode; + messageExtra?: ChatItemProps['messageExtra']; + onChange?: ChatItemProps['onChange']; + onDoubleClick?: ChatItemProps['onDoubleClick']; + onEditingChange?: ChatItemProps['onEditingChange']; + placement?: ChatItemProps['placement']; + primary?: ChatItemProps['primary']; + renderMessage?: ChatItemProps['renderMessage']; + text?: ChatItemProps['text']; + type?: ChatItemProps['type']; +} + +const MessageContent = memo( + ({ + editing, + onChange, + onEditingChange, + text, + message, + placement, + messageExtra, + renderMessage, + type, + primary, + onDoubleClick, + }) => { + const { cx, styles } = useStyles({ editing, placement, primary, type }); + const { mobile } = useResponsive(); + + const content = ( + + ); + const messageContent = renderMessage ? renderMessage(content) : content; + + return ( + + {messageContent} + {messageExtra && !editing ? ( +
{messageExtra}
+ ) : null} +
+ ); + }, +); + +export default MessageContent; diff --git a/src/ChatItem/components/Title.tsx b/src/ChatItem/components/Title.tsx new file mode 100644 index 00000000..dfc08f7d --- /dev/null +++ b/src/ChatItem/components/Title.tsx @@ -0,0 +1,31 @@ +import { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { ChatItemProps } from '@/ChatItem'; +import { formatTime } from '@/ChatItem/utils/formatTime'; + +import { useStyles } from '../style'; + +export interface TitleProps { + avatar: ChatItemProps['avatar']; + placement?: ChatItemProps['placement']; + showTitle?: ChatItemProps['showTitle']; + time?: ChatItemProps['time']; +} + +const Title = memo(({ showTitle, placement, time, avatar }) => { + const { styles } = useStyles({ placement, showTitle }); + + return ( + + {showTitle ? avatar.title || 'untitled' : undefined} + {time && } + + ); +}); + +export default Title; diff --git a/src/ChatItem/demos/Alert.tsx b/src/ChatItem/demos/Alert.tsx new file mode 100644 index 00000000..5de3124f --- /dev/null +++ b/src/ChatItem/demos/Alert.tsx @@ -0,0 +1,5 @@ +import { ChatItem } from '@ant-design/pro-chat'; + +import { avatar } from './data'; + +export default () => ; diff --git a/src/ChatItem/demos/data.tsx b/src/ChatItem/demos/data.tsx new file mode 100644 index 00000000..5a644cd2 --- /dev/null +++ b/src/ChatItem/demos/data.tsx @@ -0,0 +1,37 @@ +import type { ActionIconGroupProps, MetaData } from '@ant-design/pro-chat'; +import { Copy, Edit, RotateCw, Trash } from 'lucide-react'; + +export const avatar: MetaData = { + avatar: '😎', + backgroundColor: '#E8DA5A', + title: 'Advertiser', +}; + +export const items: ActionIconGroupProps['items'] = [ + { + icon: Edit, + key: 'edit', + label: 'Edit', + }, +]; + +export const dropdownMenu: ActionIconGroupProps['dropdownMenu'] = [ + { + icon: Copy, + key: 'copy', + label: 'Copy', + }, + { + icon: RotateCw, + key: 'regenerate', + label: 'Regenerate', + }, + { + type: 'divider', + }, + { + icon: Trash, + key: 'delete', + label: 'Delete', + }, +]; diff --git a/src/ChatItem/demos/index.tsx b/src/ChatItem/demos/index.tsx new file mode 100644 index 00000000..05b2c5a8 --- /dev/null +++ b/src/ChatItem/demos/index.tsx @@ -0,0 +1,48 @@ +import { ActionIconGroup, ChatItem, ChatItemProps } from '@ant-design/pro-chat'; +import { useState } from 'react'; + +import { avatar, dropdownMenu, items } from './data'; + +export default () => { + const [edit, setEdit] = useState(false); + const control: ChatItemProps | any = { + loading: false, + message: { + rows: true, + value: + "要使用 dayjs 的 fromNow 函数,需要先安装 dayjs 库并在代码中引入它。然后,可以使用以下语法来获取当前时间与给定时间之间的相对时间:\n\n```javascript\ndayjs().fromNow();\ndayjs('2021-05-01').fromNow();\n```", + }, + placement: { + options: ['left', 'right'], + value: 'left', + }, + primary: false, + showTitle: false, + time: 1_686_538_950_084, + type: { + options: ['block', 'pure'], + value: 'block', + }, + }; + + return ( + { + if (action.key === 'edit') { + setEdit(true); + } + }} + type="ghost" + /> + } + avatar={avatar} + editing={edit} + onEditingChange={setEdit} + /> + ); +}; diff --git a/src/ChatItem/index.md b/src/ChatItem/index.md new file mode 100644 index 00000000..65584891 --- /dev/null +++ b/src/ChatItem/index.md @@ -0,0 +1,19 @@ +--- +nav: 组件 +group: Chat +title: ChatItem +description: ChatItem is a React component that represents a single item in a chat conversation. It displays the user's avatar, name, and message. It can also display a loading indicator if the message is still being sent. +order: 10 +--- + +## Default + + + +## Alert + + + +## APIs + + diff --git a/src/ChatItem/index.tsx b/src/ChatItem/index.tsx new file mode 100644 index 00000000..5938e553 --- /dev/null +++ b/src/ChatItem/index.tsx @@ -0,0 +1,111 @@ +import { useResponsive } from 'antd-style'; +import { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import Actions from './components/Actions'; +import Avatar from './components/Avatar'; +import BorderSpacing from './components/BorderSpacing'; +import ErrorContent from './components/ErrorContent'; +import MessageContent from './components/MessageContent'; +import Title from './components/Title'; +import { useStyles } from './style'; +import type { ChatItemProps } from './type'; + +const MOBILE_AVATAR_SIZE = 32; + +const ChatItem = memo( + ({ + avatarAddon, + onAvatarClick, + actions, + className, + primary, + loading, + message, + placement = 'left', + type = 'block', + avatar, + error, + showTitle, + time, + editing, + onChange, + onEditingChange, + messageExtra, + renderMessage, + text, + errorMessage, + onDoubleClick, + ...props + }) => { + const { mobile } = useResponsive(); + const { cx, styles } = useStyles({ + editing, + placement, + primary, + showTitle, + title: avatar.title, + type, + }); + + return ( + + + + + <Flexbox + align={placement === 'left' ? 'flex-start' : 'flex-end'} + className={styles.messageContent} + direction={ + type === 'block' + ? placement === 'left' + ? 'horizontal' + : 'horizontal-reverse' + : 'vertical' + } + gap={8} + > + {error ? ( + <ErrorContent error={error} message={errorMessage} placement={placement} /> + ) : ( + <MessageContent + editing={editing} + message={message} + messageExtra={messageExtra} + onChange={onChange} + onDoubleClick={onDoubleClick} + onEditingChange={onEditingChange} + placement={placement} + primary={primary} + renderMessage={renderMessage} + text={text} + type={type} + /> + )} + <Actions actions={actions} editing={editing} placement={placement} type={type} /> + </Flexbox> + </Flexbox> + {mobile && type === 'block' && <BorderSpacing borderSpacing={MOBILE_AVATAR_SIZE} />} + </Flexbox> + ); + }, +); + +export default ChatItem; + +export type { ChatItemProps } from './type'; diff --git a/src/ChatItem/style.ts b/src/ChatItem/style.ts new file mode 100644 index 00000000..43722c8f --- /dev/null +++ b/src/ChatItem/style.ts @@ -0,0 +1,190 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles( + ( + { cx, css, token, isDarkMode, responsive }, + { + placement, + type, + title, + primary, + avatarSize, + showTitle, + editing, + }: { + avatarSize?: number; + editing?: boolean; + placement?: 'left' | 'right'; + primary?: boolean; + showTitle?: boolean; + title?: string; + type?: 'block' | 'pure'; + }, + ) => { + const blockStylish = css` + padding: 8px 12px; + background-color: ${primary + ? isDarkMode + ? token.colorFill + : token.colorBgElevated + : isDarkMode + ? token.colorFillSecondary + : token.colorBgContainer}; + border-radius: ${token.borderRadiusLG}px; + transition: background-color 100ms ${token.motionEaseOut}; + `; + + const pureStylish = css` + padding-top: ${title ? 0 : '6px'}; + `; + + const pureContainerStylish = css` + margin-bottom: -16px; + transition: background-color 100ms ${token.motionEaseOut}; + `; + + const typeStylish = type === 'block' ? blockStylish : pureStylish; + + const editingStylish = + editing && + css` + width: 100%; + `; + + return { + actions: cx( + css` + align-self: ${type === 'block' + ? 'flex-end' + : placement === 'left' + ? 'flex-start' + : 'flex-end'}; + justify-content: ${placement === 'left' ? 'flex-end' : 'flex-start'}; + `, + editing && + css` + pointer-events: none !important; + opacity: 0 !important; + `, + ), + alert: css``, + avatarContainer: css` + position: relative; + flex: none; + width: ${avatarSize}px; + height: ${avatarSize}px; + `, + avatarGroupContainer: css` + width: ${avatarSize}px; + `, + container: cx( + type === 'pure' && pureContainerStylish, + css` + position: relative; + width: 100%; + max-width: 100vw; + padding: 12px 16px; + + time { + display: inline-block; + white-space: nowrap; + } + + div[role='menubar'] { + display: flex; + } + + time, + div[role='menubar'] { + pointer-events: none; + opacity: 0; + transition: opacity 200ms ${token.motionEaseOut}; + } + + &:hover { + time, + div[role='menubar'] { + pointer-events: unset; + opacity: 1; + } + } + + ${responsive.mobile} { + padding: 4px 16px; + } + `, + ), + editingContainer: cx( + editingStylish, + css` + padding: 8px 12px 12px; + border: 1px solid ${token.colorBorderSecondary}; + + &:active, + &:hover { + border-color: ${token.colorBorder}; + } + `, + type === 'pure' && + css` + background: ${token.colorFillQuaternary}; + border-radius: ${token.borderRadius}px; + `, + ), + editingInput: css` + width: 100%; + `, + loading: css` + position: absolute; + right: ${placement === 'left' ? '-4px' : 'unset'}; + bottom: 0; + left: ${placement === 'right' ? '-4px' : 'unset'}; + + width: 16px; + height: 16px; + + color: ${token.colorBgLayout}; + + background: ${token.colorPrimary}; + border-radius: 50%; + `, + message: cx( + typeStylish, + css` + position: relative; + `, + ), + messageContainer: cx( + editingStylish, + css` + position: relative; + `, + ), + messageContent: cx( + editingStylish, + css` + position: relative; + overflow-x: hidden; + + ${responsive.mobile} { + flex-direction: column !important; + } + `, + ), + messageExtra: cx('message-extra'), + name: css` + position: ${showTitle ? 'relative' : 'absolute'}; + top: ${showTitle ? 'unset' : '-16px'}; + right: ${placement === 'right' ? '0' : 'unset'}; + left: ${placement === 'left' ? '0' : 'unset'}; + + margin-bottom: 6px; + + font-size: 12px; + line-height: 1; + color: ${token.colorTextDescription}; + text-align: ${placement === 'left' ? 'left' : 'right'}; + `, + }; + }, +); diff --git a/src/ChatItem/type.ts b/src/ChatItem/type.ts new file mode 100644 index 00000000..7fddb59a --- /dev/null +++ b/src/ChatItem/type.ts @@ -0,0 +1,75 @@ +import { AlertProps } from 'antd'; +import { ReactNode } from 'react'; + +import { EditableMessageProps } from '@/EditableMessage'; +import { DivProps, MetaData } from '@/types'; + +export interface ChatItemProps { + /** + * @description Actions to be displayed in the chat item + */ + actions?: ReactNode; + /** + * @description Metadata for the avatar + */ + avatar: MetaData; + avatarAddon?: ReactNode; + /** + * @description Custom CSS class name for the chat item + */ + className?: string; + /** + * @description Whether the chat item is in editing mode + */ + editing?: boolean; + /** + * @description Props for Error render + */ + error?: AlertProps; + errorMessage?: ReactNode; + /** + * @description Whether the chat item is in loading state + */ + loading?: boolean; + /** + * @description The message content of the chat item + */ + message?: ReactNode; + messageExtra?: ReactNode; + onAvatarClick?: () => void; + /** + * @description Callback when the message content changes + * @param value - The new message content + */ + onChange?: (value: string) => void; + onDoubleClick?: DivProps['onDoubleClick']; + /** + * @description Callback when the editing mode changes + * @param editing - The new editing mode + */ + onEditingChange?: (editing: boolean) => void; + /** + * @description The placement of the chat item + * @default 'left' + */ + placement?: 'left' | 'right'; + /** + * @description Whether the chat item is primary + */ + primary?: boolean; + renderMessage?: (content: ReactNode) => ReactNode; + /** + * @description Whether to show the title of the chat item + */ + showTitle?: boolean; + text?: EditableMessageProps['text']; + /** + * @description The timestamp of the chat item + */ + time?: number; + /** + * @description The type of the chat item + * @default 'block' + */ + type?: 'block' | 'pure'; +} diff --git a/src/ChatItem/utils/formatTime.ts b/src/ChatItem/utils/formatTime.ts new file mode 100644 index 00000000..a59a4762 --- /dev/null +++ b/src/ChatItem/utils/formatTime.ts @@ -0,0 +1,14 @@ +import dayjs from 'dayjs'; + +export const formatTime = (time: number): string => { + const now = dayjs(); + const target = dayjs(time); + + if (target.isSame(now, 'day')) { + return target.format('HH:mm:ss'); + } else if (target.isSame(now, 'year')) { + return target.format('MM-DD HH:mm:ss'); + } else { + return target.format('YYYY-MM-DD HH:mm:ss'); + } +}; diff --git a/src/ChatList/ActionsBar.tsx b/src/ChatList/ActionsBar.tsx new file mode 100644 index 00000000..f2c301f4 --- /dev/null +++ b/src/ChatList/ActionsBar.tsx @@ -0,0 +1,27 @@ +import { memo } from 'react'; + +import ActionIconGroup, { type ActionIconGroupProps } from '@/ActionIconGroup'; +import { useChatListActionsBar } from '@/hooks/useChatListActionsBar'; + +export interface ActionsBarProps extends ActionIconGroupProps { + text?: { + copy?: string; + delete?: string; + edit?: string; + regenerate?: string; + }; +} + +const ActionsBar = memo<ActionsBarProps>(({ text, ...props }) => { + const { regenerate, edit, copy, divider, del } = useChatListActionsBar(text); + return ( + <ActionIconGroup + dropdownMenu={[edit, copy, regenerate, divider, del]} + items={[regenerate, edit]} + type="ghost" + {...props} + /> + ); +}); + +export default ActionsBar; diff --git a/src/ChatList/HistoryDivider.tsx b/src/ChatList/HistoryDivider.tsx new file mode 100644 index 00000000..bb92a48e --- /dev/null +++ b/src/ChatList/HistoryDivider.tsx @@ -0,0 +1,25 @@ +import { Divider } from 'antd'; +import { Timer } from 'lucide-react'; +import { memo } from 'react'; + +import Icon from '@/Icon'; +import Tag from '@/components/Tag'; + +interface HistoryDividerProps { + enable?: boolean; + text?: string; +} + +const HistoryDivider = memo<HistoryDividerProps>(({ enable, text }) => { + if (!enable) return null; + + return ( + <div style={{ padding: '0 20px' }}> + <Divider> + <Tag icon={<Icon icon={Timer} />}>{text || 'History Message'}</Tag> + </Divider> + </div> + ); +}); + +export default HistoryDivider; diff --git a/src/ChatList/Item.tsx b/src/ChatList/Item.tsx new file mode 100644 index 00000000..717250e5 --- /dev/null +++ b/src/ChatList/Item.tsx @@ -0,0 +1,225 @@ +import { App } from 'antd'; +import copy from 'copy-to-clipboard'; +import { FC, ReactNode, memo, useCallback, useMemo, useState } from 'react'; + +import { ActionEvent } from '@/ActionIconGroup'; +import ChatItem, { type ChatItemProps } from '@/ChatItem'; +import { LLMRoleType } from '@/types/llm'; +import { ChatMessage } from '@/types/message'; + +import ActionsBar, { type ActionsBarProps } from './ActionsBar'; + +export type OnMessageChange = (id: string, content: string) => void; +export type OnActionClick = (action: ActionEvent, message: ChatMessage) => void; +export type RenderRole = LLMRoleType | 'default' | string; +export type RenderItem = FC<{ key: string } & ChatMessage & ListItemProps>; +export type RenderMessage = FC<ChatMessage & { editableContent: ReactNode }>; +export type RenderMessageExtra = FC<ChatMessage>; +export type RenderErrorMessage = FC<ChatMessage>; +export type RenderAction = FC<ActionsBarProps & ChatMessage>; + +export interface ListItemProps { + groupNav?: ChatItemProps['avatarAddon']; + loading?: boolean; + /** + * @description 点击操作按钮的回调函数 + */ + onActionsClick?: OnActionClick; + /** + * @description 消息变化的回调函数 + */ + onMessageChange?: OnMessageChange; + renderActions?: { + [actionKey: string]: RenderAction; + }; + /** + * @description 渲染错误消息的函数 + */ + renderErrorMessages?: { + [errorType: 'default' | string]: RenderErrorMessage; + }; + renderItems?: { + [role: RenderRole]: RenderItem; + }; + /** + * @description 渲染消息的函数 + */ + renderMessages?: { + [role: RenderRole]: RenderMessage; + }; + /** + * @description 渲染消息额外内容的函数 + */ + renderMessagesExtra?: { + [role: RenderRole]: RenderMessageExtra; + }; + /** + * @description 是否显示聊天项的名称 + * @default false + */ + showTitle?: boolean; + /** + * @description 文本内容 + */ + text?: ChatItemProps['text'] & + ActionsBarProps['text'] & { + copySuccess?: string; + history?: string; + } & { + [key: string]: string; + }; + /** + * @description 聊天列表的类型 + * @default 'chat' + */ + type?: 'docs' | 'chat'; +} + +export type ChatListItemProps = ChatMessage & ListItemProps; + +const Item = memo<ChatListItemProps>((props) => { + const { + renderMessagesExtra, + showTitle, + onActionsClick, + onMessageChange, + type, + text, + renderMessages, + renderErrorMessages, + renderActions, + loading, + groupNav, + renderItems, + ...item + } = props; + + const [editing, setEditing] = useState(false); + + const { message } = App.useApp(); + + const RenderItem = useMemo(() => { + if (!renderItems || !item?.role) return; + let renderFunction; + if (renderItems?.[item.role]) renderFunction = renderItems[item.role]; + if (!renderFunction && renderItems?.['default']) renderFunction = renderItems['default']; + if (!renderFunction) return; + return renderFunction; + }, [renderItems?.[item.role]]); + + const RenderMessage = useCallback( + ({ editableContent, data }: { data: ChatMessage; editableContent: ReactNode }) => { + if (!renderMessages || !item?.role) return; + let RenderFunction; + if (renderMessages?.[item.role]) RenderFunction = renderMessages[item.role]; + if (!RenderFunction && renderMessages?.['default']) + RenderFunction = renderMessages['default']; + if (!RenderFunction) return; + return <RenderFunction {...data} editableContent={editableContent} />; + }, + [renderMessages?.[item.role]], + ); + + const MessageExtra = useCallback( + ({ data }: { data: ChatMessage }) => { + if (!renderMessagesExtra || !item?.role) return; + let RenderFunction; + if (renderMessagesExtra?.[item.role]) RenderFunction = renderMessagesExtra[item.role]; + if (renderMessagesExtra?.['default']) RenderFunction = renderMessagesExtra['default']; + if (!RenderFunction && !RenderFunction) return; + + return <RenderFunction {...data} />; + }, + [renderMessagesExtra?.[item.role]], + ); + + const ErrorMessage = useCallback( + ({ data }: { data: ChatMessage }) => { + if (!renderErrorMessages || !item?.error?.type) return; + let RenderFunction; + if (renderErrorMessages?.[item.error.type]) + RenderFunction = renderErrorMessages[item.error.type]; + if (!RenderFunction && renderErrorMessages?.['default']) + RenderFunction = renderErrorMessages['default']; + if (!RenderFunction) return; + return <RenderFunction {...data} />; + }, + [renderErrorMessages?.[item?.error?.type]], + ); + + const Actions = useCallback( + ({ data }: { data: ChatMessage }) => { + if (!renderActions || !item?.role) return; + let RenderFunction; + if (renderActions?.[item.role]) RenderFunction = renderActions[item.role]; + if (renderActions?.['default']) RenderFunction = renderActions['default']; + if (!RenderFunction) RenderFunction = ActionsBar; + + const handleActionClick: ListItemProps['onActionsClick'] = (action, data) => { + switch (action.key) { + case 'copy': { + copy(data.content); + message.success(text?.copySuccess || 'Copy Success'); + break; + } + case 'edit': { + setEditing(true); + } + } + + onActionsClick?.(action, data); + }; + + return ( + <RenderFunction + {...data} + onActionClick={(actionKey) => handleActionClick?.(actionKey, data)} + text={text} + /> + ); + }, + [renderActions?.[item.role], text, onActionsClick], + ); + + const error = useMemo(() => { + if (!item.error) return; + return { + message: item.error?.message, + }; + }, [item.error]); + + if (RenderItem) return <RenderItem key={item.id} {...props} />; + + return ( + <ChatItem + actions={<Actions data={item} />} + avatar={(item as any).meta} + avatarAddon={groupNav} + editing={editing} + error={error} + errorMessage={<ErrorMessage data={item} />} + loading={loading} + message={item.content} + messageExtra={<MessageExtra data={item} />} + onChange={(value) => onMessageChange?.(item.id, value)} + onDoubleClick={(e) => { + if (item.id === 'default' || item.error) return; + if (item.role && ['assistant', 'user'].includes(item.role) && e.altKey) { + setEditing(true); + } + }} + onEditingChange={setEditing} + placement={type === 'chat' ? (item.role === 'user' ? 'right' : 'left') : 'left'} + primary={item.role === 'user'} + renderMessage={(editableContent) => ( + <RenderMessage data={item} editableContent={editableContent} /> + )} + showTitle={showTitle} + text={text} + time={item.updateAt || item.createAt} + type={type === 'chat' ? 'block' : 'pure'} + /> + ); +}); + +export default Item; diff --git a/src/ChatList/index.tsx b/src/ChatList/index.tsx new file mode 100644 index 00000000..40998f50 --- /dev/null +++ b/src/ChatList/index.tsx @@ -0,0 +1,84 @@ +import { Fragment, memo } from 'react'; + +import type { ChatMessage, DivProps } from '@/types'; + +import HistoryDivider from './HistoryDivider'; +import Item, { ListItemProps } from './Item'; +import { useStyles } from './style'; + +export interface ChatListProps extends DivProps, ListItemProps { + /** + * @description Data of chat messages to be displayed + */ + data: ChatMessage[]; + enableHistoryCount?: boolean; + historyCount?: number; + loadingId?: string; +} +export type { + OnActionClick, + OnMessageChange, + RenderAction, + RenderErrorMessage, + RenderItem, + RenderMessage, + RenderMessageExtra, +} from './Item'; + +const ChatList = memo<ChatListProps>( + ({ + onActionsClick, + renderMessagesExtra, + className, + data, + type = 'chat', + text, + showTitle, + onMessageChange, + renderMessages, + renderErrorMessages, + loadingId, + renderItems, + enableHistoryCount, + renderActions, + historyCount = 0, + ...props + }) => { + const { cx, styles } = useStyles(); + + return ( + <div className={cx(styles.container, className)} {...props}> + {data.map((item, index) => { + const itemProps = { + loading: loadingId === item.id, + onActionsClick, + onMessageChange, + renderActions, + renderErrorMessages, + renderItems, + renderMessages, + renderMessagesExtra, + showTitle, + text, + type, + }; + + const historyLength = data.length; + const enableHistoryDivider = + enableHistoryCount && + historyLength > historyCount && + historyCount === historyLength - index + 1; + + return ( + <Fragment key={item.id}> + <HistoryDivider enable={enableHistoryDivider} text={text?.history} /> + <Item {...itemProps} {...item} /> + </Fragment> + ); + })} + </div> + ); + }, +); + +export default ChatList; diff --git a/src/ChatList/style.ts b/src/ChatList/style.ts new file mode 100644 index 00000000..e684626a --- /dev/null +++ b/src/ChatList/style.ts @@ -0,0 +1,9 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles(({ css }) => { + return { + container: css` + position: relative; + `, + }; +}); diff --git a/src/EditableMessage/demos/index.tsx b/src/EditableMessage/demos/index.tsx new file mode 100644 index 00000000..61801d6a --- /dev/null +++ b/src/EditableMessage/demos/index.tsx @@ -0,0 +1,106 @@ +import { EditableMessage } from '@ant-design/pro-chat'; +import { useState } from 'react'; + +export const content = `# This is an H1 +## This is an H2 +### This is an H3 +#### This is an H4 +##### This is an H5 + +The point of reference-style links is not that they’re easier to write. The point is that with reference-style links, your document source is vastly more readable. Compare the above examples: using reference-style links, the paragraph itself is only 81 characters long; with inline-style links, it’s 176 characters; and as raw \`HTML\`, it’s 234 characters. In the raw \`HTML\`, there’s more markup than there is text. + +--- + +> This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, +> consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. +> Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus. +> +> Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse +> id sem consectetuer libero luctus adipiscing. + +--- + +an example | *an example* | **an example** + +--- + +1. Bird +1. McHale +1. Parish + 1. Bird + 1. McHale + 1. Parish + +--- + +- Red +- Green +- Blue + - Red + - Green + - Blue + +--- + +This is [an example](http://example.com/ "Title") inline link. + +<http://example.com/> + + +| title | title | title | +| --- | --- | --- | +| content | content | content | + + +\`\`\`bash +$ pnpm install +\`\`\` + + +\`\`\`javascript +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-dom/test-utils'; +import { useDropNodeOnCanvas } from './useDropNodeOnCanvas'; +\`\`\` + +--- + +以下是一段Markdown格式的LaTeX数学公式: + +我是一个行内公式:$E=mc^2$ + +我是一个独立公式: +$$ +\\sum_{i=1}^{n} x_i = x_1 + x_2 + \\ldots + x_n +$$ + +我是一个带有分式的公式: +$$ +\\frac{{n!}}{{k!(n-k)!}} = \\binom{n}{k} +$$ + +我是一个带有上下标的公式: +$$ +x^{2} + y^{2} = r^{2} +$$ + +我是一个带有积分符号的公式: +$$ +\\int_{a}^{b} f(x) \\, dx +$$ +`; + +export default () => { + const [openModal, setOpenModal] = useState(false); + const [editing, setEdit] = useState(false); + + return ( + <EditableMessage + editing={editing} + onEditingChange={setEdit} + onOpenChange={setOpenModal} + openModal={openModal} + value={content} + /> + ); +}; diff --git a/src/EditableMessage/index.md b/src/EditableMessage/index.md new file mode 100644 index 00000000..41d196a0 --- /dev/null +++ b/src/EditableMessage/index.md @@ -0,0 +1,14 @@ +--- +nav: 组件 +group: Message +title: EditableMessage +description: The EditableMessage component is used to display a message that can be edited by the user. It consists of a Markdown component and an optional modal for editing the message. When the user clicks on the message, it enters editing mode and displays an input field for editing the message. +--- + +## Default + +<code src="./demos/index.tsx" nopadding></code> + +## APIs + +<API></API> diff --git a/src/EditableMessage/index.tsx b/src/EditableMessage/index.tsx new file mode 100644 index 00000000..a0ceb867 --- /dev/null +++ b/src/EditableMessage/index.tsx @@ -0,0 +1,173 @@ +import { CSSProperties, memo } from 'react'; +import useControlledState from 'use-merge-value'; + +import MessageInput, { type MessageInputProps } from '@/MessageInput'; +import MessageModal, { type MessageModalProps } from '@/MessageModal'; +import { Markdown } from '@ant-design/pro-editor'; + +export interface EditableMessageProps { + /** + * @title The class name for the Markdown and MessageInput component + */ + classNames?: { + /** + * @title The class name for the MessageInput component + */ + input?: string; + /** + * @title The class name for the Markdown component + */ + markdown?: string; + textarea?: string; + }; + editButtonSize?: MessageInputProps['editButtonSize']; + /** + * @title Whether the component is in edit mode or not + * @default false + */ + editing?: boolean; + fullFeaturedCodeBlock?: boolean; + height?: MessageInputProps['height']; + inputType?: MessageInputProps['type']; + model?: { + extra?: MessageModalProps['extra']; + footer?: MessageModalProps['footer']; + }; + /** + * @title Callback function when the value changes + * @param value - The new value + */ + onChange?: (value: string) => void; + /** + * @title Callback function when the editing state changes + * @param editing - Whether the component is in edit mode or not + */ + onEditingChange?: (editing: boolean) => void; + /** + * @title Callback function when the modal open state changes + * @param open - Whether the modal is open or not + */ + onOpenChange?: (open: boolean) => void; + /** + * @title Whether the modal is open or not + * @default false + */ + openModal?: boolean; + placeholder?: string; + /** + * @title Whether to show the edit button when the text value is empty + * @default false + */ + showEditWhenEmpty?: boolean; + styles?: { + /** + * @title The style for the MessageInput component + */ + input?: CSSProperties; + /** + * @title The style for the Markdown component + */ + markdown?: CSSProperties; + }; + text?: MessageModalProps['text']; + /** + * @title The current text value + */ + value: string; +} + +const EditableMessage = memo<EditableMessageProps>( + ({ + value, + onChange, + classNames = {}, + onEditingChange, + editing, + openModal, + onOpenChange, + placeholder = 'Type something...', + showEditWhenEmpty = false, + styles, + height, + inputType, + editButtonSize, + text, + fullFeaturedCodeBlock, + model, + }) => { + const [isEdit, setTyping] = useControlledState(false, { + onChange: onEditingChange, + value: editing, + }); + + const [expand, setExpand] = useControlledState<boolean>(false, { + onChange: onOpenChange, + value: openModal, + }); + + const isAutoSize = height === 'auto'; + + const input = ( + <MessageInput + className={classNames?.input} + classNames={{ textarea: classNames?.textarea }} + defaultValue={value} + editButtonSize={editButtonSize} + height={height} + onCancel={() => setTyping(false)} + onConfirm={(text) => { + onChange?.(text); + setTyping(false); + }} + placeholder={placeholder} + style={styles?.input} + text={text} + textareaClassname={classNames?.input} + type={inputType} + /> + ); + + if (!value && showEditWhenEmpty) return input; + + return ( + <> + {!expand && isEdit ? ( + input + ) : ( + <Markdown + className={classNames?.markdown} + highlight={{ + containerWrapper: fullFeaturedCodeBlock, + }} + style={{ + height: isAutoSize ? 'unset' : height, + overflowX: 'hidden', + overflowY: 'auto', + ...styles?.markdown, + }} + > + {value || placeholder} + </Markdown> + )} + <MessageModal + editing={isEdit} + extra={model?.extra} + footer={model?.footer} + height={height} + onChange={(text) => onChange?.(text)} + onEditingChange={setTyping} + onOpenChange={(e) => { + setExpand(e); + setTyping(false); + }} + open={expand} + placeholder={placeholder} + text={text} + value={value} + /> + </> + ); + }, +); + +export default EditableMessage; diff --git a/src/EditableMessageList/demos/data.ts b/src/EditableMessageList/demos/data.ts new file mode 100644 index 00000000..e82b5155 --- /dev/null +++ b/src/EditableMessageList/demos/data.ts @@ -0,0 +1,43 @@ +import { LLMMessage } from '@ant-design/pro-chat'; + +export const data: LLMMessage[] = [ + { + content: + '| **量化等级** | **最低 GPU 显存**(推理) | **最低 GPU 显存**(高效参数微调) |\n| -------------- | ------------------------- | --------------------------------- |\n| FP16(无量化) | 13 GB | 14 GB |\n| INT8 | 8 GB | 9 GB |\n| INT4 | 6 GB | 7 GB |\n\n请解释 FP16 、INT8、INT4 的意思?', + role: 'user', + }, + { + content: + 'FP16、INT8、INT4 是量化技术中常用的三种精度级别。它们是用来减小模型的计算量和内存占用的一种方式,从而加速推理和高效参数微调的过程。\n\nFP16 表示使用半精度浮点数(16 位)来表示模型参数和激活值,相比于单精度浮点数(32 位)可以减少一半的内存占用和计算量,但是可能会损失一些精度。\n\nINT8 表示使用 8 位整数来表示模型参数和激活值,相比于 FP16 可以进一步减少内存占用和计算量,但是需要进行量化训练和量化后的推理过程。\n\nINT4 表示使用 4 位整数来表示模型参数和激活值,相比于 INT8 可以进一步减少内存占用和计算量,但是需要进行更加复杂的量化训练和量化后的推理过程,且精度降低更明显。', + role: 'assistant', + }, + { + content: + '对比项\t中文LLaMA\t中文Alpaca\n训练方式\t传统CLM (在通用语料上训练)\t指令精调 (在指令数据上训练)\n\nCLM 和指令精调是什么意思?', + role: 'user', + }, + { + content: + 'CLM 是 Continual Language Model 的缩写,指的是基于大规模通用语料库的语言模型。这种模型通常使用 Transformer 结构,通过无监督学习的方式预测下一个词,从而学习语言的统计规律和语义表示。CLM 可以被用于多种自然语言处理任务中,如文本生成、机器翻译、语言理解等。\n\n指令精调(Instruction-based Fine-tuning)是一种在指令数据上进行微调的方法。在这种方法中,模型首先在大规模通用语料上进行训练,然后在特定领域的指令数据上进行微调,以适应该领域的特定需求。指令数据通常是一些特定领域的语料库,比如医学、法律、金融等。指令精调可以提高模型在特定领域的表现,同时保留模型在通用语料上的语言表示能力。', + role: 'assistant', + }, + { + content: 'SOTA是什么意思', + role: 'user', + }, + { + content: + 'SOTA 是 State-Of-The-Art 的缩写,意为“最先进的技术”或“最高水平的技术”。在各个领域,SOTA 指代的是当前最优秀的技术或方法,通常是指在某个任务或问题上取得的最高性能指标。在机器学习、自然语言处理、计算机视觉等领域,SOTA 是一种非常重要的标志,代表着该领域的最高水平和技术进展。SOTA 的不断更新和提升,反映了该领域的技术发展和研究进展。', + role: 'assistant', + }, + { + content: + 'Since the FastChat (Vicuna) repository doesn’t yet support GPTQ-quantized models, I have integrated the GPTQ-for-LLaMa repository into this fork to run a GPTQ-quantized model. Why should we use the GPTQ-quantized version of the Vicuna model? By using the GPTQ-quantized version, we can reduce the VRAM requirement from 28 GB to about 10 GB, which allows us to run the Vicuna-13B model on a single consumer GPU. Another advantage is the ~3x speedup during inference when using the quantized model version. Any disadvantages? Yes, the quantized model is slightly less precise and therefore performs slightly worse than the full precision model. However, it is the quantization of the model that allows many of us to use the model at all. The following figure from the GPTQ paper shows that, especially for larger language models, the qualitative degradation due to the application of quantization is small:\n\n请解释 什么是 GPTQ', + role: 'user', + }, + { + content: + 'GPTQ 是 GPT-Quantized 的缩写,是一种基于量化技术的 GPT 模型。GPTQ 使用定点数(Fixed-Point Number)来表示模型参数和激活值,相比于浮点数可以减小模型的计算量和内存占用,从而提高模型的推理速度和效率。同时,GPTQ 还采用了一些量化技巧和优化策略,如对称量化、非线性量化、动态范围调整等,以保证模型在量化后仍然能够保持较高的精度和性能。\n\n在上述引用中,提到了将 GPTQ 应用于 Vicuna 模型的优点,即可以将 VRAM 要求从 28 GB 减少到约 10 GB,从而可以在单个消费级 GPU 上运行 Vicuna-13B 模型,并且推理速度可以提高约 3 倍。但是,GPTQ 模型的缺点是精度稍低,表现可能略逊于全精度模型。不过,对于大型语言模型而言,应用量化技术带来的质量下降是较小的,因此 GPTQ 可以在一定程度上平衡模型的精度和效率。', + role: 'assistant', + }, +]; diff --git a/src/EditableMessageList/demos/index.tsx b/src/EditableMessageList/demos/index.tsx new file mode 100644 index 00000000..fcd4c126 --- /dev/null +++ b/src/EditableMessageList/demos/index.tsx @@ -0,0 +1,5 @@ +import { EditableMessageList } from '@ant-design/pro-chat'; + +import { data } from './data'; + +export default () => <EditableMessageList dataSources={data} />; diff --git a/src/EditableMessageList/index.md b/src/EditableMessageList/index.md new file mode 100644 index 00000000..e0706d09 --- /dev/null +++ b/src/EditableMessageList/index.md @@ -0,0 +1,14 @@ +--- +nav: 组件 +group: Message +title: EditableMessageList +description: EditableMessageList is a React component that allows users to edit a list of chat messages, including their content and role. It is designed to be used in chatbot building applications. +--- + +## Default + +<code src="./demos/index.tsx" ></code> + +## APIs + +<API></API> diff --git a/src/EditableMessageList/index.tsx b/src/EditableMessageList/index.tsx new file mode 100644 index 00000000..9b826355 --- /dev/null +++ b/src/EditableMessageList/index.tsx @@ -0,0 +1,106 @@ +import { Button, Select } from 'antd'; +import isEqual from 'fast-deep-equal'; +import { Plus, Trash } from 'lucide-react'; +import { memo, useEffect, useReducer } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import ActionIcon from '@/ActionIcon'; +import Icon from '@/Icon'; +import { ControlInput } from '@/components/ControlInput'; +import { LLMMessage } from '@/types/llm'; + +import { messagesReducer } from './messageReducer'; + +export interface EditableMessageListProps { + /** + * @description The data sources to be rendered + */ + dataSources: LLMMessage[]; + /** + * @description Whether the component is disabled or not + * @default false + */ + disabled?: boolean; + /** + * @description Callback function triggered when the data sources are changed + * @param chatMessages - the updated data sources + */ + onChange?: (chatMessages: LLMMessage[]) => void; +} + +export const EditableMessageList = memo<EditableMessageListProps>( + ({ disabled, dataSources, onChange }) => { + const [chatMessages, dispatch] = useReducer(messagesReducer, dataSources); + + useEffect(() => { + if (!isEqual(dataSources, chatMessages)) { + onChange?.(chatMessages); + } + }, [chatMessages]); + + return dataSources ? ( + <Flexbox gap={12}> + {chatMessages.map((item, index) => ( + <Flexbox + align={'center'} + gap={8} + horizontal + key={`${index}-${item.content}`} + width={'100%'} + > + <Select + disabled={disabled} + dropdownStyle={{ zIndex: 100 }} + onChange={(value) => { + dispatch({ index, role: value, type: 'updateMessageRole' }); + }} + options={[ + { label: 'System', value: 'system' }, + { label: 'Input', value: 'user' }, + { label: 'Output', value: 'assistant' }, + ]} + style={{ width: 120 }} + value={item.role} + /> + <ControlInput + disabled={disabled} + onChange={(e) => { + dispatch({ index, message: e, type: 'updateMessage' }); + }} + placeholder={item.role === 'user' ? '请填入输入的样例内容' : '请填入输出的样例'} + value={item.content} + /> + <ActionIcon + icon={Trash} + onClick={() => { + dispatch({ index, type: 'deleteMessage' }); + }} + placement="right" + size={{ fontSize: 16 }} + title="Delete" + /> + </Flexbox> + ))} + + <Button + block + disabled={disabled} + icon={<Icon icon={Plus} />} + onClick={() => { + const lastMeg = chatMessages.at(-1); + + dispatch({ + message: { content: '', role: lastMeg?.role === 'user' ? 'assistant' : 'user' }, + type: 'addMessage', + }); + }} + > + Add Props + </Button> + </Flexbox> + ) : undefined; + }, + isEqual, +); + +export default EditableMessageList; diff --git a/src/EditableMessageList/messageReducer.ts b/src/EditableMessageList/messageReducer.ts new file mode 100644 index 00000000..c1ac2e89 --- /dev/null +++ b/src/EditableMessageList/messageReducer.ts @@ -0,0 +1,85 @@ +import { produce } from 'immer'; + +import { LLMMessage, LLMRoleType } from '@/types/llm'; +import { ChatMessageError } from '@/types/message'; + +export type MessageDispatch = + | { message: LLMMessage; type: 'addMessage' } + | { index: number; message: LLMMessage; type: 'insertMessage' } + | { index: number; type: 'deleteMessage' } + | { type: 'resetMessages' } + | { index: number; message: string; type: 'updateMessage' } + | { index: number; role: LLMRoleType; type: 'updateMessageRole' } + // + | { message: string; type: 'addUserMessage' } + | { responseStream: string[]; type: 'updateLatestBotMessage' } + | { index: number; message: string; type: 'updateMessageChoice' } + | { + error: ChatMessageError | undefined; + index: number; + type: 'setErrorMessage'; + }; + +export const messagesReducer = (state: LLMMessage[], payload: MessageDispatch): LLMMessage[] => { + switch (payload.type) { + case 'addMessage': { + return [...state, payload.message]; + } + + case 'insertMessage': { + return produce(state, (draftState) => { + draftState.splice(payload.index, 0, payload.message); + }); + } + + case 'deleteMessage': { + return state.filter((_, index) => index !== payload.index); + } + case 'resetMessages': { + return []; + } + + case 'updateMessage': { + return produce(state, (draftState) => { + const { index, message } = payload; + + draftState[index].content = message; + }); + } + case 'updateMessageRole': { + return produce(state, (draftState) => { + const { index, role } = payload; + + draftState[index].role = role; + }); + } + + case 'addUserMessage': { + return produce(state, (draftState) => { + draftState.push({ content: payload.message, role: 'user' }); + }); + } + + case 'updateLatestBotMessage': { + return produce(state, () => { + const { responseStream } = payload; + const newMessage = { content: responseStream.join(''), role: 'assistant' }; + + return [...state.slice(0, -1), newMessage]; + }); + } + + case 'setErrorMessage': { + return produce(state, (draftState) => { + const { index, error } = payload; + + // @ts-ignore + draftState[index].error = error; + }); + } + + default: { + throw new Error('暂未实现的 type,请检查 reducer'); + } + } +}; diff --git a/src/Emoji/index.tsx b/src/Emoji/index.tsx new file mode 100644 index 00000000..3d84e282 --- /dev/null +++ b/src/Emoji/index.tsx @@ -0,0 +1,32 @@ +import { memo } from 'react'; + +import { DivProps } from '../types'; + +import { useStyles } from './style'; + +export interface EmojiProps extends DivProps { + /** + * @description The emoji character to be rendered + */ + emoji: string; + /** + * @description Size of the emoji + * @default 40 + */ + size?: number; +} + +const Emoji = memo<EmojiProps>(({ emoji, className, style, size = 40 }) => { + const { cx, styles } = useStyles(); + + return ( + <div + className={cx(styles.container, className)} + style={{ fontSize: size * 0.9, height: size, width: size, ...style }} + > + {emoji} + </div> + ); +}); + +export default Emoji; diff --git a/src/Emoji/style.ts b/src/Emoji/style.ts new file mode 100644 index 00000000..f69517c6 --- /dev/null +++ b/src/Emoji/style.ts @@ -0,0 +1,29 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles(({ css, token }) => { + return { + container: css` + position: relative; + + display: flex; + align-items: center; + justify-content: center; + + line-height: 1; + text-align: center; + `, + loading: css` + position: absolute; + inset: 0; + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 100%; + + color: ${token.colorText}; + `, + }; +}); diff --git a/src/Icon/index.tsx b/src/Icon/index.tsx new file mode 100644 index 00000000..1412157d --- /dev/null +++ b/src/Icon/index.tsx @@ -0,0 +1,92 @@ +import { LucideIcon } from 'lucide-react'; +import { memo, useMemo } from 'react'; + +import { DivProps } from '@/types'; + +import { useStyles } from './style'; + +export type IconSize = + | 'large' + | 'normal' + | 'small' + | { + fontSize?: number; + strokeWidth?: number; + }; + +const calcSize = (size?: IconSize) => { + let fontSize: number | string; + let strokeWidth: number; + + switch (size) { + case 'large': { + fontSize = 24; + strokeWidth = 2; + break; + } + case 'normal': { + fontSize = 20; + strokeWidth = 2; + break; + } + case 'small': { + fontSize = 14; + strokeWidth = 1.5; + break; + } + default: { + if (size) { + fontSize = size?.fontSize || 24; + strokeWidth = size?.strokeWidth || 2; + } else { + fontSize = '1em'; + strokeWidth = 2; + } + break; + } + } + return { fontSize, strokeWidth }; +}; + +export interface IconProps extends DivProps { + color?: string; + fill?: string; + /** + * @description The icon element to be rendered + * @type LucideIcon + */ + icon: LucideIcon; + /** + * @description Size of the icon + * @default 'normal' + */ + size?: IconSize; + /** + * @description Rotate icon with animation + * @default false + */ + spin?: boolean; +} + +const Icon = memo<IconProps>(({ icon, size, color, fill, className, spin, ...props }) => { + const { styles, cx } = useStyles(); + const SvgIcon = icon; + + const { fontSize, strokeWidth } = useMemo(() => calcSize(size), [size]); + + return ( + <span className={cx('anticon', spin && styles.spin, className)} role="img" {...props}> + <SvgIcon + color={color} + fill={fill ?? 'transparent'} + focusable={false} + height={fontSize} + size={fontSize} + strokeWidth={strokeWidth} + width={fontSize} + /> + </span> + ); +}); + +export default Icon; diff --git a/src/Icon/style.ts b/src/Icon/style.ts new file mode 100644 index 00000000..0697cd39 --- /dev/null +++ b/src/Icon/style.ts @@ -0,0 +1,17 @@ +import { createStyles, keyframes } from 'antd-style'; + +export const useStyles = createStyles(({ css }) => { + const spin = keyframes` + 0% { + rotate: 0deg; + } + 100% { + rotate: 360deg; + } + `; + return { + spin: css` + animation: ${spin} 1s linear infinite; + `, + }; +}); diff --git a/src/List/ListItem/index.tsx b/src/List/ListItem/index.tsx new file mode 100644 index 00000000..92da1923 --- /dev/null +++ b/src/List/ListItem/index.tsx @@ -0,0 +1,160 @@ +import { LoadingOutlined, MessageOutlined } from '@ant-design/icons'; +import { CSSProperties, HTMLAttributes, ReactNode, forwardRef } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { useStyles } from './style'; +import { getChatItemTime } from './time'; + +/** + * 卡片列表项的属性 + */ +export interface ListItemProps { + /** + * 渲染操作区域的 React 节点 + */ + actions?: ReactNode; + /** + * 是否处于激活状态 + */ + active: boolean; + addon?: ReactNode; + /** + * 头像的 React 节点 + */ + avatar?: ReactNode; + /** + * 自定义样式类名 + */ + className?: string; + /** + * 自定义样式类名对象 + * @property time - 时间的样式类名 + */ + classNames?: { + time?: string; + }; + /** + * 日期时间戳 + */ + date?: number; + /** + * 描述信息 + */ + description?: ReactNode; + /** + * 是否处于加载状态 + */ + loading?: boolean; + /** + * 点击事件回调函数 + */ + onClick?: () => void; + /** + * 鼠标悬停状态变化事件回调函数 + * @param hover - 是否悬停 + */ + onHoverChange?: (hover: boolean) => void; + + pin?: boolean; + /** + * 是否显示操作区域 + */ + showAction?: boolean; + /** + * 自定义样式对象 + */ + style?: CSSProperties; + /** + * 标题 + */ + title: ReactNode; +} + +const ListItem = forwardRef<HTMLDivElement, ListItemProps & HTMLAttributes<any>>( + ( + { + active, + avatar, + loading, + description, + date, + title, + onHoverChange, + actions, + className, + style, + showAction, + children, + classNames, + addon, + pin, + ...props + }, + ref, + ) => { + const { styles, cx } = useStyles(); + + return ( + <Flexbox + align={'flex-start'} + className={cx(styles.container, active && styles.active, pin && styles.pin, className)} + distribution={'space-between'} + gap={8} + horizontal + onMouseEnter={() => { + onHoverChange?.(true); + }} + onMouseLeave={() => { + onHoverChange?.(false); + }} + paddingBlock={12} + paddingInline={16} + ref={ref} + style={style} + {...props} + > + {avatar ?? <MessageOutlined style={{ marginTop: 4 }} />} + + <Flexbox className={styles.content} gap={8}> + <Flexbox distribution={'space-between'} horizontal> + <div className={styles.title}>{title}</div> + </Flexbox> + {description && <div className={styles.desc}>{description}</div>} + {addon} + </Flexbox> + + {loading ? ( + <LoadingOutlined spin={true} /> + ) : ( + <> + {showAction && ( + <Flexbox + className={styles.actions} + gap={4} + horizontal + onClick={(e: any) => { + e.preventDefault(); + e.stopPropagation(); + }} + style={{ display: showAction ? undefined : 'none' }} + > + {actions} + </Flexbox> + )} + {date && ( + <div + className={cx(styles.time, classNames?.time)} + style={showAction ? { opacity: 0 } : {}} + > + {getChatItemTime(date)} + </div> + )} + </> + )} + {children} + </Flexbox> + ); + }, +); + +export default ListItem; diff --git a/src/List/ListItem/style.ts b/src/List/ListItem/style.ts new file mode 100644 index 00000000..d379f224 --- /dev/null +++ b/src/List/ListItem/style.ts @@ -0,0 +1,79 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles(({ css, token }) => { + return { + actions: css` + position: absolute; + top: 50%; + right: 16px; + transform: translateY(-50%); + `, + active: css` + color: ${token.colorText}; + background-color: ${token.colorFillSecondary}; + + &:hover { + background-color: ${token.colorFill}; + } + `, + container: css` + cursor: pointer; + color: ${token.colorTextTertiary}; + background: transparent; + transition: background-color 200ms ${token.motionEaseOut}; + + &:active { + background-color: ${token.colorFillSecondary}; + } + + &:hover { + background-color: ${token.colorFillTertiary}; + } + `, + content: css` + position: relative; + overflow: hidden; + flex: 1; + align-self: center; + `, + desc: css` + overflow: hidden; + + width: 100%; + + font-size: 12px; + line-height: 1; + color: ${token.colorTextDescription}; + text-overflow: ellipsis; + white-space: nowrap; + `, + + pin: css` + background-color: ${token.colorFillTertiary}; + + &:active { + background-color: ${token.colorFill} !important; + } + + &:hover { + background-color: ${token.colorFill}; + } + `, + + time: css` + font-size: 12px; + color: ${token.colorTextPlaceholder}; + `, + title: css` + overflow: hidden; + + width: 100%; + + font-size: 16px; + line-height: 1; + color: ${token.colorText}; + text-overflow: ellipsis; + white-space: nowrap; + `, + }; +}); diff --git a/src/List/ListItem/time.ts b/src/List/ListItem/time.ts new file mode 100644 index 00000000..5234bbc4 --- /dev/null +++ b/src/List/ListItem/time.ts @@ -0,0 +1,15 @@ +import dayjs from 'dayjs'; +import 'dayjs/locale/zh-cn'; + +dayjs.locale('zh-cn'); + +export const getChatItemTime = (updateAt: number) => { + const time = dayjs(updateAt); + const diff = dayjs().day() - time.day(); + + if (time.isSame(dayjs(), 'day')) return time.format('HH:mm'); + + if (diff === 1) return '昨天'; + + return time.format('MM-DD'); +}; diff --git a/src/List/index.ts b/src/List/index.ts new file mode 100644 index 00000000..57c950de --- /dev/null +++ b/src/List/index.ts @@ -0,0 +1,7 @@ +import ListItem from './ListItem'; + +const List = { + Item: ListItem, +}; + +export default List; diff --git a/src/MessageInput/demos/index.tsx b/src/MessageInput/demos/index.tsx new file mode 100644 index 00000000..b0a2924a --- /dev/null +++ b/src/MessageInput/demos/index.tsx @@ -0,0 +1,6 @@ +import { content } from '@/EditableMessage/demos'; +import { MessageInput } from '@ant-design/pro-chat'; + +export default () => { + return <MessageInput defaultValue={content} height={200} style={{ width: '100%' }} />; +}; diff --git a/src/MessageInput/index.md b/src/MessageInput/index.md new file mode 100644 index 00000000..b9bf04ca --- /dev/null +++ b/src/MessageInput/index.md @@ -0,0 +1,14 @@ +--- +nav: 组件 +group: Message +title: MessageInput +description: CopyButton is a React component used to copy text content to the clipboard. It provides a button with a copy icon that, when clicked, copies the specified content to the user's clipboard. It also displays a tooltip indicating whether the copy action was successful or not. +--- + +## Default + +<code src="./demos/index.tsx" center></code> + +## APIs + +<API></API> diff --git a/src/MessageInput/index.tsx b/src/MessageInput/index.tsx new file mode 100644 index 00000000..588b9fc5 --- /dev/null +++ b/src/MessageInput/index.tsx @@ -0,0 +1,114 @@ +import { Button, ButtonProps } from 'antd'; +import { memo, useState, type CSSProperties } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { TextArea, type TextAreaProps } from '@/components/Input'; +import { DivProps } from '@/types'; + +import { useStyles } from './style'; + +export interface MessageInputProps extends DivProps { + /** + * @description Additional className to apply to the component. + */ + className?: string; + classNames?: TextAreaProps['classNames']; + /** + * @description The default value of the input box. + */ + defaultValue?: string; + editButtonSize?: ButtonProps['size']; + height?: number | 'auto' | string; + /** + * @description Callback function triggered when user clicks on the cancel button. + */ + onCancel?: () => void; + + /** + * @description Callback function triggered when user clicks on the confirm button. + * @param text - The text input by the user. + */ + onConfirm?: (text: string) => void; + /** + * @description Custom rendering of the bottom buttons. + * @param text - The text input by the user. + */ + renderButtons?: (text: string) => ButtonProps[]; + text?: { + cancel?: string; + confirm?: string; + }; + textareaClassname?: string; + textareaStyle?: CSSProperties; + /** + * @description The type of the input box. + */ + type?: TextAreaProps['type']; +} + +const MessageInput = memo<MessageInputProps>( + ({ + text, + type = 'pure', + onCancel, + defaultValue, + onConfirm, + renderButtons, + textareaStyle, + textareaClassname, + placeholder = 'Type something...', + + height = 'auto', + + style, + editButtonSize = 'middle', + classNames, + ...props + }) => { + const [temporarySystemRole, setRole] = useState<string>(defaultValue || ''); + const { cx, styles } = useStyles(); + + const isAutoSize = height === 'auto'; + + return ( + <Flexbox gap={16} style={{ flex: 1, width: '100%', ...style }} {...props}> + <TextArea + autoSize={isAutoSize} + className={cx(styles, textareaClassname)} + classNames={classNames} + onBlur={(e) => setRole(e.target.value)} + onChange={(e) => setRole(e.target.value)} + placeholder={placeholder} + resize={false} + style={{ height: isAutoSize ? 'unset' : height, minHeight: '100%', ...textareaStyle }} + type={type} + value={temporarySystemRole} + /> + <Flexbox direction={'horizontal-reverse'} gap={8}> + {renderButtons ? ( + renderButtons(temporarySystemRole).map((buttonProps, index) => ( + <Button key={index} size="small" {...buttonProps} /> + )) + ) : ( + <> + <Button + onClick={() => { + onConfirm?.(temporarySystemRole); + }} + size={editButtonSize} + type="primary" + > + {text?.confirm || 'Confirm'} + </Button> + <Button onClick={onCancel} size={editButtonSize}> + {text?.cancel || 'Cancel'} + </Button> + </> + )} + </Flexbox> + </Flexbox> + ); + }, +); + +export default MessageInput; diff --git a/src/MessageInput/style.ts b/src/MessageInput/style.ts new file mode 100644 index 00000000..06f557aa --- /dev/null +++ b/src/MessageInput/style.ts @@ -0,0 +1,13 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles( + ({ css, token }) => css` + position: relative; + + height: 100%; + + font-family: ${token.fontFamilyCode}; + font-size: 13px; + line-height: 1.8; + `, +); diff --git a/src/MessageModal/demos/index.tsx b/src/MessageModal/demos/index.tsx new file mode 100644 index 00000000..6a72407c --- /dev/null +++ b/src/MessageModal/demos/index.tsx @@ -0,0 +1,20 @@ +import { MessageModal } from '@ant-design/pro-chat'; +import { Button } from 'antd'; +import { useState } from 'react'; + +export default () => { + const [open, setOpen] = useState(false); + + return ( + <> + <Button + onClick={() => { + setOpen(true); + }} + > + open + </Button> + <MessageModal onOpenChange={setOpen} open={open} value={'editable text'} /> + </> + ); +}; diff --git a/src/MessageModal/index.md b/src/MessageModal/index.md new file mode 100644 index 00000000..73eb8e12 --- /dev/null +++ b/src/MessageModal/index.md @@ -0,0 +1,14 @@ +--- +nav: 组件 +group: Message +title: MessageModal +description: The MessageModal component is a modal window that can display either a message in Markdown format or a message input field for editing the message. +--- + +## Default + +<code src="./demos/index.tsx" center></code> + +## APIs + +<API></API> diff --git a/src/MessageModal/index.tsx b/src/MessageModal/index.tsx new file mode 100644 index 00000000..3e39a688 --- /dev/null +++ b/src/MessageModal/index.tsx @@ -0,0 +1,119 @@ +import { useResponsive } from 'antd-style'; +import { CSSProperties, ReactNode, memo } from 'react'; +import useControlledState from 'use-merge-value'; + +import MessageInput, { type MessageInputProps } from '@/MessageInput'; +import { Markdown } from '@ant-design/pro-editor'; +import { Modal, type ModalProps } from 'antd'; + +export interface MessageModalProps extends Pick<ModalProps, 'open' | 'footer'> { + /** + * @description Whether the message is being edited or not + * @default false + */ + editing?: boolean; + extra?: ReactNode; + height?: MessageInputProps['height']; + /** + * @description Callback fired when message content is changed + */ + onChange?: (text: string) => void; + /** + * @description Callback fired when editing state is changed + */ + onEditingChange?: (editing: boolean) => void; + /** + * @description Callback fired when open state is changed + */ + onOpenChange?: (open: boolean) => void; + /** + * @description Whether the modal is open or not + * @default false + */ + placeholder?: string; + text?: { + cancel?: string; + confirm?: string; + edit?: string; + title?: string; + }; + /** + * @description The value of the message content + */ + value: string; +} + +const MessageModal = memo<MessageModalProps>( + ({ + editing, + open, + height = 'auto', + onOpenChange, + onEditingChange, + placeholder, + value, + onChange, + text, + footer, + extra, + }) => { + const { mobile } = useResponsive(); + + const [isEdit, setTyping] = useControlledState(false, { + onChange: onEditingChange, + value: editing, + }); + + const [expand, setExpand] = useControlledState(false, { + onChange: onOpenChange, + value: open, + }); + + const isAutoSize = height === 'auto'; + const markdownStyle: CSSProperties = { + height: isAutoSize ? 'unset' : height, + overflowX: 'hidden', + overflowY: 'auto', + }; + + return ( + <Modal + cancelText={text?.cancel || 'Cancel'} + footer={isEdit ? null : footer} + okText={text?.edit || 'Edit'} + onCancel={() => setExpand(false)} + onOk={() => setTyping(true)} + open={expand} + styles={mobile ? { body: { padding: 16 } } : {}} + title={text?.title || 'Prompt'} + > + {isEdit ? ( + <MessageInput + defaultValue={value} + height={height} + onCancel={() => setTyping(false)} + onConfirm={(text) => { + setTyping(false); + onChange?.(text); + }} + placeholder={placeholder} + text={{ + cancel: text?.cancel, + confirm: text?.confirm, + }} + type={'block'} + /> + ) : ( + <> + {extra} + <Markdown style={value ? markdownStyle : { ...markdownStyle, opacity: 0.5 }}> + {String(value || placeholder)} + </Markdown> + </> + )} + </Modal> + ); + }, +); + +export default MessageModal; diff --git a/src/ProChat/components/ChatList/Actions/Assistant.tsx b/src/ProChat/components/ChatList/Actions/Assistant.tsx new file mode 100644 index 00000000..70901ab6 --- /dev/null +++ b/src/ProChat/components/ChatList/Actions/Assistant.tsx @@ -0,0 +1,31 @@ +import ActionIconGroup from '@/ActionIconGroup'; +import { RenderAction } from '@/ChatList'; +import { useChatListActionsBar } from '@/hooks/useChatListActionsBar'; +import { memo } from 'react'; + +import { ErrorActionsBar } from './Error'; + +export const AssistantActionsBar: RenderAction = memo(({ text, id, onActionClick, error }) => { + const { regenerate, edit, copy, divider, del } = useChatListActionsBar(text); + + if (id === 'default') return; + + if (error) return <ErrorActionsBar onActionClick={onActionClick} text={text} />; + + return ( + <ActionIconGroup + dropdownMenu={[ + edit, + copy, + regenerate, + // divider, + // TODO: need a translate + divider, + del, + ]} + items={[regenerate, copy]} + onActionClick={onActionClick} + type="ghost" + /> + ); +}); diff --git a/src/ProChat/components/ChatList/Actions/Error.tsx b/src/ProChat/components/ChatList/Actions/Error.tsx new file mode 100644 index 00000000..d51c37bf --- /dev/null +++ b/src/ProChat/components/ChatList/Actions/Error.tsx @@ -0,0 +1,10 @@ +import ActionIconGroup from '@/ActionIconGroup'; +import { ActionsBarProps } from '@/ChatList/ActionsBar'; +import { useChatListActionsBar } from '@/hooks/useChatListActionsBar'; +import { memo } from 'react'; + +export const ErrorActionsBar = memo<ActionsBarProps>(({ text, onActionClick }) => { + const { regenerate, del } = useChatListActionsBar(text); + + return <ActionIconGroup items={[regenerate, del]} onActionClick={onActionClick} type="ghost" />; +}); diff --git a/src/ProChat/components/ChatList/Actions/Fallback.tsx b/src/ProChat/components/ChatList/Actions/Fallback.tsx new file mode 100644 index 00000000..98e66f64 --- /dev/null +++ b/src/ProChat/components/ChatList/Actions/Fallback.tsx @@ -0,0 +1,11 @@ +import ActionIconGroup from '@/ActionIconGroup'; +import { RenderAction } from '@/ChatList'; +import { useChatListActionsBar } from '@/hooks/useChatListActionsBar'; +import { memo } from 'react'; + +export const DefaultActionsBar: RenderAction = memo(({ text, onActionClick }) => { + const { del } = useChatListActionsBar(text); + return ( + <ActionIconGroup dropdownMenu={[del]} items={[]} onActionClick={onActionClick} type="ghost" /> + ); +}); diff --git a/src/ProChat/components/ChatList/Actions/Function.tsx b/src/ProChat/components/ChatList/Actions/Function.tsx new file mode 100644 index 00000000..98d3fbbf --- /dev/null +++ b/src/ProChat/components/ChatList/Actions/Function.tsx @@ -0,0 +1,16 @@ +import ActionIconGroup from '@/ActionIconGroup'; +import { RenderAction } from '@/ChatList'; +import { useChatListActionsBar } from '@/hooks/useChatListActionsBar'; +import { memo } from 'react'; + +export const FunctionActionsBar: RenderAction = memo(({ text, onActionClick }) => { + const { regenerate, divider, del } = useChatListActionsBar(text); + return ( + <ActionIconGroup + dropdownMenu={[regenerate, divider, del]} + items={[regenerate]} + onActionClick={onActionClick} + type="ghost" + /> + ); +}); diff --git a/src/ProChat/components/ChatList/Actions/User.tsx b/src/ProChat/components/ChatList/Actions/User.tsx new file mode 100644 index 00000000..b0b191c4 --- /dev/null +++ b/src/ProChat/components/ChatList/Actions/User.tsx @@ -0,0 +1,25 @@ +import ActionIconGroup from '@/ActionIconGroup'; +import { RenderAction } from '@/ChatList'; +import { useChatListActionsBar } from '@/hooks/useChatListActionsBar'; +import { memo } from 'react'; + +export const UserActionsBar: RenderAction = memo(({ text, onActionClick }) => { + const { regenerate, edit, copy, divider, del } = useChatListActionsBar(text); + + return ( + <ActionIconGroup + dropdownMenu={[ + edit, + copy, + regenerate, + // divider, + // TODO: need a translate + divider, + del, + ]} + items={[regenerate, edit]} + onActionClick={onActionClick} + type="ghost" + /> + ); +}); diff --git a/src/ProChat/components/ChatList/Actions/index.ts b/src/ProChat/components/ChatList/Actions/index.ts new file mode 100644 index 00000000..23d2c026 --- /dev/null +++ b/src/ProChat/components/ChatList/Actions/index.ts @@ -0,0 +1,13 @@ +import { ChatListProps } from '@/ChatList'; + +import { AssistantActionsBar } from './Assistant'; +import { DefaultActionsBar } from './Fallback'; +import { FunctionActionsBar } from './Function'; +import { UserActionsBar } from './User'; + +export const renderActions: ChatListProps['renderActions'] = { + assistant: AssistantActionsBar, + function: FunctionActionsBar, + system: DefaultActionsBar, + user: UserActionsBar, +}; diff --git a/src/ProChat/components/ChatList/Extras/Assistant.tsx b/src/ProChat/components/ChatList/Extras/Assistant.tsx new file mode 100644 index 00000000..98583db2 --- /dev/null +++ b/src/ProChat/components/ChatList/Extras/Assistant.tsx @@ -0,0 +1,34 @@ +import Tag from '@/components/Tag'; +import { RenderMessageExtra } from '@/index'; + +import { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { useStore } from '@/ProChat/store'; + +export const AssistantMessageExtra: RenderMessageExtra = memo(({ extra, ...rest }) => { + const [model, messageItemExtraRender] = useStore((s) => [ + s.config.model, + s.messageItemExtraRender, + ]); + + const showModelTag = extra?.fromModel && model !== extra?.fromModel; + const hasTranslate = !!extra?.translate; + + const showExtra = showModelTag || hasTranslate; + + const dom = messageItemExtraRender?.(rest, 'assistant'); + if (!showExtra && !dom) return; + + return ( + <Flexbox gap={8} style={{ marginTop: 8 }}> + {showModelTag && ( + <div> + {/*TODO: need a model icons */} + <Tag>{extra?.fromModel as string}</Tag> + </div> + )} + {dom} + </Flexbox> + ); +}); diff --git a/src/ProChat/components/ChatList/Extras/User.tsx b/src/ProChat/components/ChatList/Extras/User.tsx new file mode 100644 index 00000000..ad6c6978 --- /dev/null +++ b/src/ProChat/components/ChatList/Extras/User.tsx @@ -0,0 +1,22 @@ +import { RenderMessageExtra } from '@/index'; +import { Divider } from 'antd'; +import { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { useStore } from '@/ProChat/store'; + +export const UserMessageExtra: RenderMessageExtra = memo(({ extra, ...rest }) => { + const hasTranslate = !!extra?.translate; + + const [messageItemExtraRender] = useStore((s) => [s.messageItemExtraRender]); + return ( + <Flexbox gap={8} style={{ marginTop: hasTranslate ? 8 : 0 }}> + {extra?.translate && ( + <div> + <Divider style={{ margin: '12px 0' }} /> + </div> + )} + {messageItemExtraRender?.(rest, 'user')} + </Flexbox> + ); +}); diff --git a/src/ProChat/components/ChatList/Extras/index.ts b/src/ProChat/components/ChatList/Extras/index.ts new file mode 100644 index 00000000..2bb03970 --- /dev/null +++ b/src/ProChat/components/ChatList/Extras/index.ts @@ -0,0 +1,9 @@ +import { ChatListProps } from '@/ChatList'; + +import { AssistantMessageExtra } from './Assistant'; +import { UserMessageExtra } from './User'; + +export const renderMessagesExtra: ChatListProps['renderMessagesExtra'] = { + assistant: AssistantMessageExtra, + user: UserMessageExtra, +}; diff --git a/src/ProChat/components/ChatList/Loading.tsx b/src/ProChat/components/ChatList/Loading.tsx new file mode 100644 index 00000000..adbc08b2 --- /dev/null +++ b/src/ProChat/components/ChatList/Loading.tsx @@ -0,0 +1,53 @@ +import { useTheme } from 'antd-style'; + +const Svg = () => ( + <svg viewBox="0 0 32 24" xmlns="http://www.w3.org/2000/svg"> + <circle cx="0" cy="12" r="0" transform="translate(8 0)"> + <animate + attributeName="r" + begin="0" + calcMode="spline" + dur="1.2s" + keySplines="0.2 0.2 0.4 0.8;0.2 0.6 0.4 0.8;0.2 0.6 0.4 0.8" + keyTimes="0;0.2;0.7;1" + repeatCount="indefinite" + values="0; 4; 0; 0" + /> + </circle> + <circle cx="0" cy="12" r="0" transform="translate(16 0)"> + <animate + attributeName="r" + begin="0.3" + calcMode="spline" + dur="1.2s" + keySplines="0.2 0.2 0.4 0.8;0.2 0.6 0.4 0.8;0.2 0.6 0.4 0.8" + keyTimes="0;0.2;0.7;1" + repeatCount="indefinite" + values="0; 4; 0; 0" + /> + </circle> + <circle cx="0" cy="12" r="0" transform="translate(24 0)"> + <animate + attributeName="r" + begin="0.6" + calcMode="spline" + dur="1.2s" + keySplines="0.2 0.2 0.4 0.8;0.2 0.6 0.4 0.8;0.2 0.6 0.4 0.8" + keyTimes="0;0.2;0.7;1" + repeatCount="indefinite" + values="0; 4; 0; 0" + /> + </circle> + </svg> +); + +const BubblesLoading = () => { + const { colorTextTertiary } = useTheme(); + return ( + <div style={{ fill: colorTextTertiary, height: 24, width: 32 }}> + <Svg /> + </div> + ); +}; + +export default BubblesLoading; diff --git a/src/ProChat/components/ChatList/Messages/Assistant.tsx b/src/ProChat/components/ChatList/Messages/Assistant.tsx new file mode 100644 index 00000000..44460804 --- /dev/null +++ b/src/ProChat/components/ChatList/Messages/Assistant.tsx @@ -0,0 +1,9 @@ +import { RenderMessage } from '@/ChatList'; +import { memo } from 'react'; + +import { DefaultMessage } from './Default'; + +export const AssistantMessage: RenderMessage = memo(({ id, content, ...props }) => { + // todo: need a custom render + return <DefaultMessage content={content} id={id} {...props} />; +}); diff --git a/src/ProChat/components/ChatList/Messages/Default.tsx b/src/ProChat/components/ChatList/Messages/Default.tsx new file mode 100644 index 00000000..7b747363 --- /dev/null +++ b/src/ProChat/components/ChatList/Messages/Default.tsx @@ -0,0 +1,12 @@ +import { RenderMessage } from '@/ChatList'; +import { memo } from 'react'; + +import { LOADING_FLAT } from '@/ProChat/const/message'; + +import BubblesLoading from '../Loading'; + +export const DefaultMessage: RenderMessage = memo(({ id, editableContent, content }) => { + if (content === LOADING_FLAT) return <BubblesLoading />; + + return <div id={id}>{editableContent}</div>; +}); diff --git a/src/ProChat/components/ChatList/Messages/index.ts b/src/ProChat/components/ChatList/Messages/index.ts new file mode 100644 index 00000000..c24d4d61 --- /dev/null +++ b/src/ProChat/components/ChatList/Messages/index.ts @@ -0,0 +1,9 @@ +import { ChatListProps } from '@/ChatList'; + +import { AssistantMessage } from './Assistant'; +import { DefaultMessage } from './Default'; + +export const renderMessages: ChatListProps['renderMessages'] = { + assistant: AssistantMessage, + default: DefaultMessage, +}; diff --git a/src/ProChat/components/ChatList/OTPInput.tsx b/src/ProChat/components/ChatList/OTPInput.tsx new file mode 100644 index 00000000..e2e2b7f4 --- /dev/null +++ b/src/ProChat/components/ChatList/OTPInput.tsx @@ -0,0 +1,136 @@ +import { useControllableValue } from 'ahooks'; +import { createStyles } from 'antd-style'; +import React, { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +const useStyles = createStyles( + ({ css, token }) => css` + width: ${token.controlHeight}px; + height: ${token.controlHeight}px; + + font-size: 16px; + color: ${token.colorText}; + text-align: center; + + background: ${token.colorBgContainer}; + border: 1px solid ${token.colorBorder}; + border-radius: 8px; + + &:focus, + &:focus-visible { + border-color: ${token.colorPrimary}; + outline: none; + } + `, +); + +/** + * Let's borrow some props from HTML "input". More info below: + * + * [Pick Documentation](https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys) + * + * [How to extend HTML Elements](https://reacthustle.com/blog/how-to-extend-html-elements-in-react-typescript) + */ +type PartialInputProps = Pick<React.ComponentPropsWithoutRef<'input'>, 'className' | 'style'>; + +interface OtpInputProps extends PartialInputProps { + onChange?: (value: string) => void; + /** + * Number of characters/input for this component + */ + size?: number; + /** + * Validation pattern for each input. + * e.g: /[0-9]{1}/ for digits only or /[0-9a-zA-Z]{1}/ for alphanumeric + */ + validationPattern?: RegExp; + /** + * full value of the otp input, up to {size} characters + */ + value?: string; +} + +const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => { + const current = e.currentTarget; + if (e.key === 'ArrowLeft' || e.key === 'Backspace') { + const prev = current.previousElementSibling as HTMLInputElement | null; + prev?.focus(); + prev?.setSelectionRange(0, 1); + return; + } + + if (e.key === 'ArrowRight') { + const prev = current.nextSibling as HTMLInputElement | null; + prev?.focus(); + prev?.setSelectionRange(0, 1); + return; + } +}; + +const OtpInput = memo<OtpInputProps>((props) => { + const { + //Set the default size to 6 characters + size = 6, + //Default validation is digits + validationPattern = /\d/, + value: outerValue, + onChange, + className, + ...restProps + } = props; + const [value, setValue] = useControllableValue({ onChange, value: outerValue }); + + const { styles, cx } = useStyles(); + // Create an array based on the size. + const arr = Array.from({ length: size }).fill('-'); + + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => { + const elem = e.target; + const val = e.target.value; + + // check if the value is valid + if (!validationPattern.test(val) && val !== '') return; + + // change the value using onChange props + const valueArr = value?.split('') || []; + valueArr[index] = val; + const newVal = valueArr.join('').slice(0, 6); + setValue(newVal); + + //focus the next element if there's a value + if (val) { + const next = elem.nextElementSibling as HTMLInputElement | null; + next?.focus(); + } + }; + + const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => { + e.preventDefault(); + const val = e.clipboardData.getData('text').slice(0, Math.max(0, size)); + + setValue(val); + }; + + return ( + <Flexbox gap={12} horizontal> + {arr.map((_, index) => { + return ( + <input + key={index} + {...restProps} + className={cx(styles, className)} + maxLength={6} + onChange={(e) => handleInputChange(e, index)} + onKeyUp={handleKeyUp} + onPaste={handlePaste} + pattern={validationPattern.source} + type="text" + value={value?.at(index) ?? ''} + /> + ); + })} + </Flexbox> + ); +}); + +export default OtpInput; diff --git a/src/ProChat/components/ChatList/SkeletonList.tsx b/src/ProChat/components/ChatList/SkeletonList.tsx new file mode 100644 index 00000000..2198da5a --- /dev/null +++ b/src/ProChat/components/ChatList/SkeletonList.tsx @@ -0,0 +1,35 @@ +import { Skeleton } from 'antd'; +import { createStyles } from 'antd-style'; +import { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +const useStyles = createStyles(({ css, prefixCls }) => ({ + user: css` + display: flex; + flex-direction: row-reverse; + gap: 16px; + + .${prefixCls}-skeleton-paragraph { + display: flex; + flex-direction: column; + align-items: flex-end; + } + `, +})); +const SkeletonList = memo(() => { + const { styles } = useStyles(); + + return ( + <Flexbox gap={24} padding={12} style={{ marginTop: 24 }}> + <Skeleton + active + avatar={{ size: 40 }} + className={styles.user} + paragraph={{ width: ['50%', '30%'] }} + title={false} + /> + <Skeleton active avatar={{ size: 40 }} paragraph={{ width: ['50%', '30%'] }} title={false} /> + </Flexbox> + ); +}); +export default SkeletonList; diff --git a/src/ProChat/components/ChatList/index.tsx b/src/ProChat/components/ChatList/index.tsx new file mode 100644 index 00000000..eda96780 --- /dev/null +++ b/src/ProChat/components/ChatList/index.tsx @@ -0,0 +1,93 @@ +import ChatList from '@/ChatList'; +import isEqual from 'fast-deep-equal'; +import { memo } from 'react'; + +import { useStore } from '@/ProChat/store'; +import { chatSelectors } from '@/ProChat/store/selectors'; + +import { renderActions } from './Actions'; +import { renderMessagesExtra } from './Extras'; +import { renderMessages } from './Messages'; +import SkeletonList from './SkeletonList'; + +interface ListProps { + showTitle?: boolean; +} +const List = memo<ListProps>(({ showTitle }) => { + const data = useStore(chatSelectors.currentChatsWithGuideMessage, isEqual); + + const [ + init, + displayMode, + enableHistoryCount, + historyCount, + chatLoadingId, + deleteMessage, + resendMessage, + dispatchMessage, + ] = useStore((s) => { + const config = s.config; + + return [ + s.init, + s.displayMode, + config.enableHistoryCount, + config.historyCount, + s.chatLoadingId, + s.deleteMessage, + s.resendMessage, + s.dispatchMessage, + ]; + }); + + if (!init) return <SkeletonList />; + + return ( + <ChatList + showTitle={showTitle} + // @ts-ignore + data={data} + enableHistoryCount={enableHistoryCount} + historyCount={historyCount} + loadingId={chatLoadingId} + onActionsClick={(action, { id, error }) => { + switch (action.key) { + case 'del': { + deleteMessage(id); + break; + } + case 'regenerate': { + resendMessage(id); + + // if this message is an error message, we need to delete it + if (error) deleteMessage(id); + break; + } + } + + // TODO: need a custom callback + }} + onMessageChange={(id, content) => + dispatchMessage({ id, key: 'content', type: 'updateMessage', value: content }) + } + renderActions={renderActions} + // need support custom Render + renderMessages={renderMessages} + renderMessagesExtra={renderMessagesExtra} + style={{ marginTop: 24 }} + text={{ + cancel: '取消', + confirm: '确认', + copy: '复制', + copySuccess: '复制成功', + delete: '删除', + edit: '编辑', + history: '历史范围', + regenerate: '重新生成', + }} + type={displayMode || 'chat'} + /> + ); +}); + +export default List; diff --git a/src/ProChat/components/InputArea/ActionBar.tsx b/src/ProChat/components/InputArea/ActionBar.tsx new file mode 100644 index 00000000..22969976 --- /dev/null +++ b/src/ProChat/components/InputArea/ActionBar.tsx @@ -0,0 +1,46 @@ +import { createStyles } from 'antd-style'; +import { memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import ActionIcon from '@/ActionIcon'; +import { ConfigProvider, Popconfirm } from 'antd'; +import { Trash2 } from 'lucide-react'; + +import { useStore } from '../../store'; + +const useStyles = createStyles(({ css, token }) => ({ + extra: css` + color: ${token.colorTextTertiary}; + `, +})); + +export const ActionBar = () => { + const [clearMessage] = useStore((s) => [s.clearMessage]); + + const { styles, theme } = useStyles(); + + return ( + <ConfigProvider theme={{ token: { colorText: theme.colorTextSecondary } }}> + <Flexbox + align={'center'} + direction={'horizontal-reverse'} + paddingInline={12} + className={styles.extra} + gap={8} + > + <Popconfirm + title={'你即将要清空会话,清空后将无法找回。是否清空当前会话?'} + okButtonProps={{ danger: true }} + okText={'清空会话'} + onConfirm={() => { + clearMessage(); + }} + > + <ActionIcon title={'清空当前会话'} icon={Trash2} /> + </Popconfirm> + </Flexbox> + </ConfigProvider> + ); +}; + +export default memo(ActionBar); diff --git a/src/ProChat/components/InputArea/AutoCompleteTextArea.tsx b/src/ProChat/components/InputArea/AutoCompleteTextArea.tsx new file mode 100644 index 00000000..dd19d7b4 --- /dev/null +++ b/src/ProChat/components/InputArea/AutoCompleteTextArea.tsx @@ -0,0 +1,46 @@ +import { AutoComplete, Input } from 'antd'; +import { TextAreaProps } from 'antd/es/input'; +import { useState } from 'react'; +import { useStore } from '../../store'; + +export const AutoCompleteTextArea: React.FC<TextAreaProps> = (props) => { + const [autocompleteRequest] = useStore((s) => [s.autocompleteRequest]); + + const [options, setOptions] = useState<{ value: string; label: string }[]>([]); + const [open, setOpen] = useState(false); + return ( + <AutoComplete + className={props.className} + options={options} + size="large" + style={{ + height: 'max-content', + }} + open={open} + onDropdownVisibleChange={(open) => { + setOpen(open); + }} + value={props.value} + onSelect={(value) => { + props.onChange?.({ target: { value } } as any); + setOptions([]); + }} + onSearch={async (value) => { + const result = await autocompleteRequest?.(value); + setOptions((result as any[]) || []); + }} + > + <Input.TextArea + {...props} + onFocus={(e) => { + setOpen(false); + props.onFocus?.(e); + }} + onPressEnter={(e) => { + if (open && options.length > 0) return; + props.onPressEnter?.(e); + }} + /> + </AutoComplete> + ); +}; diff --git a/src/ProChat/components/InputArea/index.tsx b/src/ProChat/components/InputArea/index.tsx new file mode 100644 index 00000000..e32c27ed --- /dev/null +++ b/src/ProChat/components/InputArea/index.tsx @@ -0,0 +1,122 @@ +import { SendOutlined } from '@ant-design/icons'; +import { Button, ConfigProvider } from 'antd'; +import { createStyles, useResponsive } from 'antd-style'; +import { memo, useRef, useState } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { useStore } from '../../store'; + +import ActionBar from './ActionBar'; +import { AutoCompleteTextArea } from './AutoCompleteTextArea'; + +const useStyles = createStyles(({ css, responsive, token }) => ({ + container: css` + position: sticky; + z-index: ${token.zIndexPopupBase}; + bottom: 0; + + padding-top: 12px; + padding-bottom: 24px; + + background-image: linear-gradient(to top, ${token.colorBgLayout} 88%, transparent 100%); + + ${responsive.mobile} { + width: 100%; + } + `, + boxShadow: css` + position: relative; + border-radius: 8px; + box-shadow: ${token.boxShadowSecondary}; + `, + input: css` + width: 100%; + border: none; + outline: none; + border-radius: 8px; + `, + btn: css` + position: absolute; + z-index: 10; + right: 8px; + bottom: 6px; + + color: ${token.colorTextTertiary}; + &:hover { + color: ${token.colorTextSecondary}; + } + `, + extra: css` + color: ${token.colorTextTertiary}; + `, +})); + +export const InputArea = ({}) => { + const [sendMessage, isLoading, placeholder] = useStore((s) => [ + s.sendMessage, + !!s.chatLoadingId, + s.placeholder, + ]); + const [message, setMessage] = useState(''); + const isChineseInput = useRef(false); + const { styles, theme } = useStyles(); + const { mobile } = useResponsive(); + + const send = () => { + sendMessage(message); + setMessage(''); + }; + + return ( + <ConfigProvider + theme={{ + token: { + borderRadius: 4, + colorBgContainer: theme.colorBgElevated, + controlHeightLG: 48, + colorBorder: 'transparent', + colorPrimaryHover: 'transparent', + }, + }} + > + <Flexbox gap={8} padding={16} className={styles.container}> + <ActionBar /> + <Flexbox horizontal gap={8} align={'center'} className={styles.boxShadow}> + <AutoCompleteTextArea + size={'large'} + value={message} + placeholder={placeholder || '请输入内容...'} + onChange={(e) => { + setMessage(e.target.value); + }} + autoSize={{ maxRows: 8 }} + onCompositionStart={() => { + isChineseInput.current = true; + }} + onCompositionEnd={() => { + isChineseInput.current = false; + }} + className={styles.input} + onPressEnter={(e) => { + if (!isLoading && !e.shiftKey && !isChineseInput.current) { + e.preventDefault(); + send(); + } + }} + /> + {mobile ? null : ( + <Button + loading={isLoading} + type="text" + className={styles.btn} + onClick={() => send()} + icon={<SendOutlined />} + /> + )} + </Flexbox> + </Flexbox> + </ConfigProvider> + ); +}; + +export default memo(InputArea); diff --git a/src/ProChat/components/ScrollAnchor/index.tsx b/src/ProChat/components/ScrollAnchor/index.tsx new file mode 100644 index 00000000..c93a7b3d --- /dev/null +++ b/src/ProChat/components/ScrollAnchor/index.tsx @@ -0,0 +1,29 @@ +import { memo, useEffect } from 'react'; +import { useInView } from 'react-intersection-observer'; + +import { useStore } from '@/ProChat/store'; + +import { useAtBottom } from './useAtBottom'; + +const ChatScrollAnchor = memo(() => { + const trackVisibility = useStore((s) => !!s.chatLoadingId); + + const isAtBottom = useAtBottom(); + const { ref, entry, inView } = useInView({ + delay: 100, + rootMargin: '0px 0px -150px 0px', + trackVisibility, + }); + + useEffect(() => { + if (isAtBottom && trackVisibility && !inView) { + entry?.target.scrollIntoView({ + block: 'start', + }); + } + }, [inView, entry, isAtBottom, trackVisibility]); + + return <div ref={ref} style={{ height: 1, width: '100%' }} />; +}); + +export default ChatScrollAnchor; diff --git a/src/ProChat/components/ScrollAnchor/useAtBottom.ts b/src/ProChat/components/ScrollAnchor/useAtBottom.ts new file mode 100644 index 00000000..8c1ee1b7 --- /dev/null +++ b/src/ProChat/components/ScrollAnchor/useAtBottom.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +export const useAtBottom = (offset = 0) => { + const [isAtBottom, setIsAtBottom] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setIsAtBottom(window.innerHeight + window.scrollY >= document.body.offsetHeight - offset); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + handleScroll(); + + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [offset]); + + return isAtBottom; +}; diff --git a/src/ProChat/const/message.ts b/src/ProChat/const/message.ts new file mode 100644 index 00000000..9927101c --- /dev/null +++ b/src/ProChat/const/message.ts @@ -0,0 +1,4 @@ +export const LOADING_FLAT = '...'; + +// 只要 start with 这个,就可以判断为 function message +export const FUNCTION_MESSAGE_FLAG = '{"function'; diff --git a/src/ProChat/const/meta.ts b/src/ProChat/const/meta.ts new file mode 100644 index 00000000..1cac24f8 --- /dev/null +++ b/src/ProChat/const/meta.ts @@ -0,0 +1,2 @@ +export const DEFAULT_AVATAR = '🤖'; +export const DEFAULT_USER_AVATAR = '😀'; diff --git a/src/ProChat/container/App.tsx b/src/ProChat/container/App.tsx new file mode 100644 index 00000000..7bf58260 --- /dev/null +++ b/src/ProChat/container/App.tsx @@ -0,0 +1,47 @@ +import BackBottom from '@/BackBottom'; +import { createStyles } from 'antd-style'; +import { CSSProperties, ReactNode, memo, useRef } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import ChatList from '../components/ChatList'; +import InputArea from '../components/InputArea'; +import ChatScrollAnchor from '../components/ScrollAnchor'; +import { useOverrideStyles } from './OverrideStyle'; + +const useStyles = createStyles( + ({ css, responsive, stylish }) => css` + overflow: hidden scroll; + height: 100%; + ${responsive.mobile} { + ${stylish.noScrollbar} + width: 100vw; + } + `, +); + +interface ConversationProps { + chatInput?: ReactNode; + showTitle?: boolean; + style?: CSSProperties; + className?: string; +} + +const App = memo<ConversationProps>(({ chatInput, className, style, showTitle }) => { + const ref = useRef(null); + const { styles, cx } = useStyles(); + const { styles: override } = useOverrideStyles(); + return ( + <Flexbox className={cx(override.container, className)} style={style}> + <div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}> + <div className={styles} ref={ref}> + <ChatList showTitle={showTitle} /> + <ChatScrollAnchor /> + </div> + <BackBottom target={ref} text={'返回底部'} /> + </div> + {chatInput ?? <InputArea />} + </Flexbox> + ); +}); + +export default App; diff --git a/src/ProChat/container/OverrideStyle/antdOverride.ts b/src/ProChat/container/OverrideStyle/antdOverride.ts new file mode 100644 index 00000000..32eb926b --- /dev/null +++ b/src/ProChat/container/OverrideStyle/antdOverride.ts @@ -0,0 +1,64 @@ +import { FullToken, css } from 'antd-style'; + +export default (token: FullToken, prefixCls: string) => css` + .${prefixCls}-btn { + box-shadow: none; + } + + .${prefixCls}-tooltip-inner { + display: flex; + align-items: center; + justify-content: center; + + min-height: unset; + padding: 4px 8px; + + color: ${token.colorBgLayout} !important; + + background-color: ${token.colorText} !important; + border-radius: ${token.borderRadiusSM}px !important; + } + + .${prefixCls}-tooltip-arrow { + &::before, + &::after { + background: ${token.colorText} !important; + } + } + + .${prefixCls}-switch-handle::before { + background: ${token.colorBgContainer} !important; + } + + .${prefixCls}-alert { + span[role='img'] { + align-self: flex-start; + width: 16px; + height: 16px; + margin-top: 3px; + } + + .${prefixCls}-alert-description { + word-break: break-all; + word-wrap: break-word; + } + + &.${prefixCls}-alert-with-description { + padding-block: 12px; + padding-inline: 12px; + + .${prefixCls}-alert-message { + font-size: 14px; + font-weight: 600; + word-break: break-all; + word-wrap: break-word; + } + } + } + + @media (max-width: 575px) { + .${prefixCls}-tooltip { + display: none !important; + } + } +`; diff --git a/src/ProChat/container/OverrideStyle/global.ts b/src/ProChat/container/OverrideStyle/global.ts new file mode 100644 index 00000000..d4aeb30c --- /dev/null +++ b/src/ProChat/container/OverrideStyle/global.ts @@ -0,0 +1,71 @@ +import { FullToken, css } from 'antd-style'; + +export default (token: FullToken) => css` + line-height: 1; + text-size-adjust: none; + text-rendering: optimizelegibility; + vertical-align: baseline; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-overflow-scrolling: touch; + -webkit-tap-highlight-color: transparent; + } + code { + font-family: ${token.fontFamilyCode} !important; + + span { + font-family: ${token.fontFamilyCode} !important; + } + } + + p { + word-wrap: break-word; + } + + ::selection { + color: #000; + background: ${token.blue3}; + + -webkit-text-fill-color: unset !important; + } + + * { + box-sizing: border-box; + vertical-align: baseline; + } + + @media only screen and (min-width: 574px) { + * { + ::-webkit-scrollbar { + cursor: pointer; + width: 4px; + height: 4px; + background-color: transparent; + } + + ::-webkit-scrollbar-thumb { + cursor: pointer; + background-color: transparent; + border-radius: 2px; + transition: background-color 500ms ${token.motionEaseOut}; + + &:hover { + background-color: ${token.colorText}; + } + } + + ::-webkit-scrollbar-corner { + display: none; + width: 0; + height: 0; + } + + &:hover { + ::-webkit-scrollbar-thumb { + background-color: ${token.colorFill}; + } + } + } + } +`; diff --git a/src/ProChat/container/OverrideStyle/index.ts b/src/ProChat/container/OverrideStyle/index.ts new file mode 100644 index 00000000..4818ce48 --- /dev/null +++ b/src/ProChat/container/OverrideStyle/index.ts @@ -0,0 +1,7 @@ +import { createStyles } from 'antd-style'; +import antdOverride from './antdOverride'; +import global from './global'; + +export const useOverrideStyles = createStyles(({ token, prefixCls, cx }) => ({ + container: cx(`${prefixCls}-pro-chat`, global(token), antdOverride(token, prefixCls)), +})); diff --git a/src/ProChat/container/Provider.tsx b/src/ProChat/container/Provider.tsx new file mode 100644 index 00000000..fd5ed6a9 --- /dev/null +++ b/src/ProChat/container/Provider.tsx @@ -0,0 +1,70 @@ +import StoreUpdater, { ProChatChatReference } from '@/ProChat/container/StoreUpdater'; +import { memo, ReactNode } from 'react'; +import { DevtoolsOptions } from 'zustand/middleware'; +import { ChatProps, createStore, Provider, useStoreApi } from '../store'; + +interface ProChatProviderProps extends ChatProps { + children: ReactNode; + devtoolOptions?: boolean | DevtoolsOptions; + chatRef?: ProChatChatReference; +} + +export const ProChatProvider = memo<ProChatProviderProps>( + ({ + children, + devtoolOptions, + chats, + onChatsChange, + loading, + helloMessage, + userMeta, + assistantMeta, + request, + chatRef, + ...props + }) => { + let isWrapped = true; + + const Content = ( + <> + {children} + <StoreUpdater + chatRef={chatRef} + init={!loading} + helloMessage={helloMessage} + chats={chats} + userMeta={userMeta} + request={request} + assistantMeta={assistantMeta} + onChatsChange={onChatsChange} + /> + </> + ); + + try { + useStoreApi(); + } catch (e) { + isWrapped = false; + } + + if (isWrapped) { + return Content; + } + + return ( + <Provider createStore={() => createStore(props, devtoolOptions)}> + {Content} + <StoreUpdater + chatRef={chatRef} + init={!loading} + helloMessage={helloMessage} + chats={chats} + userMeta={userMeta} + request={request} + assistantMeta={assistantMeta} + onChatsChange={onChatsChange} + /> + </Provider> + ); + }, +); diff --git a/src/ProChat/container/StoreUpdater.tsx b/src/ProChat/container/StoreUpdater.tsx new file mode 100644 index 00000000..f20eaf78 --- /dev/null +++ b/src/ProChat/container/StoreUpdater.tsx @@ -0,0 +1,52 @@ +import { memo, MutableRefObject, useImperativeHandle } from 'react'; +import { createStoreUpdater } from 'zustand-utils'; + +import { ProChatInstance, useProChat } from '../hooks/useProChat'; +import { ChatProps, ChatState, useStoreApi } from '../store'; + +export type ProChatChatReference = MutableRefObject<ProChatInstance | undefined>; + +export interface StoreUpdaterProps + extends Partial< + Pick<ChatState, 'chats' | 'config' | 'init' | 'onChatsChange' | 'helloMessage' | 'request'> + >, + Pick<ChatProps, 'userMeta' | 'assistantMeta'> { + chatRef?: ProChatChatReference; +} + +const StoreUpdater = memo<StoreUpdaterProps>( + ({ + init, + onChatsChange, + chatRef, + request, + userMeta, + assistantMeta, + helloMessage, + chats, + config, + }) => { + const storeApi = useStoreApi(); + const useStoreUpdater = createStoreUpdater(storeApi); + + useStoreUpdater('init', init); + + useStoreUpdater('userMeta', userMeta); + useStoreUpdater('assistantMeta', assistantMeta); + + useStoreUpdater('helloMessage', helloMessage); + useStoreUpdater('config', config); + + useStoreUpdater('chats', chats); + useStoreUpdater('onChatsChange', onChatsChange); + + useStoreUpdater('request', request); + + const instance = useProChat(); + useImperativeHandle(chatRef, () => instance); + + return null; + }, +); + +export default StoreUpdater; diff --git a/src/ProChat/container/index.tsx b/src/ProChat/container/index.tsx new file mode 100644 index 00000000..f97b752d --- /dev/null +++ b/src/ProChat/container/index.tsx @@ -0,0 +1,30 @@ +import { App as Container } from 'antd'; +import { CSSProperties, ReactNode, memo } from 'react'; + +import App from './App'; + +import { DevtoolsOptions } from 'zustand/middleware'; +import { ChatProps } from '../store'; +import { ProChatProvider } from './Provider'; +import { ProChatChatReference } from './StoreUpdater'; + +export interface ProChatProps extends ChatProps { + renderInput?: ReactNode; + __PRO_CHAT_STORE_DEVTOOLS__?: boolean | DevtoolsOptions; + showTitle?: boolean; + style?: CSSProperties; + className?: string; + chatRef?: ProChatChatReference; +} + +export const ProChat = memo<ProChatProps>( + ({ renderInput, __PRO_CHAT_STORE_DEVTOOLS__, showTitle, style, className, ...props }) => { + return ( + <ProChatProvider {...props} devtoolOptions={__PRO_CHAT_STORE_DEVTOOLS__}> + <Container> + <App chatInput={renderInput} showTitle={showTitle} style={style} className={className} /> + </Container> + </ProChatProvider> + ); + }, +); diff --git a/src/ProChat/demos/control.tsx b/src/ProChat/demos/control.tsx new file mode 100644 index 00000000..bd13d063 --- /dev/null +++ b/src/ProChat/demos/control.tsx @@ -0,0 +1,34 @@ +/** + * compact: true + */ +import { ChatMessageMap, ProChat } from '@ant-design/pro-chat'; + +import { useTheme } from 'antd-style'; +import { useState } from 'react'; + +import { MockResponse } from '../mocks/streamResponse'; + +export default () => { + const theme = useTheme(); + + const [chats, setChats] = useState<ChatMessageMap>(); + + return ( + <div style={{ background: theme.colorBgLayout }}> + <ProChat + chats={chats} + onChatsChange={(chats) => { + console.log(chats); + setChats(chats); + }} + request={async (messages) => { + const mockedData: string = `这是一段模拟的流式字符串数据。本次会话传入了${messages.length}条消息`; + + const mockResponse = new MockResponse(mockedData, 100); + + return mockResponse.getResponse(); + }} + /> + </div> + ); +}; diff --git a/src/ProChat/demos/default.tsx b/src/ProChat/demos/default.tsx new file mode 100644 index 00000000..a71df2f5 --- /dev/null +++ b/src/ProChat/demos/default.tsx @@ -0,0 +1,43 @@ +/** + * compact: true + */ +import { ProChat } from '@ant-design/pro-chat'; + +import { useTheme } from 'antd-style'; +import { example } from '../mocks/basic'; + +export default () => { + const theme = useTheme(); + return ( + <div style={{ background: theme.colorBgLayout }}> + <ProChat + request={'/api/chat'} + config={{ + ...example.config, + params: { + ...example.config.params, + userId: '123', + extra: 'extra', + }, + }} + autocompleteRequest={async (value) => { + if (value === '/') { + return [{ value: '你可以帮助我列出问题吗?', label: '你可以帮助我列出问题吗?' }]; + } + return []; + }} + userMeta={{ + extra: 'extra', + }} + messageItemExtraRender={(_, type) => { + if (type === 'user') return <span>🦐</span>; + return <span>👍</span>; + }} + placeholder="输入 / 查看推荐问题,或者直接输入你的问题" + onResetMessage={async () => { + console.log('数据清空'); + }} + /> + </div> + ); +}; diff --git a/src/ProChat/demos/doc-mode.tsx b/src/ProChat/demos/doc-mode.tsx new file mode 100644 index 00000000..4f0a5212 --- /dev/null +++ b/src/ProChat/demos/doc-mode.tsx @@ -0,0 +1,28 @@ +/** + * iframe: 800 + */ +import { ProChat } from '@ant-design/pro-chat'; +import { useTheme } from 'antd-style'; + +import { example } from '../mocks/fullFeature'; +import { MockResponse } from '../mocks/streamResponse'; + +export default () => { + const theme = useTheme(); + return ( + <div style={{ background: theme.colorBgLayout }}> + <ProChat + displayMode={'docs'} + request={async (messages) => { + const mockedData: string = `这是一段模拟的流式字符串数据。本次会话传入了${messages.length}条消息`; + + const mockResponse = new MockResponse(mockedData, 100); + + return mockResponse.getResponse(); + }} + chats={example.chats} + config={example.config} + /> + </div> + ); +}; diff --git a/src/ProChat/demos/draggable.tsx b/src/ProChat/demos/draggable.tsx new file mode 100644 index 00000000..290c0d20 --- /dev/null +++ b/src/ProChat/demos/draggable.tsx @@ -0,0 +1,36 @@ +/** + * iframe: 500 + * title: 作为侧边栏使用 + */ +import { MockResponse } from '@/ProChat/mocks/streamResponse'; +import { ProChat } from '@ant-design/pro-chat'; +import { DraggablePanel } from '@ant-design/pro-editor'; +import { useTheme } from 'antd-style'; +import { Flexbox } from 'react-layout-kit'; + +export default () => { + const theme = useTheme(); + + return ( + <DraggablePanel + mode={'float'} + defaultPosition={{ x: 400, y: 30 }} + minHeight={400} + style={{ background: theme.colorBgLayout }} + > + <Flexbox align={'center'} style={{ fontSize: 12 }}> + 客服助理 + </Flexbox> + <ProChat + request={async (messages) => { + const mockedData: string = `这是一段模拟的流式字符串数据。本次会话传入了${messages.length}条消息`; + + const mockResponse = new MockResponse(mockedData); + + return mockResponse.getResponse(); + }} + style={{ height: '100vh' }} + /> + </DraggablePanel> + ); +}; diff --git a/src/ProChat/demos/helloMessage.tsx b/src/ProChat/demos/helloMessage.tsx new file mode 100644 index 00000000..cfadaad3 --- /dev/null +++ b/src/ProChat/demos/helloMessage.tsx @@ -0,0 +1,19 @@ +/** + * compact: true + */ +import { ProChat } from '@ant-design/pro-chat'; + +import { useTheme } from 'antd-style'; + +export default () => { + const theme = useTheme(); + return ( + <div style={{ background: theme.colorBgLayout }}> + <ProChat + helloMessage={ + '这是一条自定义消息,支持 markdown 消息,例如:[ProChat](https://github.com/ant-design/pro-chat)' + } + /> + </div> + ); +}; diff --git a/src/ProChat/demos/initialChats.tsx b/src/ProChat/demos/initialChats.tsx new file mode 100644 index 00000000..ca688244 --- /dev/null +++ b/src/ProChat/demos/initialChats.tsx @@ -0,0 +1,16 @@ +/** + * compact: true + */ +import { ProChat } from '@ant-design/pro-chat'; + +import { useTheme } from 'antd-style'; +import { example } from '../mocks/basic'; + +export default () => { + const theme = useTheme(); + return ( + <div style={{ background: theme.colorBgLayout }}> + <ProChat initialChats={example.chats} /> + </div> + ); +}; diff --git a/src/ProChat/demos/loading.tsx b/src/ProChat/demos/loading.tsx new file mode 100644 index 00000000..4781485d --- /dev/null +++ b/src/ProChat/demos/loading.tsx @@ -0,0 +1,38 @@ +/** + * title: 设置 loading 添加数据获取态 + * compact: true + */ +import { ProChat } from '@ant-design/pro-chat'; +import { Button, Divider } from 'antd'; +import { useTheme } from 'antd-style'; +import { useState } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +export default () => { + const [loading, setLoading] = useState(true); + const theme = useTheme(); + + return ( + <Flexbox style={{ background: theme.colorBgLayout }}> + <Flexbox padding={16} gap={16} horizontal> + <Button + type={'primary'} + onClick={() => { + setLoading(false); + }} + > + 加载完成 + </Button> + <Button + onClick={() => { + setLoading(true); + }} + > + 开始加载 + </Button> + </Flexbox> + <Divider /> + <ProChat loading={loading} /> + </Flexbox> + ); +}; diff --git a/src/ProChat/demos/meta.tsx b/src/ProChat/demos/meta.tsx new file mode 100644 index 00000000..937af034 --- /dev/null +++ b/src/ProChat/demos/meta.tsx @@ -0,0 +1,21 @@ +import { chats } from '@/ProChat/mocks/threebody'; +import { ProChat } from '@ant-design/pro-chat'; +import { useTheme } from 'antd-style'; + +export default () => { + const theme = useTheme(); + + return ( + <div style={{ background: theme.colorBgLayout }}> + <ProChat + showTitle + userMeta={{ + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg', + title: 'Ant Design', + }} + assistantMeta={{ avatar: '🛸', title: '三体世界', backgroundColor: '#67dedd' }} + initialChats={chats.chats} + /> + </div> + ); +}; diff --git a/src/ProChat/demos/modal.tsx b/src/ProChat/demos/modal.tsx new file mode 100644 index 00000000..d54ebbeb --- /dev/null +++ b/src/ProChat/demos/modal.tsx @@ -0,0 +1,8 @@ +/** + * iframe + */ +import { ProChat } from '@ant-design/pro-chat'; + +export default () => { + return <ProChat />; +}; diff --git a/src/ProChat/demos/no-stream.tsx b/src/ProChat/demos/no-stream.tsx new file mode 100644 index 00000000..88d8e721 --- /dev/null +++ b/src/ProChat/demos/no-stream.tsx @@ -0,0 +1,33 @@ +/** + * iframe: 500 + * title: 非流式的请求 + * description: 消息将在等待 5s 后返回 + */ +import { ProChat } from '@ant-design/pro-chat'; +import { useTheme } from 'antd-style'; + +const delay = (text: string) => + new Promise<string>((resolve) => { + setTimeout(() => { + resolve(text); + }, 5000); + }); + +export default () => { + const theme = useTheme(); + + return ( + <div style={{ background: theme.colorBgLayout, height: '100vh' }}> + <ProChat + request={async (messages) => { + const text = await delay( + `这是一条模拟非流式输出的消息的消息。本次会话传入了${messages.length}条消息`, + ); + + return new Response(text); + }} + style={{ height: '100vh' }} + /> + </div> + ); +}; diff --git a/src/ProChat/demos/request.tsx b/src/ProChat/demos/request.tsx new file mode 100644 index 00000000..61baf496 --- /dev/null +++ b/src/ProChat/demos/request.tsx @@ -0,0 +1,25 @@ +/** + * compact: true + */ +import { ProChat } from '@ant-design/pro-chat'; +import { useTheme } from 'antd-style'; + +import { MockResponse } from '../mocks/streamResponse'; + +export default () => { + const theme = useTheme(); + + return ( + <div style={{ background: theme.colorBgLayout }}> + <ProChat + request={async (messages) => { + const mockedData: string = `这是一段模拟的流式字符串数据。本次会话传入了${messages.length}条消息`; + + const mockResponse = new MockResponse(mockedData); + + return mockResponse.getResponse(); + }} + /> + </div> + ); +}; diff --git a/src/ProChat/demos/use-pro-chat.tsx b/src/ProChat/demos/use-pro-chat.tsx new file mode 100644 index 00000000..8587ea21 --- /dev/null +++ b/src/ProChat/demos/use-pro-chat.tsx @@ -0,0 +1,100 @@ +/** + * compact: true + */ +import { ProChat, ProChatProvider, useProChat } from '@ant-design/pro-chat'; +import { Button, Divider, Flex, message } from 'antd'; +import { useTheme } from 'antd-style'; + +import { MockResponse } from '@/ProChat/mocks/streamResponse'; +import { example } from '../mocks/basic'; + +const Chat = () => { + const theme = useTheme(); + return ( + <div style={{ background: theme.colorBgLayout }}> + <ProChat + request={async (messages) => { + const mockedData: string = `这是一段模拟的流式字符串数据。本次会话传入了${messages.length}条消息`; + + const mockResponse = new MockResponse(mockedData, 100); + + return mockResponse.getResponse(); + }} + /> + </div> + ); +}; + +const Control = () => { + const proChat = useProChat(); + + return ( + <Flex style={{ padding: 24 }} gap={8} justify={'space-between'}> + <Flex gap={8}> + <Button + type={'primary'} + onClick={() => { + proChat.sendMessage('这是程序化发送的消息'); + }} + > + 发送一条消息 + </Button> + <Button + onClick={() => { + const messages = proChat.getChatMessages(); + + const msg = messages.at(-1); + if (msg) { + message.info(msg.content); + } else { + message.warning('会话为空'); + } + }} + > + 获取最新会话消息 + </Button> + </Flex> + + <Button + onClick={() => { + const messages = proChat.getChatMessages(); + const { id, content } = messages[0] || {}; + + if (!id) return; + proChat.setMessageContent(id, content + '👋'); + }} + > + 修改首条消息,添加表情:👋 + </Button> + <Flex gap={8}> + <Button + danger + onClick={() => { + const messages = proChat.getChatMessages(); + proChat.deleteMessage(messages[0].id); + message.success('已删除第一条消息'); + }} + > + 删除第一条消息 + </Button> + <Button + type={'primary'} + danger + onClick={() => { + proChat.clearMessage(); + }} + > + 清空消息 + </Button> + </Flex> + </Flex> + ); +}; + +export default () => ( + <ProChatProvider initialChats={example.chats}> + <Control /> + <Divider>🔼 程序化控制 | 🔽 用户控制</Divider> + <Chat /> + </ProChatProvider> +); diff --git a/src/ProChat/demos/use-ref.tsx b/src/ProChat/demos/use-ref.tsx new file mode 100644 index 00000000..0afb9e09 --- /dev/null +++ b/src/ProChat/demos/use-ref.tsx @@ -0,0 +1,44 @@ +/** + * compact: true + */ +import { ProChat, ProChatInstance } from '@ant-design/pro-chat'; +import { useTheme } from 'antd-style'; +import { useRef } from 'react'; + +import { MockResponse } from '@/ProChat/mocks/streamResponse'; +import { Button } from 'antd'; +import { example } from '../mocks/basic'; + +export default () => { + const theme = useTheme(); + const proChatRef = useRef<ProChatInstance>(); + + return ( + <div style={{ background: theme.colorBgLayout }}> + <Button + type={'primary'} + onClick={() => { + if (!proChatRef.current) return; + const messages = proChatRef.current.getChatMessages(); + const { id, content } = messages[0] || {}; + + if (!id) return; + proChatRef.current.setMessageContent(id, content + '👋'); + }} + > + 修改首条消息,添加表情:👋 + </Button> + <ProChat + initialChats={example.chats} + chatRef={proChatRef} + request={async (messages) => { + const mockedData: string = `这是一段模拟的流式字符串数据。本次会话传入了${messages.length}条消息`; + + const mockResponse = new MockResponse(mockedData, 100); + + return mockResponse.getResponse(); + }} + /> + </div> + ); +}; diff --git a/src/ProChat/hooks/useProChat.ts b/src/ProChat/hooks/useProChat.ts new file mode 100644 index 00000000..093ac23d --- /dev/null +++ b/src/ProChat/hooks/useProChat.ts @@ -0,0 +1,48 @@ +import { ChatMessage } from '@/types'; +import { useMemoizedFn } from 'ahooks'; +import { useMemo } from 'react'; +import { ChatStore, useStoreApi } from '../store'; +import { chatSelectors } from '../store/selectors'; + +export interface ProChatInstance + extends Pick< + ChatStore, + 'resendMessage' | 'stopGenerateMessage' | 'sendMessage' | 'deleteMessage' | 'clearMessage' + > { + getChats: () => ChatStore['chats']; + getChatMessages: () => ChatMessage[]; + setMessageContent: (id: string, content: string) => void; +} + +export const useProChat = () => { + const storeApi = useStoreApi(); + + const { + resendMessage, + sendMessage, + stopGenerateMessage, + deleteMessage, + clearMessage, + dispatchMessage, + } = storeApi.getState(); + + const getChats = useMemoizedFn(() => storeApi.getState().chats); + const getChatMessages = useMemoizedFn(() => chatSelectors.currentChats(storeApi.getState())); + + const setMessageContent = useMemoizedFn((id: string, content: string) => { + dispatchMessage({ type: 'updateMessage', id, key: 'content', value: content }); + }); + + return useMemo<ProChatInstance>(() => { + return { + getChats, + getChatMessages, + resendMessage, + sendMessage, + stopGenerateMessage, + deleteMessage, + clearMessage, + setMessageContent, + }; + }, []); +}; diff --git a/src/ProChat/index.md b/src/ProChat/index.md new file mode 100644 index 00000000..890aafbd --- /dev/null +++ b/src/ProChat/index.md @@ -0,0 +1,85 @@ +--- +nav: 组件 +group: Chat +title: ProChat +order: 0 +description: a Chat Solution +--- + +## Default + +指定请求 + +<code src="./demos/request.tsx"></code> + +### 非流式模式 + +非流式模式下,会话消息会一次性全部返回,适用于消息量较少的场景。如果消息量较大,可能用户体验较差。 + +<code src="./demos/no-stream.tsx"></code> + +## 受控模式 + +使用 `chats` 和 `onChatsChange` 实现 chats 会话消息的受控 + +<code src="./demos/control.tsx"></code> + +## 设定初始会话消息 + +使用 `initialChats` 设定初始会话消息。 + +<code src="./demos/initialChats.tsx"></code> + +## 设定打招呼消息 + +通过 `helloMessage` 设定打招呼消息。 + +<code src="./demos/helloMessage.tsx"></code> + +## 加载中 + +使用 loading 字段控制加载中状态。适用于第一次加载数据时使用。 + +<code src="./demos/loading.tsx"></code> + +## 文档模式 + +类 OpenAI 的文档模式,使用 `doc` 字段控制文档模式。适用于需要展示文档的场景。 + +<code src="./demos/doc-mode.tsx"></code> + +## 指定会话双方的头像、名称 + +ProChat使用 `meta` 来表意会话双方的头像、名称等信息。设定助手的头像、名称使用 `assistantMeta`,设定用户的头像、名称使用 `userMeta`。 使用 `showTitle` 显示名称。 + +<code src="./demos/meta.tsx"></code> + +## 悬浮窗使用 + +将 `ProChat` 组件作为会话解决方案 + +<code src="./demos/draggable.tsx"></code> + +## 编程式操作控制 + +### chatRef + +在一些简单场景下,使用 `chatRef` 可以获取 `ProChatInstance` ,以在组件同级控制 ProChat 行为。 + +<code src="./demos/use-ref.tsx"></code> + +### useProChat + +针对一些复杂场景,可以搭配 `ProChatProvider` 和 `useProChat` hooks, 实现编程式消息发送、获取、删除等行为。 + +<code src="./demos/use-pro-chat.tsx"></code> + +:::warning +useProChat hooks 必须在包裹 `ProChatProvider` 后方可使用。 +::: + +:::warning +所有 `initialChats` 等初始化 props 需要从 `ProChat` 移动至 `ProChatProvider` +::: + +## APIs diff --git a/src/ProChat/index.tsx b/src/ProChat/index.tsx new file mode 100644 index 00000000..3afffbda --- /dev/null +++ b/src/ProChat/index.tsx @@ -0,0 +1,5 @@ +export * from '../types/message'; + +export { ProChat } from './container'; +export { ProChatProvider } from './container/Provider'; +export { useProChat, type ProChatInstance } from './hooks/useProChat'; diff --git a/src/ProChat/mocks/basic.ts b/src/ProChat/mocks/basic.ts new file mode 100644 index 00000000..b1c90dd2 --- /dev/null +++ b/src/ProChat/mocks/basic.ts @@ -0,0 +1,30 @@ +export const example = { + chats: { + ZGxiX2p4: { + content: '昨天的当天是明天的什么?', + createAt: 1697862242452, + id: 'ZGxiX2p4', + role: 'user', + updateAt: 1697862243540, + }, + Sb5pAzLL: { + content: '昨天的当天是明天的昨天。', + createAt: 1697862247302, + id: 'Sb5pAzLL', + parentId: 'ZGxiX2p4', + role: 'assistant', + updateAt: 1697862249387, + model: 'gpt-3.5-turbo', + }, + }, + config: { + model: 'gpt-3.5-turbo', + params: { + frequency_penalty: 0, + presence_penalty: 0, + temperature: 0.6, + top_p: 1, + }, + systemRole: '', + }, +}; diff --git a/src/ProChat/mocks/fullFeature.ts b/src/ProChat/mocks/fullFeature.ts new file mode 100644 index 00000000..91568446 --- /dev/null +++ b/src/ProChat/mocks/fullFeature.ts @@ -0,0 +1,118 @@ +export const example = { + chats: { + ZGxiX2p4: { + content: '请展示完整的会话高亮效果?', + createAt: 1697862242452, + id: 'ZGxiX2p4', + role: 'user', + updateAt: 1697862243540, + }, + Sb5pAzLL: { + content: ` +# This is an H1 +## This is an H2 +### This is an H3 +#### This is an H4 +##### This is an H5 + +The point of reference-style links is not that they’re easier to write. The point is that with reference-style links, your document source is vastly more readable. Compare the above examples: using reference-style links, the paragraph itself is only 81 characters long; with inline-style links, it’s 176 characters; and as raw \`HTML\`, it’s 234 characters. In the raw \`HTML\`, there’s more markup than there is text. + +--- + +> This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, +> consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. +> Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus. +> +> Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse +> id sem consectetuer libero luctus adipiscing. + +--- + +an example | *an example* | **an example** + +--- + +1. Bird +1. McHale +1. Parish + 1. Bird + 1. McHale + 1. Parish + +--- + +- Red +- Green +- Blue + - Red + - Green + - Blue + +--- + +This is [an example](http://example.com/ "Title") inline link. + +<http://example.com/> + + +| title | title | title | +| --- | --- | --- | +| content | content | content | + + +\`\`\`bash +$ pnpm install +\`\`\` + + +\`\`\`javascript +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-dom/test-utils'; +import { useDropNodeOnCanvas } from './useDropNodeOnCanvas'; +\`\`\` + +--- + +以下是一段Markdown格式的LaTeX数学公式: + +我是一个行内公式:$E=mc^2$ + +我是一个独立公式: +$$ +\\sum_{i=1}^{n} x_i = x_1 + x_2 + \\ldots + x_n +$$ + +我是一个带有分式的公式: +$$ +\\frac{{n!}}{{k!(n-k)!}} = \\binom{n}{k} +$$ + +我是一个带有上下标的公式: +$$ +x^{2} + y^{2} = r^{2} +$$ + +我是一个带有积分符号的公式: +$$ +\\int_{a}^{b} f(x) \\, dx +$$ +`, + createAt: 1697862247302, + id: 'Sb5pAzLL', + parentId: 'ZGxiX2p4', + role: 'assistant', + updateAt: 1697862249387, + model: 'gpt-3.5-turbo', + }, + }, + config: { + model: 'gpt-3.5-turbo', + params: { + frequency_penalty: 0, + presence_penalty: 0, + temperature: 0.6, + top_p: 1, + }, + systemRole: '', + }, +}; diff --git a/src/ProChat/mocks/streamResponse.ts b/src/ProChat/mocks/streamResponse.ts new file mode 100644 index 00000000..ac50d084 --- /dev/null +++ b/src/ProChat/mocks/streamResponse.ts @@ -0,0 +1,36 @@ +export class MockResponse { + private controller!: ReadableStreamDefaultController<Uint8Array>; + private encoder = new TextEncoder(); + + private stream: ReadableStream<Uint8Array>; + + constructor( + private data: string, + private delay: number = 300, + ) { + this.stream = new ReadableStream({ + start: (controller) => { + this.controller = controller; + this.pushData(); + }, + }); + } + + private pushData() { + if (this.data.length === 0) { + this.controller.close(); + return; + } + + const chunk = this.data.slice(0, 1); + this.data = this.data.slice(1); + + this.controller.enqueue(this.encoder.encode(chunk)); + + setTimeout(() => this.pushData(), this.delay); + } + + getResponse() { + return new Response(this.stream); + } +} diff --git a/src/ProChat/mocks/threebody.ts b/src/ProChat/mocks/threebody.ts new file mode 100644 index 00000000..2c8b8c61 --- /dev/null +++ b/src/ProChat/mocks/threebody.ts @@ -0,0 +1,30 @@ +export const chats = { + chats: { + ZGxiX2p4: { + content: '我对三体世界说话。', + createAt: 1697862242452, + id: 'ZGxiX2p4', + role: 'user', + updateAt: 1697862243540, + }, + Sb5pAzLL: { + content: '保持静默,不要回答,不要回答。', + createAt: 1697862247302, + id: 'Sb5pAzLL', + parentId: 'ZGxiX2p4', + role: 'assistant', + updateAt: 1697862249387, + model: 'gpt-3.5-turbo', + }, + }, + config: { + model: 'gpt-3.5-turbo', + params: { + frequency_penalty: 0, + presence_penalty: 0, + temperature: 0.6, + top_p: 1, + }, + systemRole: '', + }, +}; diff --git a/src/ProChat/store/action.ts b/src/ProChat/store/action.ts new file mode 100644 index 00000000..ee1937dc --- /dev/null +++ b/src/ProChat/store/action.ts @@ -0,0 +1,312 @@ +import { merge, template } from 'lodash-es'; +import { StateCreator } from 'zustand/vanilla'; + +import { LOADING_FLAT } from '@/ProChat/const/message'; +import { ChatStore } from '@/ProChat/store/index'; +import { fetchSSE } from '@/ProChat/utils/fetch'; +import { isFunctionMessage } from '@/ProChat/utils/message'; +import { setNamespace } from '@/ProChat/utils/storeDebug'; +import { nanoid } from '@/ProChat/utils/uuid'; +import { ChatMessage } from '@/types/message'; + +import { initialModelConfig } from '@/ProChat/store/initialState'; +import { ChatStreamPayload } from '@/ProChat/types/chat'; +import { getSlicedMessagesWithConfig } from '../utils/message'; +import { MessageDispatch, messagesReducer } from './reducers/message'; +import { chatSelectors } from './selectors'; + +const t = setNamespace('chat/message'); + +interface FetchChatModelOptions { + signal?: AbortSignal | undefined; +} + +/** + * 聊天操作 + */ +export interface ChatAction { + /** + * 清除消息 + */ + clearMessage: () => void; + /** + * 删除消息 + * @param id - 消息 ID + */ + deleteMessage: (id: string) => void; + /** + * 分发消息 + * @param payload - 消息分发参数 + */ + dispatchMessage: (payload: MessageDispatch) => void; + /** + * 生成消息 + * @param messages - 聊天消息数组 + * @param options - 获取 SSE 选项 + */ + generateMessage: ( + messages: ChatMessage[], + assistantMessageId: string, + ) => Promise<{ isFunctionCall: boolean }>; + /** + * 实际获取 AI 响应 + * + * @param messages - 聊天消息数组 + * @param parentId - 父消息 ID,可选 + */ + realFetchAIResponse: (messages: ChatMessage[], parentId: string) => Promise<void>; + + /** + * 重新发送消息 + * @param id - 消息 ID + */ + resendMessage: (id: string) => Promise<void>; + /** + * 发送消息 + * @param text - 消息文本 + */ + sendMessage: (text: string) => Promise<void>; + stopGenerateMessage: () => void; + + toggleChatLoading: ( + loading: boolean, + id?: string, + action?: string, + ) => AbortController | undefined; + defaultModelFetcher: ( + params: Partial<ChatStreamPayload>, + options?: FetchChatModelOptions, + ) => Promise<Response>; +} + +export const chatAction: StateCreator<ChatStore, [['zustand/devtools', never]], [], ChatAction> = ( + set, + get, +) => ({ + clearMessage: async () => { + const { dispatchMessage, onResetMessage } = get(); + + // 重置消息,清空聊天记录,等待 onResetMessage 完成后再清空 + if (onResetMessage) await onResetMessage(); + + dispatchMessage({ type: 'resetMessages' }); + + // TODO: need callback after reset + }, + + deleteMessage: (id) => { + get().dispatchMessage({ id, type: 'deleteMessage' }); + }, + + dispatchMessage: (payload) => { + const { chats, onChatsChange } = get(); + + const nextChats = messagesReducer(chats, payload); + + set({ chats: nextChats }, false, t('dispatchMessage')); + + onChatsChange?.(nextChats); + }, + generateMessage: async (messages, assistantId) => { + const { dispatchMessage, toggleChatLoading, config, defaultModelFetcher } = get(); + + const abortController = toggleChatLoading( + true, + assistantId, + t('generateMessage(start)', { assistantId, messages }) as string, + ); + + const compiler = template(config.inputTemplate, { interpolate: /{{([\S\s]+?)}}/g }); + + // ========================== // + // 对 messages 做统一预处理 // + // ========================== // + + // 1. 按参数设定截断长度 + const slicedMessages = getSlicedMessagesWithConfig(messages, config); + + // 2. 替换 inputMessage 模板 + const postMessages = !config.inputTemplate + ? slicedMessages + : slicedMessages.map((m) => { + if (m.role === 'user') { + try { + return { ...m, content: compiler({ text: m.content }) }; + } catch (error) { + console.error(error); + + return m; + } + } + return m; + }); + + // 3. 添加 systemRole + if (config.systemRole) { + postMessages.unshift({ content: config.systemRole, role: 'system' } as ChatMessage); + } + + const fetcher = () => + defaultModelFetcher( + { + messages: postMessages, + model: config.model, + ...config.params, + }, + { signal: abortController?.signal }, + ); + + let output = ''; + let isFunctionCall = false; + + await fetchSSE(fetcher, { + onErrorHandle: (error) => { + dispatchMessage({ id: assistantId, key: 'error', type: 'updateMessage', value: error }); + }, + onMessageHandle: (text) => { + output += text; + + dispatchMessage({ + id: assistantId, + key: 'content', + type: 'updateMessage', + value: output, + }); + + // TODO: need a function call judge callback + // 如果是 function call + if (isFunctionMessage(output)) { + isFunctionCall = true; + } + }, + }); + + toggleChatLoading(false, undefined, t('generateMessage(end)') as string); + + return { isFunctionCall }; + }, + + realFetchAIResponse: async (messages, userMessageId) => { + const { dispatchMessage, generateMessage, config } = get(); + + // 添加一个空的信息用于放置 ai 响应,注意顺序不能反 + // 因为如果顺序反了,messages 中将包含新增的 ai message + const mid = nanoid(); + + dispatchMessage({ + id: mid, + message: LOADING_FLAT, + parentId: userMessageId, + role: 'assistant', + type: 'addMessage', + }); + + // TODO: need a callback before generate message + + // 为模型添加 fromModel 的额外信息 + // TODO: 此处需要model 信息 + dispatchMessage({ id: mid, key: 'fromModel', type: 'updateMessageExtra', value: config.model }); + + // 生成 ai message + await generateMessage(messages, mid); + + // todo: need callback after generate message + }, + + resendMessage: async (messageId) => { + // 1. 构造所有相关的历史记录 + const chats = chatSelectors.currentChats(get()); + + const currentIndex = chats.findIndex((c) => c.id === messageId); + if (currentIndex < 0) return; + + const currentMessage = chats[currentIndex]; + + let contextMessages: ChatMessage[] = []; + + switch (currentMessage.role) { + case 'function': + case 'user': { + contextMessages = chats.slice(0, currentIndex + 1); + break; + } + case 'assistant': { + // 消息是 AI 发出的因此需要找到它的 user 消息 + const userId = currentMessage.parentId; + const userIndex = chats.findIndex((c) => c.id === userId); + // 如果消息没有 parentId,那么同 user/function 模式 + contextMessages = chats.slice(0, userIndex < 0 ? currentIndex + 1 : userIndex + 1); + break; + } + } + + if (contextMessages.length <= 0) return; + + const { realFetchAIResponse } = get(); + + const latestMsg = contextMessages.filter((s) => s.role === 'user').at(-1); + + if (!latestMsg) return; + + await realFetchAIResponse(contextMessages, latestMsg.id); + }, + + sendMessage: async (message) => { + const { dispatchMessage, realFetchAIResponse } = get(); + + if (!message) return; + + const userId = nanoid(); + dispatchMessage({ id: userId, message, role: 'user', type: 'addMessage' }); + + // Todo: need a callback before send message + + // Get the current messages to generate AI response + const messages = chatSelectors.currentChats(get()); + + await realFetchAIResponse(messages, userId); + + // TODO: need a callback after send + }, + + stopGenerateMessage: () => { + const { abortController, toggleChatLoading } = get(); + if (!abortController) return; + + abortController.abort(); + + toggleChatLoading(false); + }, + toggleChatLoading: (loading, id, action) => { + if (loading) { + const abortController = new AbortController(); + set({ abortController, chatLoadingId: id }, false, action); + return abortController; + } else { + set({ abortController: undefined, chatLoadingId: undefined }, false, action); + } + }, + + defaultModelFetcher: (params, options) => { + const { request } = get(); + const payload = merge( + { + model: initialModelConfig.model, + stream: true, + ...initialModelConfig.params, + }, + params, + ); + + if (typeof request === 'function') return request(payload.messages as ChatMessage[], payload); + + const url = typeof request === 'string' ? request : '/api/openai/chat'; + + return fetch(url, { + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + signal: options?.signal, + }); + }, +}); diff --git a/src/ProChat/store/index.ts b/src/ProChat/store/index.ts new file mode 100644 index 00000000..367df469 --- /dev/null +++ b/src/ProChat/store/index.ts @@ -0,0 +1,8 @@ +import { StoreApi } from 'zustand'; +import { createContext } from 'zustand-utils'; +import { ChatStore } from './store'; + +export type { ChatState } from './initialState'; +export * from './store'; + +export const { useStore, useStoreApi, Provider } = createContext<StoreApi<ChatStore>>(); diff --git a/src/ProChat/store/initialState.ts b/src/ProChat/store/initialState.ts new file mode 100644 index 00000000..684a9299 --- /dev/null +++ b/src/ProChat/store/initialState.ts @@ -0,0 +1,93 @@ +import { DEFAULT_AVATAR, DEFAULT_USER_AVATAR } from '@/ProChat/const/meta'; +import { ModelConfig } from '@/ProChat/types/config'; +import { MetaData } from '@/ProChat/types/meta'; +import { ChatMessage, ChatMessageMap } from '@/types/message'; + +export type ChatRequest = (messages: ChatMessage[], config: ModelConfig) => Promise<Response>; + +export interface ChatPropsState { + /** + * 语言模型角色设定 + */ + config: ModelConfig; + /** + * 聊天记录 + */ + chats: ChatMessageMap; + onChatsChange?: (chats: ChatMessageMap) => void; + displayMode: 'chat' | 'docs'; + userMeta: MetaData; + assistantMeta: MetaData; + /** + * 帮助消息 + */ + helloMessage?: string; + request?: string | ChatRequest; + + /** + * 重置消息 + * @returns + */ + onResetMessage?: () => Promise<void>; + + /** + * 获取自动完成列表的 request + * @param value + * @returns + */ + autocompleteRequest?: (value: string) => Promise< + { + value: string; + label?: string; + }[] + >; + + /** + * 输入框的 placeholder + */ + placeholder?: string; + + /** + * 信息框额外渲染 + */ + messageItemExtraRender?: (message: ChatMessage, type: 'assistant' | 'user') => React.ReactNode; + + /** */ + // /** + // * 控制是否流式输出 + // * @default true + // */ + // stream: boolean; +} + +export interface ChatState extends ChatPropsState { + init?: boolean; + abortController?: AbortController; + chatLoadingId?: string; +} + +export const initialModelConfig: ModelConfig = { + historyCount: 1, + model: 'gpt-3.5-turbo', + params: { + frequency_penalty: 0, + presence_penalty: 0, + temperature: 0.6, + top_p: 1, + }, + systemRole: '', +}; + +export const initialState: ChatState = { + chats: {}, + init: true, + displayMode: 'chat', + userMeta: { + avatar: DEFAULT_USER_AVATAR, + }, + assistantMeta: { + avatar: DEFAULT_AVATAR, + }, + config: initialModelConfig, + // stream: true, +}; diff --git a/src/ProChat/store/reducers/message.test.ts b/src/ProChat/store/reducers/message.test.ts new file mode 100644 index 00000000..6dda72b8 --- /dev/null +++ b/src/ProChat/store/reducers/message.test.ts @@ -0,0 +1,223 @@ +import { ChatMessageMap } from '@/types/message'; + +import { MessageDispatch, messagesReducer } from './message'; + +describe('messagesReducer', () => { + let initialState: ChatMessageMap; + + beforeEach(() => { + initialState = { + message1: { + id: 'message1', + content: 'Hello World', + createAt: 1629264000000, + updateAt: 1629264000000, + role: 'user', + }, + message2: { + id: 'message2', + content: 'How are you?', + createAt: 1629264000000, + updateAt: 1629264000000, + role: 'system', + }, + } as unknown as ChatMessageMap; + }); + + describe('addMessage', () => { + it('should add a new message to the state', () => { + const payload: MessageDispatch = { + type: 'addMessage', + message: 'New Message', + role: 'user', + id: 'message3', + }; + + const newState = messagesReducer(initialState, payload); + + expect(Object.keys(newState)).toHaveLength(3); + expect(newState).toHaveProperty('message1'); + expect(newState).toHaveProperty('message2'); + expect(newState).toHaveProperty('message3'); + expect(newState.message3).toEqual({ + id: 'message3', + content: 'New Message', + createAt: expect.any(Number), + updateAt: expect.any(Number), + role: 'user', + }); + }); + + it('should use the provided id when adding a new message', () => { + const payload: MessageDispatch = { + type: 'addMessage', + id: 'customId', + message: 'New Message', + role: 'user', + }; + + const newState = messagesReducer(initialState, payload); + + expect(Object.keys(newState)).toHaveLength(3); + expect(newState).toHaveProperty('message1'); + expect(newState).toHaveProperty('message2'); + expect(newState).toHaveProperty('customId'); + expect(newState.customId).toEqual({ + id: 'customId', + content: 'New Message', + createAt: expect.any(Number), + updateAt: expect.any(Number), + role: 'user', + }); + }); + + it('should use the provided parentId when adding a new message', () => { + const payload: MessageDispatch = { + type: 'addMessage', + message: 'New Message', + id: 'message3', + role: 'user', + parentId: 'message1', + }; + + const newState = messagesReducer(initialState, payload); + + expect(newState.message3).toEqual({ + id: 'message3', + content: 'New Message', + createAt: expect.any(Number), + updateAt: expect.any(Number), + role: 'user', + parentId: 'message1', + }); + }); + + it('should use the provided parentId and quotaId when adding a new message', () => { + const payload: MessageDispatch = { + type: 'addMessage', + message: 'New Message', + id: 'message3', + role: 'user', + parentId: 'message1', + }; + + const newState = messagesReducer(initialState, payload); + + expect(newState.message3).toEqual({ + id: 'message3', + content: 'New Message', + createAt: expect.any(Number), + updateAt: expect.any(Number), + role: 'user', + parentId: 'message1', + }); + }); + }); + + describe('deleteMessage', () => { + it('should remove the specified message from the state', () => { + const payload: MessageDispatch = { + type: 'deleteMessage', + id: 'message1', + }; + + const newState = messagesReducer(initialState, payload); + + expect(Object.keys(newState)).toHaveLength(1); + expect(newState).not.toHaveProperty('message1'); + expect(newState).toHaveProperty('message2'); + }); + + it('should not modify the state if the specified message does not exist', () => { + const payload: MessageDispatch = { + type: 'deleteMessage', + id: 'nonexistentMessage', + }; + + const newState = messagesReducer(initialState, payload); + + expect(newState).toEqual(initialState); + }); + }); + + describe('updateMessage', () => { + it('should update the specified message with the provided value', () => { + const payload: MessageDispatch = { + type: 'updateMessage', + id: 'message1', + key: 'content', + value: 'Updated Message', + }; + + const newState = messagesReducer(initialState, payload); + + expect(newState.message1.content).toBe('Updated Message'); + expect(newState.message1.updateAt).toBeGreaterThan(initialState.message1.updateAt); + }); + + it('should not modify the state if the specified message does not exist', () => { + const payload: MessageDispatch = { + type: 'updateMessage', + id: 'nonexistentMessage', + key: 'content', + value: 'Updated Message', + }; + + const newState = messagesReducer(initialState, payload); + + expect(newState).toEqual(initialState); + }); + }); + + describe('updateMessageExtra', () => { + it('should update the specified message extra property with the provided value', () => { + const payload: MessageDispatch = { + type: 'updateMessageExtra', + id: 'message1', + key: 'translate', + value: { target: 'en', to: 'zh' }, + }; + + const newState = messagesReducer(initialState, payload); + + expect(newState.message1.extra!.translate).toEqual({ target: 'en', to: 'zh' }); + expect(newState.message1.updateAt).toBeGreaterThan(initialState.message1.updateAt); + }); + + it('should not modify the state if the specified message does not exist', () => { + const payload: MessageDispatch = { + type: 'updateMessageExtra', + id: 'nonexistentMessage', + key: 'translate', + value: { target: 'en', to: 'zh' }, + }; + + const newState = messagesReducer(initialState, payload); + + expect(newState).toEqual(initialState); + }); + }); + + describe('resetMessages', () => { + it('should reset the state to an empty object', () => { + const payload: MessageDispatch = { + type: 'resetMessages', + }; + + const newState = messagesReducer(initialState, payload); + + expect(newState).toEqual({}); + }); + }); + + describe('unimplemented type', () => { + it('should throw an error when an unimplemented type is provided', () => { + // @ts-ignore + const payload: MessageDispatch = { type: 'unimplementedType' }; + + expect(() => messagesReducer(initialState, payload)).toThrowError( + '暂未实现的 type,请检查 reducer', + ); + }); + }); +}); diff --git a/src/ProChat/store/reducers/message.ts b/src/ProChat/store/reducers/message.ts new file mode 100644 index 00000000..a046fb9e --- /dev/null +++ b/src/ProChat/store/reducers/message.ts @@ -0,0 +1,117 @@ +import { produce } from 'immer'; + +import { LLMRoleType } from '@/types/llm'; +import { ChatMessage, ChatMessageMap } from '@/types/message'; +import { MetaData } from '@/types/meta'; +import { nanoid } from '../../utils/uuid'; + +interface AddMessage { + id?: string; + message: string; + meta?: MetaData; + parentId?: string; + quotaId?: string; + role: LLMRoleType; + type: 'addMessage'; +} + +interface DeleteMessage { + id: string; + type: 'deleteMessage'; +} + +interface ResetMessages { + topicId?: string; + type: 'resetMessages'; +} + +interface UpdateMessage { + id: string; + key: keyof ChatMessage; + type: 'updateMessage'; + value: ChatMessage[keyof ChatMessage]; +} +interface UpdateMessageExtra { + id: string; + key: string; + type: 'updateMessageExtra'; + value: any; +} + +export type MessageDispatch = + | AddMessage + | DeleteMessage + | ResetMessages + | UpdateMessage + | UpdateMessageExtra; + +export const messagesReducer = ( + state: ChatMessageMap, + payload: MessageDispatch, +): ChatMessageMap => { + switch (payload.type) { + case 'addMessage': { + return produce(state, (draftState) => { + const mid = payload.id || nanoid(); + + draftState[mid] = { + content: payload.message, + createAt: Date.now(), + id: mid, + parentId: payload.parentId, + role: payload.role, + updateAt: Date.now(), + }; + }); + } + + case 'deleteMessage': { + return produce(state, (draftState) => { + delete draftState[payload.id]; + }); + } + + case 'updateMessage': { + return produce(state, (draftState) => { + const { id, key, value } = payload; + const message = draftState[id]; + if (!message) return; + + // @ts-ignore + message[key] = value; + message.updateAt = Date.now(); + }); + } + + case 'updateMessageExtra': { + return produce(state, (draftState) => { + const { id, key, value } = payload; + const message = draftState[id]; + if (!message) return; + + if (!message.extra) { + message.extra = { [key]: value } as any; + } else { + message.extra[key] = value; + } + + message.updateAt = Date.now(); + }); + } + + case 'resetMessages': { + return produce(state, (draftState) => { + const messages = Object.values(draftState); + + // 删除上述找到的消息 + for (const message of messages) { + delete draftState[message.id]; + } + }); + } + + default: { + throw new Error('暂未实现的 type,请检查 reducer'); + } + } +}; diff --git a/src/ProChat/store/selectors/chat.ts b/src/ProChat/store/selectors/chat.ts new file mode 100644 index 00000000..2fab0aa5 --- /dev/null +++ b/src/ProChat/store/selectors/chat.ts @@ -0,0 +1,101 @@ +import { ChatMessage } from '@/types/message'; + +import { MetaData } from '@/ProChat/types/meta'; +import { getSlicedMessagesWithConfig } from '../../utils/message'; +import type { ChatStore } from '../store'; + +// 当前激活的消息列表 +export const currentChats = (s: ChatStore): ChatMessage[] => { + if (Object.keys(s.chats).length === 0) return []; + + const getMeta = (message: ChatMessage): MetaData => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { avatar, title, backgroundColor, ...rest } = s.userMeta; + const assistant = s.assistantMeta; + switch (message.role) { + case 'user': { + return { + avatar, + title, + ...rest, + }; + } + + case 'system': { + return assistant; + } + + case 'assistant': { + return { + avatar: assistant?.avatar, + backgroundColor: assistant?.backgroundColor, + title: assistant?.title, + }; + } + } + + return {}; + }; + + const basic = Object.values<ChatMessage>(s.chats) + // 首先按照时间顺序排序,越早的在越前面 + .sort((pre, next) => pre.createAt - next.createAt) + // 映射头像关系 + .map((m) => { + return { + ...m, + meta: getMeta(m), + }; + }); + + const finalList: ChatMessage[] = []; + + const addItem = (item: ChatMessage) => { + const isExist = finalList.findIndex((i) => item.id === i.id) > -1; + if (!isExist) { + finalList.push(item); + } + }; + + // 基于添加逻辑进行重排序 + for (const item of basic) { + // 先判存在与否,不存在就加入 + addItem(item); + + for (const another of basic) { + if (another.parentId === item.id) { + addItem(another); + } + } + } + + return finalList; +}; + +// 针对新助手添加初始化时的自定义消息 +export const currentChatsWithGuideMessage = (s: ChatStore): ChatMessage[] => { + const data = currentChats(s); + // TODO: need topic inject + + const isBrandNewChat = data.length === 0; + + if (!isBrandNewChat) return data; + + const emptyInboxGuideMessage = { + content: s.helloMessage ?? '让我们开始对话吧', + createAt: Date.now(), + extra: {}, + id: 'default', + meta: s.assistantMeta, + role: 'assistant', + updateAt: Date.now(), + } as ChatMessage; + + return [emptyInboxGuideMessage]; +}; + +export const currentChatsWithHistoryConfig = (s: ChatStore): ChatMessage[] => { + const chats = currentChats(s); + + return getSlicedMessagesWithConfig(chats, s.config); +}; diff --git a/src/ProChat/store/selectors/index.ts b/src/ProChat/store/selectors/index.ts new file mode 100644 index 00000000..38fc3c44 --- /dev/null +++ b/src/ProChat/store/selectors/index.ts @@ -0,0 +1,7 @@ +import { currentChats, currentChatsWithGuideMessage, currentChatsWithHistoryConfig } from './chat'; + +export const chatSelectors = { + currentChats, + currentChatsWithGuideMessage, + currentChatsWithHistoryConfig, +}; diff --git a/src/ProChat/store/store.ts b/src/ProChat/store/store.ts new file mode 100644 index 00000000..38fe948e --- /dev/null +++ b/src/ProChat/store/store.ts @@ -0,0 +1,64 @@ +import { StateCreator } from 'zustand/vanilla'; + +import { MetaData } from '@/ProChat/types/meta'; +import isEqual from 'fast-deep-equal'; +import { merge } from 'lodash-es'; +import { optionalDevtools } from 'zustand-utils'; +import { DevtoolsOptions } from 'zustand/middleware'; +import { createWithEqualityFn } from 'zustand/traditional'; +import { ChatAction, chatAction } from './action'; +import { ChatPropsState, ChatState, initialState } from './initialState'; + +export interface ChatProps extends Partial<ChatPropsState> { + // init + loading?: boolean; + initialChats?: ChatPropsState['chats']; + userMeta?: MetaData; + assistantMeta?: MetaData; +} + +// =============== 聚合 createStoreFn ============ // + +export type ChatStore = ChatAction & ChatState; + +const vanillaStore = + ({ + loading, + initialChats, + chats, + ...props + }: ChatProps): StateCreator<ChatStore, [['zustand/devtools', never]]> => + (...parameters) => { + // initState = innerState + props + + const state = merge({}, initialState, { + init: !loading, + chats: chats ?? initialChats, + ...props, + } as ChatState); + + return { + ...state, + ...chatAction(...parameters), + }; + }; +// + +// =============== 封装 createStore ============ // + +const PRO_CHAT = 'PRO_CHAT'; + +const isDev = process.env.NODE_ENV === 'development'; + +export const createStore = (props: ChatProps, options: boolean | DevtoolsOptions = false) => { + const devtools = optionalDevtools(options !== false); + + const devtoolOptions = + options === false + ? undefined + : options === true + ? { name: PRO_CHAT + (isDev ? '_DEV' : '') } + : options; + + return createWithEqualityFn<ChatStore>()(devtools(vanillaStore(props), devtoolOptions), isEqual); +}; diff --git a/src/ProChat/types/chat.ts b/src/ProChat/types/chat.ts new file mode 100644 index 00000000..009a69a9 --- /dev/null +++ b/src/ProChat/types/chat.ts @@ -0,0 +1,70 @@ +import { LLMRoleType } from '@/types/llm'; +import { OpenAIFunctionCall } from '@/types/message'; + +export interface OpenAIChatMessage { + /** + * @title 内容 + * @description 消息内容 + */ + content: string; + + function_call?: OpenAIFunctionCall; + name?: string; + /** + * 角色 + * @description 消息发送者的角色 + */ + role: LLMRoleType | string; +} + +/** + * @title OpenAI Stream Payload + */ +export interface ChatStreamPayload { + /** + * @title 控制生成文本中的惩罚系数,用于减少重复性 + * @default 0 + */ + frequency_penalty?: number; + /** + * @title 生成文本的最大长度 + */ + max_tokens?: number; + /** + * @title 聊天信息列表 + */ + messages: OpenAIChatMessage[]; + /** + * @title 模型名称 + */ + model: string; + /** + * @title 返回的文本数量 + */ + n?: number; + /** + * 开启的插件列表 + */ + plugins?: string[]; + /** + * @title 控制生成文本中的惩罚系数,用于减少主题的变化 + * @default 0 + */ + presence_penalty?: number; + /** + * @title 是否开启流式请求 + * @default true + */ + stream?: boolean; + /** + * @title 生成文本的随机度量,用于控制文本的创造性和多样性 + * @default 0.5 + */ + temperature: number; + + /** + * @title 控制生成文本中最高概率的单个令牌 + * @default 1 + */ + top_p?: number; +} diff --git a/src/ProChat/types/config.ts b/src/ProChat/types/config.ts new file mode 100644 index 00000000..797a6493 --- /dev/null +++ b/src/ProChat/types/config.ts @@ -0,0 +1,72 @@ +// 语言模型的设置参数 +export interface ModelParams { + /** + * 控制生成文本中的惩罚系数,用于减少重复性 + * @default 0 + */ + frequency_penalty?: number; + /** + * 生成文本的最大长度 + */ + max_tokens?: number; + /** + * 控制生成文本中的惩罚系数,用于减少主题的变化 + * @default 0 + */ + presence_penalty?: number; + /** + * 生成文本的随机度量,用于控制文本的创造性和多样性 + * @default 0.6 + */ + temperature?: number; + /** + * 控制生成文本中最高概率的单个 token + * @default 1 + */ + top_p?: number; + + [key: string]: any; +} + +export type ModelRoleType = 'user' | 'system' | 'assistant' | 'function'; + +export interface LLMMessage { + content: string; + role: ModelRoleType; +} + +export type LLMFewShots = LLMMessage[]; + +export interface ModelConfig { + compressThreshold?: number; + /** + * 历史消息长度压缩阈值 + */ + enableCompressThreshold?: boolean; + /** + * 开启历史记录条数 + */ + enableHistoryCount?: boolean; + enableMaxTokens?: boolean; + /** + * 语言模型示例 + */ + fewShots?: LLMFewShots; + /** + * 历史消息条数 + */ + historyCount?: number; + inputTemplate?: string; + /** + * 角色所使用的语言模型 + */ + model?: string; + /** + * 语言模型参数 + */ + params?: ModelParams; + /** + * 系统角色 + */ + systemRole?: string; +} diff --git a/src/ProChat/types/meta.ts b/src/ProChat/types/meta.ts new file mode 100644 index 00000000..572694f9 --- /dev/null +++ b/src/ProChat/types/meta.ts @@ -0,0 +1,23 @@ +export interface MetaData { + /** + * 角色头像 + * @description 可选参数,如果不传则使用默认头像 + */ + avatar?: string; + /** + * 背景色 + * @description 可选参数,如果不传则使用默认背景色 + */ + backgroundColor?: string; + /** + * 名称 + * @description 可选参数,如果不传则使用默认名称 + */ + title?: string; + + /** + * 附加数据 + * @description 可选参数,如果不传则使用默认名称 + */ + [key: string]: any; +} diff --git a/src/ProChat/utils/fetch.ts b/src/ProChat/utils/fetch.ts new file mode 100644 index 00000000..22213d61 --- /dev/null +++ b/src/ProChat/utils/fetch.ts @@ -0,0 +1,56 @@ +import { ChatMessageError } from '../../types/message'; + +export const getMessageError = async (response: Response) => { + let chatMessageError: ChatMessageError; + + chatMessageError = { + message: `response error, status: ${response.statusText}`, + type: response.status as any, + }; + + return chatMessageError; +}; + +export interface FetchSSEOptions { + onErrorHandle?: (error: ChatMessageError) => void; + onMessageHandle?: (text: string) => void; +} + +/** + * 使用流式方法获取数据 + * @param fetchFn + * @param options + */ +export const fetchSSE = async (fetchFn: () => Promise<Response>, options: FetchSSEOptions = {}) => { + const response = await fetchFn(); + + // 如果不 ok 说明有请求错误 + if (!response.ok) { + // TODO: need a message error custom parser + const chatMessageError = await getMessageError(response); + + options.onErrorHandle?.(chatMessageError); + return; + } + + const returnRes = response.clone(); + + const data = response.body; + + if (!data) return; + + const reader = data.getReader(); + const decoder = new TextDecoder(); + + let done = false; + + while (!done) { + const { value, done: doneReading } = await reader.read(); + done = doneReading; + const chunkValue = decoder.decode(value); + + options.onMessageHandle?.(chunkValue); + } + + return returnRes; +}; diff --git a/src/ProChat/utils/merge.ts b/src/ProChat/utils/merge.ts new file mode 100644 index 00000000..71470598 --- /dev/null +++ b/src/ProChat/utils/merge.ts @@ -0,0 +1,11 @@ +import { merge as _merge, mergeWith } from 'lodash-es'; + +/** + * 用于合并对象,如果是数组则直接替换 + * @param target + * @param source + */ +export const merge: typeof _merge = <T = object>(target: T, source: T) => + mergeWith({}, target, source, (obj, src) => { + if (Array.isArray(obj)) return src; + }); diff --git a/src/ProChat/utils/message.ts b/src/ProChat/utils/message.ts new file mode 100644 index 00000000..ad46ce94 --- /dev/null +++ b/src/ProChat/utils/message.ts @@ -0,0 +1,18 @@ +import { FUNCTION_MESSAGE_FLAG } from '@/ProChat/const/message'; +import { ModelConfig } from '@/ProChat/types/config'; +import { ChatMessage } from '@/types/message'; + +export const isFunctionMessage = (content: string) => { + return content.startsWith(FUNCTION_MESSAGE_FLAG); +}; + +export const getSlicedMessagesWithConfig = ( + messages: ChatMessage[], + config: ModelConfig, +): ChatMessage[] => { + // 如果没有开启历史消息数限制,或者限制为 0,则直接返回 + if (!config.enableHistoryCount || !config.historyCount) return messages; + + // 如果开启了,则返回尾部的N条消息 + return messages.reverse().slice(0, config.historyCount).reverse(); +}; diff --git a/src/ProChat/utils/storeDebug.ts b/src/ProChat/utils/storeDebug.ts new file mode 100644 index 00000000..3507d29c --- /dev/null +++ b/src/ProChat/utils/storeDebug.ts @@ -0,0 +1,11 @@ +export const setNamespace = (namespace: string) => { + return (type: string, payload?: any) => { + const name = [namespace, type].filter(Boolean).join('/'); + return payload + ? { + payload, + type: name, + } + : name; + }; +}; diff --git a/src/ProChat/utils/uuid.ts b/src/ProChat/utils/uuid.ts new file mode 100644 index 00000000..8cc31f3c --- /dev/null +++ b/src/ProChat/utils/uuid.ts @@ -0,0 +1,7 @@ +// generate('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 16); //=> "4f90d13a42" +import { customAlphabet } from 'nanoid/non-secure'; + +export const nanoid = customAlphabet( + '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + 8, +); diff --git a/src/TokenTag/demos/index.tsx b/src/TokenTag/demos/index.tsx new file mode 100644 index 00000000..5ec5877f --- /dev/null +++ b/src/TokenTag/demos/index.tsx @@ -0,0 +1,16 @@ +import { TokenTag, TokenTagProps } from '@ant-design/pro-chat'; + +export default () => { + const control: TokenTagProps | any = { + maxValue: { + step: 1, + value: 5000, + }, + value: { + step: 1, + value: 1000, + }, + }; + + return <TokenTag {...control} />; +}; diff --git a/src/TokenTag/index.md b/src/TokenTag/index.md new file mode 100644 index 00000000..54fa21c2 --- /dev/null +++ b/src/TokenTag/index.md @@ -0,0 +1,17 @@ +--- +nav: 组件 +group: Chat +title: TokenTag +description: The TokenTag component is used to display a token tag with a FluentEmoji icon and a text indicating the remaining tokens. +order: 10 +--- + +## Default + +The remaining tokens are calculated based on the `maxValue` and `value` props. The component has three types of visual styles: normal, low, and overload, which are determined by the percentage of remaining tokens. The component is memoized for performance optimization. + +<code src="./demos/index.tsx" nopadding></code> + +## APIs + +<API></API> diff --git a/src/TokenTag/index.tsx b/src/TokenTag/index.tsx new file mode 100644 index 00000000..146283a0 --- /dev/null +++ b/src/TokenTag/index.tsx @@ -0,0 +1,72 @@ +import { useResponsive } from 'antd-style'; +import { forwardRef } from 'react'; + +import Emoji from '@/Emoji'; +import { DivProps } from '@/types'; + +import { ICON_SIZE, useStyles } from './style'; + +export interface TokenTagProps extends DivProps { + /** + * @default 'left' + */ + displayMode?: 'remained' | 'used'; + /** + * @description Maximum value for the token + */ + maxValue: number; + shape?: 'round' | 'square'; + text?: { + overload?: string; + remained?: string; + used?: string; + }; + /** + * @description Current value of the token + */ + value: number; +} + +const TokenTag = forwardRef<HTMLDivElement, TokenTagProps>( + ( + { className, displayMode = 'remained', maxValue, value, text, shape = 'round', ...props }, + ref, + ) => { + const { mobile } = useResponsive(); + const valueLeft = maxValue - value; + const percent = valueLeft / maxValue; + let type: 'normal' | 'low' | 'overload'; + let emoji; + + if (percent > 0.3) { + type = 'normal'; + emoji = '😀'; + } else if (percent > 0) { + type = 'low'; + emoji = '😅'; + } else { + type = 'overload'; + emoji = '🤯'; + } + + const { styles, cx } = useStyles({ shape, type }); + + return ( + <div className={cx(styles.container, className)} ref={ref} {...props}> + <Emoji emoji={emoji} size={ICON_SIZE} /> + {valueLeft > 0 + ? [ + mobile + ? '' + : displayMode === 'remained' + ? text?.remained || 'Remained' + : text?.used || 'Used', + displayMode === 'remained' ? valueLeft : value, + ].join(' ') + : text?.overload || 'Overload'} + </div> + ); + }, +); + +export default TokenTag; diff --git a/src/TokenTag/style.ts b/src/TokenTag/style.ts new file mode 100644 index 00000000..3e5ac302 --- /dev/null +++ b/src/TokenTag/style.ts @@ -0,0 +1,67 @@ +import { createStyles } from 'antd-style'; + +const HEIGHT = 28; +export const ICON_SIZE = 20; + +export const useStyles = createStyles( + ( + { cx, css, token, isDarkMode }, + { type, shape }: { shape: 'round' | 'square'; type: 'normal' | 'low' | 'overload' }, + ) => { + let percentStyle; + + switch (type) { + case 'normal': { + percentStyle = css` + color: ${token.colorSuccessText}; + `; + break; + } + case 'low': { + percentStyle = css` + color: ${token.colorWarningText}; + `; + break; + } + case 'overload': { + percentStyle = css` + color: ${token.colorErrorText}; + `; + break; + } + } + + const roundStylish = css` + padding: 0 ${(HEIGHT - ICON_SIZE) * 1.2}px 0 ${(HEIGHT - ICON_SIZE) / 2}px; + background: ${isDarkMode ? token.colorFillSecondary : token.colorFillTertiary}; + border-radius: ${HEIGHT / 2}px; + `; + + const squareStylish = css` + border-radius: ${token.borderRadiusSM}px; + `; + + return { + container: cx( + percentStyle, + shape === 'round' ? roundStylish : squareStylish, + css` + user-select: none; + + overflow: hidden; + display: flex; + flex: 0; + gap: 4px; + align-items: center; + + min-width: fit-content; + height: ${HEIGHT}px; + + font-family: ${token.fontFamilyCode}; + font-size: 13px; + line-height: 1; + `, + ), + }; + }, +); diff --git a/src/components/Avatar/getEmojiByCharacter.ts b/src/components/Avatar/getEmojiByCharacter.ts new file mode 100644 index 00000000..ce15c56d --- /dev/null +++ b/src/components/Avatar/getEmojiByCharacter.ts @@ -0,0 +1,6 @@ +import emojiRegex from 'emoji-regex'; + +export const getEmoji = (emoji: string): string | undefined => { + const regex = emojiRegex(); + return emoji.match(regex)?.[0]; +}; diff --git a/src/components/Avatar/index.tsx b/src/components/Avatar/index.tsx new file mode 100644 index 00000000..e11c93ec --- /dev/null +++ b/src/components/Avatar/index.tsx @@ -0,0 +1,77 @@ +import { Avatar as AntAvatar, type AvatarProps as AntAvatarProps } from 'antd'; +import { memo, useMemo } from 'react'; + +import Emoji from '@/Emoji'; +import { getEmoji } from '@/components/Avatar/getEmojiByCharacter'; + +import { useStyles } from './style'; + +export interface AvatarProps extends AntAvatarProps { + /** + * @description The URL or base64 data of the avatar image + */ + avatar?: string; + /** + * @description The background color of the avatar + */ + background?: string; + /** + * @description The shape of the avatar + * @default 'circle' + */ + shape?: 'circle' | 'square'; + /** + * @description The size of the avatar in pixels + * @default 40 + */ + size?: number; + /** + * @description The title text to display if avatar is not provided + */ + title?: string; +} + +const Avatar = memo<AvatarProps>( + ({ + className, + avatar, + title, + size = 40, + shape = 'circle', + background = 'rgba(0,0,0,0)', + onClick, + style, + ...props + }) => { + const isImage = Boolean( + avatar && ['/', 'http', 'data:'].some((index) => avatar.startsWith(index)), + ); + const isBase64 = Boolean(avatar?.startsWith('data')); + const emoji = useMemo(() => avatar && !isImage && getEmoji(avatar), [avatar]); + + const { styles, cx } = useStyles({ background, isEmoji: Boolean(emoji), size }); + + const text = String(isImage ? title : avatar); + + const avatarProps = { + className: cx(styles.avatar, className), + shape: shape, + size, + style: onClick ? style : { cursor: 'default', ...style }, + }; + + return isImage ? ( + <AntAvatar + src={isBase64 ? avatar : <img src={avatar} alt="avatar" />} + {...avatarProps} + {...props} + /> + ) : ( + <AntAvatar {...avatarProps} {...props}> + {emoji ? <Emoji emoji={emoji} size={size * 0.8} /> : text?.toUpperCase().slice(0, 2)} + </AntAvatar> + ); + }, +); + +export default Avatar; diff --git a/src/components/Avatar/style.ts b/src/components/Avatar/style.ts new file mode 100644 index 00000000..d5136272 --- /dev/null +++ b/src/components/Avatar/style.ts @@ -0,0 +1,36 @@ +import { createStyles } from 'antd-style'; +import { readableColor } from 'polished'; + +export const useStyles = createStyles( + ( + { css, token, prefixCls }, + { background, size, isEmoji }: { background?: string; isEmoji?: boolean; size: number }, + ) => { + const backgroundColor = background ?? token.colorBgContainer; + const color = readableColor(backgroundColor); + + return { + avatar: css` + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + background: ${backgroundColor}; + border: 1px solid ${background ? 'transparent' : token.colorSplit}; + + > .${prefixCls}-avatar-string { + font-size: ${size * (isEmoji ? 0.7 : 0.5)}px; + font-weight: 700; + line-height: 1 !important; + color: ${color}; + } + + > * { + cursor: pointer; + } + `, + }; + }, +); diff --git a/src/components/ControlInput.tsx b/src/components/ControlInput.tsx new file mode 100644 index 00000000..26ba3c09 --- /dev/null +++ b/src/components/ControlInput.tsx @@ -0,0 +1,95 @@ +import { ConfigProvider, Space, type InputRef } from 'antd'; +import { RotateCcw, Save } from 'lucide-react'; +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; + +import ActionIcon from '@/ActionIcon'; +import { Input, InputProps } from '@/components/Input'; + +export interface ControlInputProps extends Omit<InputProps, 'onChange' | 'value' | 'onAbort'> { + /** + * @description Callback function that is triggered when the input value changes + */ + onChange?: (value: string) => void; + /** + * @description Callback function that is triggered when the input value has stopped changing + */ + onChangeEnd?: (value: string) => void; + /** + * @description Callback function that is triggered when the input value is changing + */ + onValueChanging?: (value: string) => void; + /** + * @description The initial value of the input + */ + value?: string; +} + +export const ControlInput = forwardRef<InputRef, ControlInputProps>( + ({ value, onChange, onValueChanging, onChangeEnd, ...props }, ref) => { + const [input, setInput] = useState<string>(value || ''); + + const isChineseInput = useRef(false); + const isFocusing = useRef(false); + + const updateValue = useCallback(() => { + onChange?.(input); + }, [input]); + + useEffect(() => { + if (value !== undefined) setInput(value); + }, [value]); + + return ( + <Input + autoFocus + onBlur={() => { + isFocusing.current = false; + onChangeEnd?.(input); + }} + onChange={(e) => { + setInput(e.target.value); + onValueChanging?.(e.target.value); + }} + onCompositionEnd={() => { + isChineseInput.current = false; + }} + onCompositionStart={() => { + isChineseInput.current = true; + }} + onFocus={() => { + isFocusing.current = true; + }} + onPressEnter={(e) => { + if (!e.shiftKey && !isChineseInput.current) { + e.preventDefault(); + updateValue(); + isFocusing.current = false; + onChangeEnd?.(input); + } + }} + ref={ref} + suffix={ + value === input ? ( + <span /> + ) : ( + <ConfigProvider theme={{ token: { fontSize: 14 } }}> + <Space size={2}> + <ActionIcon + icon={RotateCcw} + onClick={() => { + setInput(value as string); + }} + size="small" + title="Reset" + /> + <ActionIcon icon={Save} onClick={updateValue} size="small" title="✅ Save" /> + </Space> + </ConfigProvider> + ) + } + value={input} + {...props} + /> + ); + }, +); diff --git a/src/components/CopyButton/index.tsx b/src/components/CopyButton/index.tsx new file mode 100644 index 00000000..797a054d --- /dev/null +++ b/src/components/CopyButton/index.tsx @@ -0,0 +1,55 @@ +import copy from 'copy-to-clipboard'; +import { Copy } from 'lucide-react'; +import { memo } from 'react'; + +import ActionIcon, { type ActionIconSize } from '@/ActionIcon'; +import { useCopied } from '@/hooks/useCopied'; +import { DivProps } from '@/types'; +import { type TooltipProps } from 'antd'; + +export interface CopyButtonProps extends DivProps { + /** + * @description Additional class name + */ + className?: string; + /** + * @description The text content to be copied + */ + content: string; + /** + * @description The placement of the tooltip + * @enum ['top', 'left', 'right', 'bottom', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight', 'leftTop', 'leftBottom', 'rightTop', 'rightBottom'] + * @default 'right' + */ + placement?: TooltipProps['placement']; + /** + * @description The size of the icon + * @enum ['large', 'normal', 'small', 'site'] + * @default 'site' + */ + size?: ActionIconSize; +} + +const CopyButton = memo<CopyButtonProps>( + ({ content, className, placement = 'right', size = 'site', ...props }) => { + const { copied, setCopied } = useCopied(); + + return ( + <ActionIcon + {...props} + className={className} + glass + icon={Copy} + onClick={() => { + copy(content); + setCopied(); + }} + placement={placement} + size={size} + title={copied ? '✅ Success' : 'Copy'} + /> + ); + }, +); + +export default CopyButton; diff --git a/src/components/Form/components/FormDivider.tsx b/src/components/Form/components/FormDivider.tsx new file mode 100644 index 00000000..45e9a8dc --- /dev/null +++ b/src/components/Form/components/FormDivider.tsx @@ -0,0 +1,8 @@ +import { Divider as AntDivider, DividerProps } from 'antd'; +import { memo } from 'react'; + +export type FormDividerProps = DividerProps; +const Divider = memo<FormDividerProps>(({ style, ...props }) => ( + <AntDivider style={{ margin: 0, opacity: 0.5, ...style }} {...props} /> +)); +export default Divider; diff --git a/src/components/Form/components/FormFooter.tsx b/src/components/Form/components/FormFooter.tsx new file mode 100644 index 00000000..7a247272 --- /dev/null +++ b/src/components/Form/components/FormFooter.tsx @@ -0,0 +1,18 @@ +import { memo } from 'react'; + +import { DivProps } from '@/types'; + +import { useStyles } from './style'; + +export type FormFooterProps = DivProps; + +const FormFooter = memo<FormFooterProps>(({ className, children, ...props }) => { + const { cx, styles } = useStyles(); + return ( + <div className={cx(styles.footer, className)} {...props}> + {children} + </div> + ); +}); + +export default FormFooter; diff --git a/src/components/Form/components/FormGroup.tsx b/src/components/Form/components/FormGroup.tsx new file mode 100644 index 00000000..69c3d3ca --- /dev/null +++ b/src/components/Form/components/FormGroup.tsx @@ -0,0 +1,65 @@ +import Icon, { type IconProps } from '@/Icon'; +import { Collapse, type CollapseProps } from 'antd'; +import { useResponsive } from 'antd-style'; +import { ChevronDown } from 'lucide-react'; +import { memo, type ReactNode } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { useStyles } from './style'; + +export interface FormGroupProps extends CollapseProps { + children: ReactNode; + extra?: ReactNode; + icon?: IconProps['icon']; + title: string; +} + +const FormGroup = memo<FormGroupProps>(({ className, icon, title, children, extra, ...props }) => { + const { mobile } = useResponsive(); + const { cx, styles } = useStyles(); + + const titleContent = ( + <div className={styles.title}> + {icon && <Icon icon={icon} />} + {title} + </div> + ); + + if (mobile) + return ( + <Flexbox className={className}> + <Flexbox className={styles.mobileGroupHeader} horizontal justify={'space-between'}> + {titleContent} + {extra} + </Flexbox> + <div className={styles.mobileGroupBody}>{children}</div> + </Flexbox> + ); + + return ( + <Collapse + className={cx(styles.group, className)} + defaultActiveKey={[1]} + expandIcon={({ isActive }) => ( + <Icon + className={styles.icon} + icon={ChevronDown} + size={{ fontSize: 16 }} + style={isActive ? {} : { rotate: '-90deg' }} + /> + )} + items={[ + { + children, + extra, + key: 1, + label: titleContent, + }, + ]} + key={1} + {...props} + /> + ); +}); + +export default FormGroup; diff --git a/src/components/Form/components/FormItem.tsx b/src/components/Form/components/FormItem.tsx new file mode 100644 index 00000000..b3691e75 --- /dev/null +++ b/src/components/Form/components/FormItem.tsx @@ -0,0 +1,37 @@ +import { FormItemProps as AntdFormItemProps, Form } from 'antd'; +import { memo } from 'react'; + +import FormDivider from './FormDivider'; +import FormTitle, { type FormTitleProps } from './FormTitle'; +import { useStyles } from './style'; + +const { Item } = Form; + +export interface FormItemProps extends AntdFormItemProps { + avatar?: FormTitleProps['avatar']; + desc?: FormTitleProps['desc']; + divider?: boolean; + hidden?: boolean; + minWidth?: string | number; + tag?: FormTitleProps['tag']; +} + +const FormItem = memo<FormItemProps>( + ({ desc, tag, minWidth, avatar, className, label, children, divider, ...props }) => { + const { cx, styles } = useStyles(minWidth); + return ( + <> + {divider && <FormDivider />} + <Item + className={cx(styles.item, !divider && styles.itemNoDivider, className)} + label={<FormTitle avatar={avatar} desc={desc} tag={tag} title={label as any} />} + {...props} + > + {children} + </Item> + </> + ); + }, +); + +export default FormItem; diff --git a/src/components/Form/components/FormTitle.tsx b/src/components/Form/components/FormTitle.tsx new file mode 100644 index 00000000..d1e8e502 --- /dev/null +++ b/src/components/Form/components/FormTitle.tsx @@ -0,0 +1,39 @@ +import { ReactNode, memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import Tag from '@/components/Tag'; +import { DivProps } from '@/types'; + +import { useStyles } from './style'; + +export interface FormTitleProps extends DivProps { + avatar?: ReactNode; + desc?: ReactNode; + tag?: string; + title: string; +} + +const FormTitle = memo<FormTitleProps>(({ className, tag, title, desc, avatar }) => { + const { cx, styles } = useStyles(); + const titleNode = ( + <div className={cx(styles.formTitle, className)}> + <Flexbox align={'center'} direction={'horizontal'} gap={8}> + {title} + {tag && <Tag>{tag}</Tag>} + </Flexbox> + {desc && <small>{desc}</small>} + </div> + ); + + if (avatar) { + return ( + <Flexbox align={`center`} gap={8} horizontal> + {avatar} + {titleNode} + </Flexbox> + ); + } + return titleNode; +}); + +export default FormTitle; diff --git a/src/components/Form/components/style.ts b/src/components/Form/components/style.ts new file mode 100644 index 00000000..c3e2b971 --- /dev/null +++ b/src/components/Form/components/style.ts @@ -0,0 +1,148 @@ +import { createStyles } from 'antd-style'; +import { isNumber } from 'lodash-es'; + +export const useStyles = createStyles( + ({ css, cx, token, isDarkMode, responsive, prefixCls }, itemMinWidth?: string | number) => ({ + footer: css` + display: flex; + gap: 8px; + justify-content: flex-end; + `, + formTitle: css` + position: relative; + + display: flex; + flex-direction: column; + gap: 6px; + + text-align: left; + + > div { + font-weight: 500; + line-height: 1; + } + + > small { + display: block; + + line-height: 1; + color: ${token.colorTextDescription}; + word-wrap: break-word; + white-space: pre-wrap; + } + + .${prefixCls}-tag { + font-family: ${token.fontFamilyCode}; + } + `, + group: cx( + isDarkMode && + css` + .${prefixCls}-collapse-content { + background: transparent; + } + + .${prefixCls}-collapse-header { + background: ${token.colorFillTertiary}; + } + `, + css` + .${prefixCls}-collapse-header { + align-items: center !important; + border-radius: 0 !important; + } + + .${prefixCls}-collapse-content-box { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + + .${prefixCls}-form-item-label { + display: flex; + flex-direction: column; + justify-content: center; + } + `, + ), + icon: css` + transition: all 100ms ${token.motionEaseOut}; + `, + item: css` + padding: 16px 0; + + .${prefixCls}-row { + justify-content: space-between; + + > div { + flex: unset !important; + flex-grow: unset !important; + } + } + + .${prefixCls}-form-item-required::before { + align-self: flex-start; + } + + ${itemMinWidth && + css` + .${prefixCls}-form-item-control { + width: ${isNumber(itemMinWidth) ? `${itemMinWidth}px` : itemMinWidth}; + } + `} + + ${responsive.mobile} { + padding: 16px 0; + + ${itemMinWidth + ? css` + .${prefixCls}-row { + flex-direction: column; + gap: 4px; + } + + .${prefixCls}-form-item-control { + flex: 1; + width: 100%; + } + ` + : css` + .${prefixCls}-row { + flex-wrap: wrap; + gap: 4px; + } + `} + } + `, + itemNoDivider: css` + &:not(:first-child) { + padding-top: 0; + } + `, + mobileGroupBody: css` + padding: 0 16px; + background: ${token.colorBgContainer}; + `, + mobileGroupHeader: css` + padding: 16px; + background: ${token.colorBgLayout}; + `, + title: css` + display: flex; + gap: 8px; + align-items: center; + + font-size: 16px; + font-weight: 600; + + .anticon { + color: ${token.colorPrimary}; + } + + ${responsive.mobile} { + font-size: 14px; + font-weight: 400; + opacity: 0.5; + } + `, + }), +); diff --git a/src/components/Form/index.tsx b/src/components/Form/index.tsx new file mode 100644 index 00000000..59dd8f17 --- /dev/null +++ b/src/components/Form/index.tsx @@ -0,0 +1,69 @@ +import { Form as AntForm, FormProps as AntFormProps, type FormInstance } from 'antd'; +import { RefAttributes, forwardRef, type ReactNode } from 'react'; + +import FormFooter from './components/FormFooter'; +import FormGroup, { type FormGroupProps } from './components/FormGroup'; +import FormItem, { type FormItemProps } from './components/FormItem'; +import { useStyles } from './style'; + +export interface ItemGroup { + children: FormItemProps[]; + extra?: FormGroupProps['extra']; + icon?: FormGroupProps['icon']; + title: FormGroupProps['title']; +} + +export interface FormProps extends AntFormProps { + children?: ReactNode; + footer?: ReactNode; + itemMinWidth?: FormItemProps['minWidth']; + items?: ItemGroup[]; +} + +const FormParent = forwardRef<FormInstance, FormProps>( + ({ className, itemMinWidth, footer, form, items, children, ...props }, ref) => { + const { cx, styles } = useStyles(); + return ( + <AntForm + className={cx(styles.form, className)} + colon={false} + form={form} + layout="horizontal" + ref={ref} + {...props} + > + {items?.map((group, groupIndex) => ( + <FormGroup extra={group?.extra} icon={group?.icon} key={groupIndex} title={group.title}> + {group.children + .filter((item) => !item.hidden) + .map((item, itemIndex) => { + return ( + <FormItem + divider={itemIndex !== 0} + key={itemIndex} + minWidth={itemMinWidth} + {...item} + /> + ); + })} + </FormGroup> + ))} + {children} + {footer && <FormFooter>{footer}</FormFooter>} + </AntForm> + ); + }, +); + +export interface IForm { + (props: FormProps & RefAttributes<FormInstance>): ReactNode; + Group: typeof FormGroup; + Item: typeof FormItem; +} + +const Form = FormParent as unknown as IForm; + +Form.Item = FormItem; +Form.Group = FormGroup; + +export default Form; diff --git a/src/components/Form/style.ts b/src/components/Form/style.ts new file mode 100644 index 00000000..3f1dd0ed --- /dev/null +++ b/src/components/Form/style.ts @@ -0,0 +1,48 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles(({ css, token, prefixCls, responsive }) => ({ + footer: css` + display: flex; + gap: 8px; + justify-content: flex-end; + `, + form: css` + display: flex; + flex-direction: column; + gap: 16px; + + ${responsive.mobile} { + gap: 0; + } + + .${prefixCls}-form-item { + margin: 0 !important; + } + + .${prefixCls}-form-item .${prefixCls}-form-item-label > label { + height: unset; + } + + .${prefixCls}-row { + position: relative; + flex-wrap: nowrap; + } + + .${prefixCls}-form-item-label { + position: relative; + flex: 1; + max-width: 100%; + } + + .${prefixCls}-form-item-control { + position: relative; + flex: 0; + min-width: unset !important; + } + + .${prefixCls}-collapse-item { + overflow: hidden !important; + border-radius: ${token.borderRadius}px !important; + } + `, +})); diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx new file mode 100644 index 00000000..7102bc1e --- /dev/null +++ b/src/components/Input/index.tsx @@ -0,0 +1,57 @@ +import { Input as AntInput, type InputProps as AntdInputProps, type InputRef } from 'antd'; +import { TextAreaProps as AntdTextAreaProps, type TextAreaRef } from 'antd/es/input/TextArea'; +import { forwardRef } from 'react'; + +import { useStyles } from './style'; + +export interface InputProps extends AntdInputProps { + /** + * @description Type of the input + * @default 'ghost' + */ + type?: 'ghost' | 'block' | 'pure'; +} + +export const Input = forwardRef<InputRef, InputProps>( + ({ className, type = 'ghost', ...props }, reference) => { + const { styles, cx } = useStyles({ type }); + + return ( + <AntInput + bordered={type !== 'pure'} + className={cx(styles.input, className)} + ref={reference} + {...props} + /> + ); + }, +); + +export interface TextAreaProps extends AntdTextAreaProps { + /** + * @description Whether to enable resizing of the textarea + * @default true + */ + resize?: boolean; + /** + * @description Type of the textarea + * @default 'ghost' + */ + type?: 'ghost' | 'block' | 'pure'; +} + +export const TextArea = forwardRef<TextAreaRef, TextAreaProps>( + ({ className, type = 'ghost', resize = true, style, ...props }, reference) => { + const { styles, cx } = useStyles({ type }); + + return ( + <AntInput.TextArea + bordered={type !== 'pure'} + className={cx(styles.textarea, className)} + ref={reference} + style={resize ? style : { resize: 'none', ...style }} + {...props} + /> + ); + }, +); diff --git a/src/components/Input/style.ts b/src/components/Input/style.ts new file mode 100644 index 00000000..3dde9af0 --- /dev/null +++ b/src/components/Input/style.ts @@ -0,0 +1,54 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles( + ({ cx, css, token, prefixCls }, { type }: { type: 'ghost' | 'block' | 'pure' }) => { + const typeStylish = css` + background-color: ${type === 'block' ? token.colorFillTertiary : 'transparent'}; + border: 1px solid ${type === 'block' ? 'transparent' : token.colorBorder}; + transition: + background-color 100ms ${token.motionEaseOut}, + border-color 200ms ${token.motionEaseOut}; + + &:hover { + background-color: ${token.colorFillTertiary}; + } + + &:focus { + border-color: ${token.colorTextQuaternary}; + } + + &.${prefixCls}-input-affix-wrapper-focused { + border-color: ${token.colorTextQuaternary}; + } + `; + + return { + input: cx( + type !== 'pure' && typeStylish, + css` + position: relative; + max-width: 100%; + height: ${type === 'pure' ? 'unset' : '36px'}; + padding: ${type === 'pure' ? '0' : '0 12px'}; + + input { + background: transparent; + } + `, + ), + textarea: cx( + type !== 'pure' && typeStylish, + css` + position: relative; + max-width: 100%; + padding: ${type === 'pure' ? '0' : '8px 12px'}; + border-radius: ${type === 'pure' ? '0' : `${token.borderRadius}px`}; + + textarea { + background: transparent; + } + `, + ), + }; + }, +); diff --git a/src/components/SliderWithInput/index.tsx b/src/components/SliderWithInput/index.tsx new file mode 100644 index 00000000..eda14738 --- /dev/null +++ b/src/components/SliderWithInput/index.tsx @@ -0,0 +1,69 @@ +import { InputNumber, Slider, type InputNumberProps } from 'antd'; +import { SliderSingleProps } from 'antd/es/slider'; +import { isNull } from 'lodash-es'; +import { memo, useCallback } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +export interface SliderWithInputProps extends SliderSingleProps { + controls?: InputNumberProps['controls']; + size?: InputNumberProps['size']; +} + +const SliderWithInput = memo<SliderWithInputProps>( + ({ + step, + value, + onChange, + max, + min, + defaultValue, + size, + controls, + style, + className, + disabled, + ...props + }) => { + const handleOnchange = useCallback((value: number | null) => { + if (Number.isNaN(value) || isNull(value)) return; + onChange?.(value); + }, []); + + return ( + <Flexbox + align={'center'} + className={className} + direction={'horizontal'} + gap={8} + style={style} + > + <Slider + defaultValue={defaultValue} + disabled={disabled} + max={max} + min={min} + onChange={handleOnchange} + step={step} + style={size === 'small' ? { flex: 1, margin: 0 } : { flex: 1 }} + tooltip={{ open: false }} + value={typeof value === 'number' ? value : 0} + {...props} + /> + <InputNumber + controls={size !== 'small' || controls} + defaultValue={defaultValue} + disabled={disabled} + max={max} + min={min} + onChange={handleOnchange} + size={size} + step={Number.isNaN(step) || isNull(step) ? undefined : step} + style={{ flex: 1, maxWidth: size === 'small' ? 40 : 64 }} + value={typeof value === 'number' ? value : 0} + /> + </Flexbox> + ); + }, +); + +export default SliderWithInput; diff --git a/src/components/Spotlight/index.tsx b/src/components/Spotlight/index.tsx new file mode 100644 index 00000000..ae43170f --- /dev/null +++ b/src/components/Spotlight/index.tsx @@ -0,0 +1,53 @@ +import { memo, useEffect, useRef, useState } from 'react'; + +import { DivProps } from '@/types'; + +import { useStyles } from './style'; + +const useMouseOffset = (): any => { + const [offset, setOffset] = useState<{ x: number; y: number }>(); + const [outside, setOutside] = useState(true); + const reference = useRef<HTMLDivElement>(); + + useEffect(() => { + if (reference.current && reference.current.parentElement) { + const element = reference.current.parentElement; + + // debounce? + const onMouseMove = (e: MouseEvent) => { + const bound = element.getBoundingClientRect(); + setOffset({ x: e.clientX - bound.x, y: e.clientY - bound.y }); + setOutside(false); + }; + + const onMouseLeave = () => { + setOutside(true); + }; + element.addEventListener('mousemove', onMouseMove); + element.addEventListener('mouseleave', onMouseLeave); + return () => { + element.removeEventListener('mousemove', onMouseMove); + element.removeEventListener('mouseleave', onMouseLeave); + }; + } + }, []); + + return [offset, outside, reference] as const; +}; + +export interface SpotlightProps extends DivProps { + /** + * @description The size of the spotlight circle + * @default 64 + */ + size?: number; +} + +const Spotlight = memo<SpotlightProps>(({ className, size = 64, ...properties }) => { + const [offset, outside, reference] = useMouseOffset(); + const { styles, cx } = useStyles({ offset, outside, size }); + + return <div className={cx(styles, className)} ref={reference} {...properties} />; +}); + +export default Spotlight; diff --git a/src/components/Spotlight/style.ts b/src/components/Spotlight/style.ts new file mode 100644 index 00000000..279b5f7a --- /dev/null +++ b/src/components/Spotlight/style.ts @@ -0,0 +1,30 @@ +import { createStyles } from 'antd-style'; + +export const useStyles = createStyles( + ( + { css, token, isDarkMode }, + { offset, outside, size }: { offset: { x: number; y: number }; outside: boolean; size: number }, + ) => { + const spotlightX = (offset?.x ?? 0) + 'px'; + const spotlightY = (offset?.y ?? 0) + 'px'; + const spotlightOpacity = outside ? '0' : '.1'; + const spotlightSize = size + 'px'; + return css` + pointer-events: none; + + position: absolute; + z-index: 1; + inset: 0; + + opacity: ${spotlightOpacity}; + background: radial-gradient( + ${spotlightSize} circle at ${spotlightX} ${spotlightY}, + ${isDarkMode ? token.colorText : '#fff'}, + ${isDarkMode ? 'transparent' : token.colorTextQuaternary} + ); + border-radius: inherit; + + transition: all 0.2s; + `; + }, +); diff --git a/src/components/Tag/index.tsx b/src/components/Tag/index.tsx new file mode 100644 index 00000000..88bd4684 --- /dev/null +++ b/src/components/Tag/index.tsx @@ -0,0 +1,45 @@ +import { Tag as AntTag, type TagProps as AntTagProps } from 'antd'; +import { createStyles } from 'antd-style'; +import { ReactNode, memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +const useStyles = createStyles(({ cx, css, token }) => ({ + small: css` + padding: 2px 6px; + line-height: 1; + `, + tag: cx(css` + color: ${token.colorTextSecondary} !important; + background: ${token.colorFillSecondary}; + border: ${token.borderRadius}px; + + &:hover { + color: ${token.colorText}; + background: ${token.colorFill}; + } + `), +})); + +export interface TagProps extends AntTagProps { + icon?: ReactNode; + size?: 'default' | 'small'; +} + +const Tag = memo<TagProps>(({ icon, children, size = 'default', ...props }) => { + const { styles, cx } = useStyles(); + + return ( + <AntTag + bordered={false} + className={cx(styles.tag, size === 'small' && styles.small)} + {...props} + > + <Flexbox align={'center'} gap={4} horizontal> + {icon} + {children} + </Flexbox> + </AntTag> + ); +}); + +export default Tag; diff --git a/src/hooks/languageMap.ts b/src/hooks/languageMap.ts new file mode 100644 index 00000000..a6782434 --- /dev/null +++ b/src/hooks/languageMap.ts @@ -0,0 +1,220 @@ +export default [ + 'abap', + 'actionscript-3', + 'ada', + 'apache', + 'apex', + 'apl', + 'applescript', + 'ara', + 'asm', + 'astro', + 'awk', + 'ballerina', + 'bat', + 'beancount', + 'berry', + 'bibtex', + 'bicep', + 'blade', + 'c', + 'cadence', + 'clarity', + 'clojure', + 'cmake', + 'cobol', + 'codeql', + 'coffee', + 'cpp', + 'crystal', + 'csharp', + 'css', + 'cue', + 'cypher', + 'd', + 'dart', + 'dax', + 'diff', + 'docker', + 'dream-maker', + 'elixir', + 'elm', + 'erb', + 'erlang', + 'fish', + 'fsharp', + 'gdresource', + 'gdscript', + 'gdshader', + 'gherkin', + 'git-commit', + 'git-rebase', + 'glimmer-js', + 'glimmer-ts', + 'glsl', + 'gnuplot', + 'go', + 'graphql', + 'groovy', + 'hack', + 'haml', + 'handlebars', + 'haskell', + 'hcl', + 'hjson', + 'hlsl', + 'html', + 'http', + 'imba', + 'ini', + 'java', + 'javascript', + 'jinja-html', + 'jison', + 'json', + 'json5', + 'jsonc', + 'jsonl', + 'jsonnet', + 'jssm', + 'jsx', + 'julia', + 'kotlin', + 'kusto', + 'latex', + 'less', + 'liquid', + 'lisp', + 'logo', + 'lua', + 'make', + 'markdown', + 'marko', + 'matlab', + 'mdc', + 'mdx', + 'mermaid', + 'mojo', + 'narrat', + 'nextflow', + 'nginx', + 'nim', + 'nix', + 'objective-c', + 'objective-cpp', + 'ocaml', + 'pascal', + 'perl', + 'php', + 'plsql', + 'postcss', + 'powerquery', + 'powershell', + 'prisma', + 'prolog', + 'proto', + 'pug', + 'puppet', + 'purescript', + 'python', + 'r', + 'raku', + 'razor', + 'reg', + 'rel', + 'riscv', + 'rst', + 'ruby', + 'rust', + 'sas', + 'sass', + 'scala', + 'scheme', + 'scss', + 'shaderlab', + 'shellscript', + 'shellsession', + 'smalltalk', + 'solidity', + 'sparql', + 'splunk', + 'sql', + 'ssh-config', + 'stata', + 'stylus', + 'svelte', + 'swift', + 'system-verilog', + 'tasl', + 'tcl', + 'tex', + 'toml', + 'tsx', + 'turtle', + 'twig', + 'typescript', + 'v', + 'vb', + 'verilog', + 'vhdl', + 'viml', + 'vue', + 'vue-html', + 'vyper', + 'wasm', + 'wenyan', + 'wgsl', + 'wolfram', + 'xml', + 'xsl', + 'yaml', + 'zenscript', + 'zig', + 'bash', + 'batch', + 'be', + 'c#', + 'cdc', + 'clj', + 'cmd', + 'console', + 'cql', + 'cs', + 'dockerfile', + 'erl', + 'f#', + 'fs', + 'fsl', + 'gjs', + 'gts', + 'hbs', + 'hs', + 'jade', + 'js', + 'kql', + 'makefile', + 'md', + 'nar', + 'nf', + 'objc', + 'perl6', + 'properties', + 'ps', + 'ps1', + 'py', + 'ql', + 'rb', + 'rs', + 'sh', + 'shader', + 'shell', + 'spl', + 'styl', + 'ts', + 'vim', + 'vimscript', + 'vy', + 'yml', + 'zsh', + '文言', +] as const; diff --git a/src/hooks/useChatListActionsBar.tsx b/src/hooks/useChatListActionsBar.tsx new file mode 100644 index 00000000..9627dce2 --- /dev/null +++ b/src/hooks/useChatListActionsBar.tsx @@ -0,0 +1,44 @@ +import { Copy, Edit, RotateCw, Trash } from 'lucide-react'; + +import { ActionIconGroupItems } from '@/ActionIconGroup'; + +interface ChatListActionsBar { + copy: ActionIconGroupItems; + del: ActionIconGroupItems; + divider: { type: 'divider' }; + edit: ActionIconGroupItems; + regenerate: ActionIconGroupItems; +} + +export const useChatListActionsBar = (text?: { + copy?: string; + delete?: string; + edit?: string; + regenerate?: string; +}): ChatListActionsBar => { + return { + copy: { + icon: Copy, + key: 'copy', + label: text?.copy || 'Copy', + }, + del: { + icon: Trash, + key: 'del', + label: text?.delete || 'Delete', + }, + divider: { + type: 'divider', + }, + edit: { + icon: Edit, + key: 'edit', + label: text?.edit || 'Edit', + }, + regenerate: { + icon: RotateCw, + key: 'regenerate', + label: text?.regenerate || 'Regenerate', + }, + }; +}; diff --git a/src/hooks/useCopied.ts b/src/hooks/useCopied.ts new file mode 100644 index 00000000..360e45f4 --- /dev/null +++ b/src/hooks/useCopied.ts @@ -0,0 +1,21 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +export const useCopied = () => { + const [copied, setCopy] = useState(false); + + useEffect(() => { + if (!copied) return; + + const timer = setTimeout(() => { + setCopy(false); + }, 2000); + + return () => { + clearTimeout(timer); + }; + }, [copied]); + + const setCopied = useCallback(() => setCopy(true), []); + + return useMemo(() => ({ copied, setCopied }), [copied]); +}; diff --git a/src/index.ts b/src/index.ts index 6a7a2cb5..1a703aa2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,34 @@ -export const hello = () => { - return 'world'; -}; +export { default as ActionIcon, type ActionIconProps, type ActionIconSize } from './ActionIcon'; +export { default as ActionIconGroup, type ActionIconGroupProps } from './ActionIconGroup'; +export { default as BackBottom, type BackBottomProps } from './BackBottom'; +export { default as ChatItem, type ChatItemProps } from './ChatItem'; +export { default as ChatList } from './ChatList'; +export type { + ChatListProps, + OnActionClick, + OnMessageChange, + RenderAction, + RenderErrorMessage, + RenderItem, + RenderMessage, + RenderMessageExtra, +} from './ChatList'; +export { default as ActionsBar, type ActionsBarProps } from './ChatList/ActionsBar'; +export * from './ProChat'; + +export { default as EditableMessage, type EditableMessageProps } from './EditableMessage'; +export { + default as EditableMessageList, + type EditableMessageListProps, +} from './EditableMessageList'; +export { default as CopyButton, type CopyButtonProps } from './components/CopyButton'; + +export { default as List } from './List'; + +export { default as MessageInput, type MessageInputProps } from './MessageInput'; +export { default as MessageModal, type MessageModalProps } from './MessageModal'; + +export { default as TokenTag, type TokenTagProps } from './TokenTag'; +export { useChatListActionsBar } from './hooks/useChatListActionsBar'; +export * from './styles'; +export type * from './types'; diff --git a/src/styles/colors.ts b/src/styles/colors.ts new file mode 100644 index 00000000..897a8a47 --- /dev/null +++ b/src/styles/colors.ts @@ -0,0 +1,909 @@ +type colorStep = [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, +]; +export interface ColorScaleItem { + dark: colorStep; + darkA: colorStep; + light: colorStep; + lightA: colorStep; +} + +export interface ColorScales { + blue: ColorScaleItem; + bnw: ColorScaleItem; + cyan: ColorScaleItem; + geekblue: ColorScaleItem; + gold: ColorScaleItem; + gray: ColorScaleItem; + green: ColorScaleItem; + lime: ColorScaleItem; + magenta: ColorScaleItem; + orange: ColorScaleItem; + purple: ColorScaleItem; + red: ColorScaleItem; + volcano: ColorScaleItem; + yellow: ColorScaleItem; +} + +export const colorScales: ColorScales = { + blue: { + dark: [ + '#000506', + '#002126', + '#00363f', + '#004e59', + '#006675', + '#008093', + '#159ab0', + '#47b3ca', + '#6acde4', + '#8ae8ff', + '#b8f0ff', + '#def7ff', + '#ffffff', + ], + darkA: [ + 'rgba(0, 167, 200, 0.03)', + 'rgba(0, 220, 253, 0.15)', + 'rgba(0, 216, 252, 0.25)', + 'rgba(0, 223, 254, 0.35)', + 'rgba(0, 222, 254, 0.46)', + 'rgba(0, 221, 253, 0.58)', + 'rgba(30, 223, 255, 0.69)', + 'rgba(89, 224, 252, 0.8)', + 'rgba(118, 228, 253, 0.9)', + '#8ae8ff', + '#b8f0ff', + '#def7ff', + '#ffffff', + ], + light: [ + '#ffffff', + '#fbfeff', + '#f4fcff', + '#eafaff', + '#dff7ff', + '#d3f5ff', + '#c4f2ff', + '#b4efff', + '#a1ecff', + '#8ae8ff', + '#159ab0', + '#004e59', + '#000506', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(55, 205, 255, 0.02)', + 'rgba(35, 195, 255, 0.05)', + 'rgba(22, 199, 255, 0.09)', + 'rgba(9, 193, 255, 0.13)', + 'rgba(11, 199, 255, 0.18)', + 'rgba(9, 201, 255, 0.24)', + 'rgba(5, 202, 255, 0.3)', + 'rgba(1, 204, 255, 0.37)', + 'rgba(1, 205, 255, 0.46)', + 'rgba(1, 145, 169, 0.92)', + '#004e59', + '#000506', + ], + }, + bnw: { + dark: [ + '#000000', + '#111111', + '#333333', + '#555555', + '#666666', + '#888888', + '#aaaaaa', + '#cccccc', + '#dddddd', + '#eeeeee', + '#ffffff', + '#ffffff', + '#ffffff', + ], + darkA: [ + 'rgba(255, 255, 255, 0.02)', + 'rgba(255, 255, 255, 0.08)', + 'rgba(255, 255, 255, 0.16)', + 'rgba(255, 255, 255, 0.22)', + 'rgba(255, 255, 255, 0.36)', + 'rgba(255, 255, 255, 0.48)', + 'rgba(255, 255, 255, 0.6)', + 'rgba(255, 255, 255, 0.72)', + 'rgba(255, 255, 255, 0.84)', + 'rgba(255, 255, 255, 0.88)', + 'rgba(255, 255, 255, 0.92)', + 'rgba(255, 255, 255, 0.96)', + 'rgba(255, 255, 255, 0.98)', + ], + light: [ + '#ffffff', + '#f5f5f5', + '#eeeeee', + '#cccccc', + '#aaaaaa', + '#888888', + '#666666', + '#444444', + '#333333', + '#222222', + '#111111', + '#111111', + '#111111', + ], + lightA: [ + 'rgba(0, 0, 0, 0.02)', + 'rgba(0, 0, 0, 0.08)', + 'rgba(0, 0, 0, 0.16)', + 'rgba(0, 0, 0, 0.22)', + 'rgba(0, 0, 0, 0.36)', + 'rgba(0, 0, 0, 0.48)', + 'rgba(0, 0, 0, 0.6)', + 'rgba(0, 0, 0, 0.72)', + 'rgba(0, 0, 0, 0.84)', + 'rgba(0, 0, 0, 0.88)', + 'rgba(0, 0, 0, 0.92)', + 'rgba(0, 0, 0, 0.96)', + 'rgba(0, 0, 0, 0.98)', + ], + }, + cyan: { + dark: [ + '#000503', + '#00221c', + '#003930', + '#005245', + '#006c5b', + '#008772', + '#2fa28a', + '#55bca4', + '#75d7be', + '#95f3d9', + '#bdf7e4', + '#dffcf0', + '#ffffff', + ], + darkA: [ + 'rgba(0, 250, 150, 0.02)', + 'rgba(0, 243, 200, 0.14)', + 'rgba(0, 248, 209, 0.23)', + 'rgba(0, 248, 209, 0.33)', + 'rgba(0, 251, 212, 0.43)', + 'rgba(0, 255, 215, 0.53)', + 'rgba(73, 253, 216, 0.64)', + 'rgba(115, 254, 222, 0.74)', + 'rgba(138, 253, 224, 0.85)', + 'rgba(155, 253, 226, 0.96)', + 'rgba(195, 255, 235, 0.97)', + 'rgba(225, 255, 242, 0.99)', + '#ffffff', + ], + light: [ + '#ffffff', + '#f9fffb', + '#effff8', + '#e3fff4', + '#d8fef0', + '#ccfcec', + '#c0fae8', + '#b3f8e3', + '#a5f6de', + '#95f3d9', + '#2fa28a', + '#005245', + '#000503', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(55, 255, 122, 0.03)', + 'rgba(26, 255, 155, 0.07)', + 'rgba(0, 255, 155, 0.11)', + 'rgba(11, 249, 161, 0.16)', + 'rgba(0, 240, 160, 0.2)', + 'rgba(3, 235, 163, 0.25)', + 'rgba(2, 232, 162, 0.3)', + 'rgba(5, 230, 163, 0.36)', + 'rgba(3, 226, 165, 0.42)', + 'rgba(1, 142, 112, 0.82)', + '#005245', + '#000503', + ], + }, + geekblue: { + dark: [ + '#000216', + '#001343', + '#00225c', + '#003176', + '#00418f', + '#0052a8', + '#0264c1', + '#1877d5', + '#288aea', + '#369eff', + '#88bffb', + '#c5dffd', + '#ffffff', + ], + darkA: [ + 'rgba(0, 22, 244, 0.09)', + 'rgba(0, 70, 248, 0.27)', + 'rgba(0, 92, 249, 0.37)', + 'rgba(0, 104, 251, 0.47)', + 'rgba(0, 116, 255, 0.56)', + 'rgba(0, 124, 255, 0.66)', + 'rgba(3, 132, 254, 0.76)', + 'rgba(29, 142, 254, 0.84)', + 'rgba(43, 150, 254, 0.92)', + '#369eff', + 'rgba(137, 193, 254, 0.99)', + '#c5dffd', + '#ffffff', + ], + light: [ + '#ffffff', + '#f8faff', + '#eaf3ff', + '#daeaff', + '#c7e0ff', + '#b1d5ff', + '#9ac9ff', + '#7fbcff', + '#60aeff', + '#369eff', + '#0264c1', + '#003176', + '#000216', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(22, 88, 255, 0.03)', + 'rgba(22, 122, 255, 0.09)', + 'rgba(8, 115, 255, 0.15)', + 'rgba(0, 114, 255, 0.22)', + 'rgba(3, 120, 255, 0.31)', + 'rgba(3, 120, 255, 0.4)', + 'rgba(4, 124, 255, 0.51)', + 'rgba(3, 126, 255, 0.63)', + 'rgba(1, 132, 255, 0.79)', + '#0264c1', + '#003176', + '#000216', + ], + }, + gold: { + dark: [ + '#070300', + '#271a00', + '#3f2c00', + '#593f00', + '#745400', + '#906a00', + '#ac8100', + '#c99811', + '#e4b12f', + '#ffcb47', + '#ffdd90', + '#ffeecd', + '#ffffff', + ], + darkA: [ + 'rgba(233, 100, 0, 0.03)', + 'rgba(244, 163, 0, 0.16)', + 'rgba(252, 176, 0, 0.25)', + 'rgba(254, 180, 0, 0.35)', + 'rgba(252, 183, 0, 0.46)', + 'rgba(253, 186, 0, 0.57)', + 'rgba(253, 190, 0, 0.68)', + 'rgba(254, 192, 22, 0.79)', + 'rgba(253, 197, 52, 0.9)', + '#ffcb47', + '#ffdd90', + '#ffeecd', + '#ffffff', + ], + light: [ + '#ffffff', + '#fffcff', + '#fff8f2', + '#fff4e2', + '#ffefd0', + '#ffe9bb', + '#ffe3a4', + '#ffdb8b', + '#ffd46d', + '#ffcb47', + '#ac8100', + '#593f00', + '#070300', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(255, 105, 255, 0.02)', + 'rgba(255, 138, 38, 0.06)', + 'rgba(255, 163, 13, 0.12)', + 'rgba(255, 171, 8, 0.19)', + 'rgba(255, 174, 3, 0.27)', + 'rgba(255, 177, 2, 0.36)', + 'rgba(255, 177, 3, 0.46)', + 'rgba(255, 181, 3, 0.58)', + 'rgba(255, 184, 3, 0.73)', + '#ac8100', + '#593f00', + '#070300', + ], + }, + gray: { + dark: [ + '#000000', + '#111111', + '#222222', + '#2d2d2d', + '#333333', + '#444444', + '#555555', + '#666666', + '#6f6f6f', + '#777777', + '#aaaaaa', + '#dddddd', + '#ffffff', + ], + darkA: [ + 'rgba(255, 255, 255, 0.02)', + 'rgba(255, 255, 255, 0.06)', + 'rgba(255, 255, 255, 0.10)', + 'rgba(255, 255, 255, 0.16)', + 'rgba(255, 255, 255, 0.24)', + 'rgba(255, 255, 255, 0.28)', + 'rgba(255, 255, 255, 0.32)', + 'rgba(255, 255, 255, 0.38)', + 'rgba(255, 255, 255, 0.44)', + 'rgba(255, 255, 255, 0.5)', + 'rgba(255, 255, 255, 0.66)', + 'rgba(255, 255, 255, 0.84)', + '#ffffff', + ], + light: [ + '#ffffff', + '#f8f8f8', + '#eeeeee', + '#e3e3e3', + '#dddddd', + '#cccccc', + '#bbbbbb', + '#aaaaaa', + '#999999', + '#888888', + '#666666', + '#333333', + '#080808', + ], + lightA: [ + 'rgba(0, 0, 0, 0.015)', + 'rgba(0, 0, 0, 0.03)', + 'rgba(0, 0, 0, 0.06)', + 'rgba(0, 0, 0, 0.12)', + 'rgba(0, 0, 0, 0.18)', + 'rgba(0, 0, 0, 0.24)', + 'rgba(0, 0, 0, 0.32)', + 'rgba(0, 0, 0, 0.38)', + 'rgba(0, 0, 0, 0.44)', + 'rgba(0, 0, 0, 0.5)', + 'rgba(0, 0, 0, 0.68)', + 'rgba(0, 0, 0, 0.84)', + 'rgba(0, 0, 0, 0.98)', + ], + }, + green: { + dark: [ + '#000503', + '#001d12', + '#002d1d', + '#003f28', + '#005232', + '#00653c', + '#007944', + '#1b8d4d', + '#3ba05a', + '#55b467', + '#96cd92', + '#cde6c3', + '#ffffff', + ], + darkA: [ + 'rgba(0, 250, 150, 0.02)', + 'rgba(0, 242, 150, 0.12)', + 'rgba(0, 250, 161, 0.18)', + 'rgba(0, 252, 160, 0.25)', + 'rgba(0, 248, 152, 0.33)', + 'rgba(0, 252, 150, 0.4)', + 'rgba(0, 252, 142, 0.48)', + 'rgba(48, 252, 137, 0.56)', + 'rgba(94, 254, 143, 0.63)', + 'rgba(120, 254, 145, 0.71)', + 'rgba(185, 253, 180, 0.81)', + 'rgba(225, 253, 214, 0.91)', + '#ffffff', + ], + light: [ + '#ffffff', + '#f4fdeb', + '#e7f8dd', + '#d8f2ce', + '#c7eabd', + '#b4e1ac', + '#a0d79b', + '#89cc8a', + '#71c179', + '#55b467', + '#007944', + '#003f28', + '#000503', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(117, 230, 5, 0.08)', + 'rgba(84, 205, 12, 0.14)', + 'rgba(60, 190, 10, 0.2)', + 'rgba(40, 174, 1, 0.26)', + 'rgba(28, 164, 3, 0.33)', + 'rgba(18, 155, 5, 0.4)', + 'rgba(4, 146, 6, 0.47)', + 'rgba(1, 144, 16, 0.56)', + 'rgba(1, 143, 28, 0.67)', + '#007944', + '#003f28', + '#000503', + ], + }, + lime: { + dark: [ + '#020400', + '#142100', + '#253700', + '#374f00', + '#4b6800', + '#608200', + '#769d00', + '#8fb81b', + '#a9d42f', + '#c4f042', + '#daf685', + '#eefbbe', + '#ffffff', + ], + darkA: [ + 'rgba(100, 200, 0, 0.02)', + 'rgba(154, 254, 0, 0.13)', + 'rgba(168, 250, 0, 0.22)', + 'rgba(177, 255, 0, 0.31)', + 'rgba(183, 254, 0, 0.41)', + 'rgba(188, 255, 0, 0.51)', + 'rgba(190, 253, 0, 0.62)', + 'rgba(196, 252, 37, 0.73)', + 'rgba(204, 255, 57, 0.83)', + 'rgba(209, 255, 70, 0.94)', + 'rgba(225, 254, 137, 0.97)', + 'rgba(240, 254, 192, 0.99)', + '#ffffff', + ], + light: [ + '#ffffff', + '#feffeb', + '#f9ffd8', + '#f2ffc1', + '#ebfdaf', + '#e4fc9b', + '#ddf987', + '#d5f773', + '#cdf35c', + '#c4f042', + '#769d00', + '#374f00', + '#020400', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(242, 255, 5, 0.08)', + 'rgba(218, 255, 11, 0.16)', + 'rgba(203, 255, 7, 0.25)', + 'rgba(193, 249, 5, 0.32)', + 'rgba(187, 247, 5, 0.4)', + 'rgba(183, 242, 0, 0.47)', + 'rgba(179, 240, 0, 0.55)', + 'rgba(177, 236, 0, 0.64)', + 'rgba(175, 235, 0, 0.74)', + '#769d00', + '#374f00', + '#020400', + ], + }, + magenta: { + dark: [ + '#100002', + '#350011', + '#4b001e', + '#63002d', + '#79093f', + '#8e1752', + '#a32466', + '#b8317b', + '#ce3e91', + '#e34ba9', + '#f38bcb', + '#fec5e8', + '#ffffff', + ], + darkA: [ + 'rgba(229, 0, 29, 0.07)', + 'rgba(252, 0, 81, 0.21)', + 'rgba(250, 0, 100, 0.3)', + 'rgba(254, 0, 115, 0.39)', + 'rgba(252, 19, 131, 0.48)', + 'rgba(254, 41, 146, 0.56)', + 'rgba(255, 56, 159, 0.64)', + 'rgba(252, 67, 168, 0.73)', + 'rgba(254, 77, 179, 0.81)', + 'rgba(255, 84, 190, 0.89)', + 'rgba(253, 145, 211, 0.96)', + '#fec5e8', + '#ffffff', + ], + light: [ + '#ffffff', + '#fff7f9', + '#ffeaf4', + '#ffdaee', + '#ffc7e7', + '#ffb2df', + '#ff99d6', + '#f980ca', + '#ef67ba', + '#e34ba9', + '#a32466', + '#63002d', + '#100002', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(255, 55, 105, 0.04)', + 'rgba(255, 22, 133, 0.09)', + 'rgba(255, 8, 142, 0.15)', + 'rgba(255, 0, 146, 0.22)', + 'rgba(255, 7, 152, 0.31)', + 'rgba(255, 0, 153, 0.4)', + 'rgba(243, 1, 149, 0.5)', + 'rgba(228, 2, 140, 0.6)', + 'rgba(216, 1, 134, 0.71)', + 'rgba(148, 0, 77, 0.86)', + '#63002d', + '#100002', + ], + }, + orange: { + dark: [ + '#080300', + '#271400', + '#3d2000', + '#552d00', + '#6f3a00', + '#8a4700', + '#a75400', + '#c66100', + '#e37013', + '#ff802b', + '#ffae87', + '#ffd7c8', + '#ffffff', + ], + darkA: [ + 'rgba(200, 75, 0, 0.04)', + 'rgba(244, 125, 0, 0.16)', + 'rgba(254, 133, 0, 0.24)', + 'rgba(250, 132, 0, 0.34)', + 'rgba(252, 132, 0, 0.44)', + 'rgba(251, 129, 0, 0.55)', + 'rgba(253, 127, 0, 0.66)', + 'rgba(254, 124, 0, 0.78)', + 'rgba(255, 126, 21, 0.89)', + '#ff802b', + '#ffae87', + '#ffd7c8', + '#ffffff', + ], + light: [ + '#ffffff', + '#fff9f8', + '#fff0ec', + '#ffe6dd', + '#ffd9ca', + '#ffcbb5', + '#ffbb9c', + '#ffaa7f', + '#ff975c', + '#ff802b', + '#a75400', + '#552d00', + '#080300', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(255, 55, 22, 0.03)', + 'rgba(255, 67, 17, 0.08)', + 'rgba(255, 76, 12, 0.14)', + 'rgba(255, 74, 3, 0.21)', + 'rgba(255, 76, 0, 0.29)', + 'rgba(255, 81, 1, 0.39)', + 'rgba(255, 88, 4, 0.51)', + 'rgba(255, 93, 0, 0.64)', + 'rgba(255, 102, 0, 0.83)', + '#a75400', + '#552d00', + '#080300', + ], + }, + purple: { + dark: [ + '#0d000b', + '#2e002a', + '#42003e', + '#560053', + '#670e66', + '#781e78', + '#892b8a', + '#9a399e', + '#ab46b2', + '#bd54c6', + '#d590da', + '#edc7ee', + '#ffffff', + ], + darkA: [ + 'rgba(217, 0, 183, 0.06)', + 'rgba(242, 0, 221, 0.19)', + 'rgba(254, 0, 238, 0.26)', + 'rgba(253, 0, 244, 0.34)', + 'rgba(251, 34, 249, 0.41)', + 'rgba(255, 64, 255, 0.47)', + 'rgba(249, 78, 251, 0.55)', + 'rgba(248, 92, 255, 0.62)', + 'rgba(244, 100, 254, 0.7)', + 'rgba(242, 108, 254, 0.78)', + 'rgba(248, 167, 253, 0.86)', + 'rgba(252, 212, 253, 0.94)', + '#ffffff', + ], + light: [ + '#ffffff', + '#fff6fb', + '#ffe7fd', + '#fdd6fe', + '#f6c4f8', + '#eeb1f1', + '#e49ce8', + '#d886de', + '#cb6ed2', + '#bd54c6', + '#892b8a', + '#560053', + '#0d000b', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(255, 30, 155, 0.04)', + 'rgba(255, 15, 235, 0.1)', + 'rgba(243, 14, 249, 0.17)', + 'rgba(218, 9, 226, 0.24)', + 'rgba(200, 3, 210, 0.31)', + 'rgba(186, 1, 196, 0.39)', + 'rgba(174, 3, 186, 0.48)', + 'rgba(164, 1, 176, 0.57)', + 'rgba(156, 0, 170, 0.67)', + 'rgba(113, 0, 114, 0.83)', + '#560053', + '#0d000b', + ], + }, + red: { + dark: [ + '#0f0006', + '#34001d', + '#4b002b', + '#640039', + '#7a0c46', + '#911b53', + '#a72860', + '#bf356e', + '#d7427b', + '#f04f88', + '#ff8eab', + '#ffc9d3', + '#ffffff', + ], + darkA: [ + 'rgba(250, 0, 100, 0.06)', + 'rgba(248, 0, 138, 0.21)', + 'rgba(250, 0, 143, 0.3)', + 'rgba(250, 0, 142, 0.4)', + 'rgba(254, 25, 146, 0.48)', + 'rgba(254, 47, 146, 0.57)', + 'rgba(253, 61, 145, 0.66)', + 'rgba(255, 71, 147, 0.75)', + 'rgba(253, 78, 145, 0.85)', + 'rgba(255, 84, 145, 0.94)', + '#ff8eab', + '#ffc9d3', + '#ffffff', + ], + light: [ + '#ffffff', + '#fff7f7', + '#ffeced', + '#ffdde2', + '#ffccd5', + '#ffb8c7', + '#ffa2b8', + '#ff88a8', + '#fe6998', + '#f04f88', + '#a72860', + '#640039', + '#0f0006', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(255, 55, 55, 0.04)', + 'rgba(255, 17, 30, 0.08)', + 'rgba(255, 12, 48, 0.14)', + 'rgba(255, 0, 45, 0.2)', + 'rgba(255, 1, 55, 0.28)', + 'rgba(255, 4, 63, 0.37)', + 'rgba(255, 2, 70, 0.47)', + 'rgba(253, 1, 80, 0.59)', + 'rgba(233, 0, 83, 0.69)', + 'rgba(151, 2, 68, 0.85)', + '#640039', + '#0f0006', + ], + }, + volcano: { + dark: [ + '#0c0100', + '#2f0a00', + '#451200', + '#5d1900', + '#762000', + '#8e2a07', + '#a53716', + '#bc4424', + '#d45132', + '#ec5e41', + '#ff9480', + '#ffcbc3', + '#ffffff', + ], + darkA: [ + 'rgba(240, 20, 0, 0.05)', + 'rgba(247, 53, 0, 0.19)', + 'rgba(246, 64, 0, 0.28)', + 'rgba(251, 68, 0, 0.37)', + 'rgba(251, 68, 0, 0.47)', + 'rgba(254, 75, 12, 0.56)', + 'rgba(254, 85, 34, 0.65)', + 'rgba(254, 92, 49, 0.74)', + 'rgba(255, 98, 60, 0.83)', + 'rgba(254, 101, 70, 0.93)', + '#ff9480', + '#ffcbc3', + '#ffffff', + ], + light: [ + '#ffffff', + '#fff7f6', + '#ffece9', + '#ffded9', + '#ffcec5', + '#ffbbaf', + '#ffa695', + '#ff8e78', + '#fb745a', + '#ec5e41', + '#a53716', + '#5d1900', + '#0c0100', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(255, 55, 30, 0.04)', + 'rgba(255, 44, 11, 0.09)', + 'rgba(255, 35, 2, 0.15)', + 'rgba(255, 42, 3, 0.23)', + 'rgba(255, 43, 5, 0.32)', + 'rgba(255, 43, 3, 0.42)', + 'rgba(255, 42, 0, 0.53)', + 'rgba(249, 41, 1, 0.65)', + 'rgba(230, 40, 2, 0.75)', + 'rgba(157, 38, 2, 0.92)', + '#5d1900', + '#0c0100', + ], + }, + yellow: { + dark: [ + '#050400', + '#251d00', + '#3e3300', + '#584a00', + '#736300', + '#8e7d00', + '#ab9800', + '#c7b426', + '#e3d142', + '#ffef5c', + '#fff594', + '#fffad3', + '#ffffff', + ], + darkA: [ + 'rgba(250, 200, 0, 0.02)', + 'rgba(247, 193, 0, 0.15)', + 'rgba(248, 204, 0, 0.25)', + 'rgba(251, 211, 0, 0.35)', + 'rgba(250, 215, 0, 0.46)', + 'rgba(254, 223, 0, 0.56)', + 'rgba(255, 227, 0, 0.67)', + 'rgba(255, 231, 49, 0.78)', + 'rgba(255, 235, 74, 0.89)', + '#ffef5c', + '#fff594', + '#fffad3', + '#ffffff', + ], + light: [ + '#ffffff', + '#fffeff', + '#fffcff', + '#fffbf1', + '#fffada', + '#fff9c2', + '#fff7aa', + '#fff592', + '#fff279', + '#ffef5c', + '#ab9800', + '#584a00', + '#050400', + ], + lightA: [ + 'rgba(255, 255, 255, 0.01)', + 'rgba(255, 155, 255, 0.01)', + 'rgba(255, 105, 255, 0.02)', + 'rgba(255, 188, 22, 0.06)', + 'rgba(255, 222, 8, 0.15)', + 'rgba(255, 230, 1, 0.24)', + 'rgba(255, 231, 5, 0.34)', + 'rgba(255, 232, 2, 0.43)', + 'rgba(255, 230, 2, 0.53)', + 'rgba(255, 230, 0, 0.64)', + '#ab9800', + '#584a00', + '#050400', + ], + }, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts new file mode 100644 index 00000000..63738ce6 --- /dev/null +++ b/src/styles/index.ts @@ -0,0 +1 @@ +export { colorScales } from './colors'; diff --git a/src/styles/stylish.ts b/src/styles/stylish.ts new file mode 100644 index 00000000..99ba13e1 --- /dev/null +++ b/src/styles/stylish.ts @@ -0,0 +1,238 @@ +import { createStylish, keyframes } from 'antd-style'; + +export const useStylish = createStylish(({ css, token, isDarkMode }) => { + const gradient = keyframes` + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } + `; + + const cyanColor = isDarkMode ? token.cyan9A : token.cyan11A; + const cyanBackground = isDarkMode ? token.cyan2A : token.cyan6A; + + return { + blur: css` + backdrop-filter: saturate(180%) blur(10px); + `, + blurStrong: css` + backdrop-filter: blur(36px); + `, + bottomScrollbar: css` + ::-webkit-scrollbar { + width: 0; + height: 4px; + background-color: transparent; + + &-thumb { + background-color: ${token.colorFill}; + border-radius: 4px; + transition: background-color 500ms ${token.motionEaseOut}; + } + + &-corner { + display: none; + width: 0; + height: 0; + } + } + `, + gradientAnimation: css` + background-image: linear-gradient( + -45deg, + ${token.gold}, + ${token.magenta}, + ${token.geekblue}, + ${token.cyan} + ); + background-size: 400% 400%; + border-radius: inherit; + animation: 5s ${gradient} 5s ease infinite; + `, + markdown: css` + color: ${token.colorText}; + + h1, + h2, + h3, + h4, + h5 { + font-weight: 600; + } + + p { + margin-block-start: 0; + margin-block-end: 0; + + font-size: 14px; + line-height: 1.8; + color: ${token.colorText}; + word-break: break-all; + word-wrap: break-word; + + + * { + margin-block-end: 0.5em; + } + } + + > *:last-child { + margin-bottom: 0 !important; + } + + blockquote { + margin: 16px 0; + padding: 0 12px; + + p { + font-style: italic; + color: ${token.colorTextDescription}; + } + } + + p:not(:last-child) { + margin-bottom: 1em; + } + + a { + color: ${token.colorLink}; + + &:hover { + color: ${token.colorLinkHover}; + } + + &:active { + color: ${token.colorLinkActive}; + } + } + + img { + max-width: 100%; + } + + pre, + [data-code-type='highlighter'] { + border: none; + border-radius: ${token.borderRadius}px; + + > code { + padding: 0 !important; + border: none !important; + } + } + + > :not([data-code-type='highlighter']) code { + padding: 2px 6px; + + font-size: ${token.fontSizeSM}px; + color: ${cyanColor}; + + background: ${cyanBackground}; + border: 1px solid ${isDarkMode ? token.cyan1A : token.cyan6A}; + border-radius: ${token.borderRadiusSM}px; + } + + table { + border-spacing: 0; + + width: 100%; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0; + margin-inline-end: 0; + padding: 8px; + + border: 1px solid ${token.colorBorderSecondary}; + border-radius: ${token.borderRadius}px; + + code { + display: inline-flex; + } + } + + th, + td { + padding-block-start: 10px; + padding-block-end: 10px; + padding-inline-start: 16px; + padding-inline-end: 16px; + } + + thead { + tr { + th { + background: ${token.colorFillTertiary}; + + &:first-child { + border-top-left-radius: ${token.borderRadius}px; + border-bottom-left-radius: ${token.borderRadius}px; + } + + &:last-child { + border-top-right-radius: ${token.borderRadius}px; + border-bottom-right-radius: ${token.borderRadius}px; + } + } + } + } + + > ol > li::marker { + color: ${isDarkMode ? token.cyan9A : token.cyan10A} !important; + } + + > ul > li { + line-height: 1.8; + list-style-type: disc; + + &::marker { + color: ${isDarkMode ? token.cyan9A : token.cyan10A} !important; + } + } + + ol, + ul { + > li::marker { + color: ${token.colorTextDescription}; + } + } + + details { + margin-bottom: 1em; + padding: 12px 16px; + + background: ${token.colorFillTertiary}; + border: 1px solid ${token.colorBorderSecondary}; + border-radius: ${token.borderRadiusLG}px; + + transition: all 400ms ${token.motionEaseOut}; + } + + details[open] { + summary { + padding-bottom: 12px; + border-bottom: 1px solid ${token.colorBorder}; + } + } + `, + noScrollbar: css` + ::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + background-color: transparent; + } + `, + resetLinkColor: css` + cursor: pointer; + color: ${token.colorTextSecondary}; + + &:hover { + color: ${token.colorText}; + } + `, + }; +}); diff --git a/src/types/customStylish.ts b/src/types/customStylish.ts new file mode 100644 index 00000000..d75c0ed8 --- /dev/null +++ b/src/types/customStylish.ts @@ -0,0 +1,9 @@ +export interface LobeCustomStylish { + blur: string; + blurStrong: string; + bottomScrollbar: string; + gradientAnimation: string; + markdown: string; + noScrollbar: string; + resetLinkColor: string; +} diff --git a/src/types/customToken.ts b/src/types/customToken.ts new file mode 100644 index 00000000..9ae52cf9 --- /dev/null +++ b/src/types/customToken.ts @@ -0,0 +1,48 @@ +const PresetColors = [ + 'red', + 'volcano', + 'orange', + 'gold', + 'yellow', + 'lime', + 'green', + 'cyan', + 'blue', + 'geekblue', + 'purple', + 'magenta', + 'gray', +] as const; + +export type PresetColorKey = (typeof PresetColors)[number]; + +export type PresetColorType = Record<PresetColorKey, string>; + +type ColorPaletteKeyIndex = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11; + +type ColorTokenKey = + | 'Bg' + | 'BgHover' + | 'Border' + | 'BorderSecondary' + | 'BorderHover' + | 'Hover' + | '' + | 'Active' + | 'TextHover' + | 'Text' + | 'TextActive'; + +export type ColorToken = { + [key in `${keyof PresetColorType}${ColorTokenKey}`]: string; +}; + +export type ColorPalettes = { + [key in `${keyof PresetColorType}${ColorPaletteKeyIndex}`]: string; +}; + +export type ColorPalettesAlpha = { + [key in `${keyof PresetColorType}${ColorPaletteKeyIndex}A`]: string; +}; + +export interface LobeCustomToken extends ColorToken, ColorPalettes, ColorPalettesAlpha {} diff --git a/src/types/error.ts b/src/types/error.ts new file mode 100644 index 00000000..334410cc --- /dev/null +++ b/src/types/error.ts @@ -0,0 +1,29 @@ +export const ChatErrorType = { + // ******* 业务错误语义 ******* // + + InvalidAccessCode: 'InvalidAccessCode', // 密码无效 + OpenAIBizError: 'OpenAIBizError', // OpenAI 返回的业务错误 + NoAPIKey: 'NoAPIKey', + + // ******* 客户端错误 ******* // + BadRequest: 400, + Unauthorized: 401, + Forbidden: 403, + ContentNotFound: 404, // 没找到接口 + MethodNotAllowed: 405, // 不支持 + TooManyRequests: 429, + + // ******* 服务端错误 ******* // + InternalServerError: 500, + BadGateway: 502, + ServiceUnavailable: 503, + GatewayTimeout: 504, +} as const; +/* eslint-enable */ + +export type ErrorType = (typeof ChatErrorType)[keyof typeof ChatErrorType]; + +export interface ErrorResponse { + body: any; + errorType: ErrorType; +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 00000000..87f5f1c6 --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,11 @@ +import 'antd-style'; + +import { LobeCustomStylish } from './customStylish'; +import { LobeCustomToken } from './customToken'; + +declare module 'antd-style' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + export interface CustomToken extends LobeCustomToken {} + // eslint-disable-next-line @typescript-eslint/no-empty-interface + export interface CustomStylish extends LobeCustomStylish {} +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..04ac9ea8 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,17 @@ +import { type HTMLAttributes } from 'react'; + +export * from './customStylish'; +export * from './customToken'; +export * from './llm'; +export * from './message'; +export * from './meta'; + +export type DivProps = HTMLAttributes<HTMLDivElement>; + +export type SvgProps = HTMLAttributes<SVGSVGElement>; + +export type ImgProps = HTMLAttributes<HTMLImageElement>; + +export type InputProps = HTMLAttributes<HTMLInputElement>; + +export type TextAreaProps = HTMLAttributes<HTMLTextAreaElement>; diff --git a/src/types/llm.ts b/src/types/llm.ts new file mode 100644 index 00000000..8cdce086 --- /dev/null +++ b/src/types/llm.ts @@ -0,0 +1,36 @@ +// 语言模型的设置参数 +export interface LLMParams { + /** + * 控制生成文本中的惩罚系数,用于减少重复性 + * @default 0 + */ + frequency_penalty?: number; + /** + * 生成文本的最大长度 + */ + max_tokens?: number; + /** + * 控制生成文本中的惩罚系数,用于减少主题的变化 + * @default 0 + */ + presence_penalty?: number; + /** + * 生成文本的随机度量,用于控制文本的创造性和多样性 + * @default 0.6 + */ + temperature?: number; + /** + * 控制生成文本中最高概率的单个 token + * @default 1 + */ + top_p?: number; +} + +export type LLMRoleType = 'user' | 'system' | 'assistant' | 'function'; + +export interface LLMMessage { + content: string; + role: LLMRoleType; +} + +export type LLMExample = LLMMessage[]; diff --git a/src/types/message.ts b/src/types/message.ts new file mode 100644 index 00000000..1e48971e --- /dev/null +++ b/src/types/message.ts @@ -0,0 +1,38 @@ +import { ModelRoleType } from '@/ProChat/types/config'; + +/** + * 聊天消息错误对象 + */ +export interface ChatMessageError { + body?: any; + message: string; + type: string | number; +} + +export interface ChatMessage { + /** + * @title 内容 + * @description 消息内容 + */ + content: string; + error?: any; + model?: string; + name?: string; + parentId?: string; + /** + * 角色 + * @description 消息发送者的角色 + */ + role: ModelRoleType | string; + createAt: number; + id: string; + updateAt: number; + extra?: Record<string, any>; +} + +export type ChatMessageMap = Record<string, ChatMessage>; + +export interface OpenAIFunctionCall { + arguments?: string; + name: string; +} diff --git a/src/types/meta.ts b/src/types/meta.ts new file mode 100644 index 00000000..3a6b2408 --- /dev/null +++ b/src/types/meta.ts @@ -0,0 +1,24 @@ +export interface MetaData { + /** + * 角色头像 + * @description 可选参数,如果不传则使用默认头像 + */ + avatar?: string; + /** + * 背景色 + * @description 可选参数,如果不传则使用默认背景色 + */ + backgroundColor?: string; + /** + * 名称 + * @description 可选参数,如果不传则使用默认名称 + */ + title?: string; +} + +export interface BaseDataModel { + createAt: number; + id: string; + meta: MetaData; + updateAt: number; +} diff --git a/tests/__snapshots__/index.test.ts.snap b/tests/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000..cbfc1134 --- /dev/null +++ b/tests/__snapshots__/index.test.ts.snap @@ -0,0 +1,874 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`colorScales 1`] = ` +{ + "blue": { + "dark": [ + "#000506", + "#002126", + "#00363f", + "#004e59", + "#006675", + "#008093", + "#159ab0", + "#47b3ca", + "#6acde4", + "#8ae8ff", + "#b8f0ff", + "#def7ff", + "#ffffff", + ], + "darkA": [ + "rgba(0, 167, 200, 0.03)", + "rgba(0, 220, 253, 0.15)", + "rgba(0, 216, 252, 0.25)", + "rgba(0, 223, 254, 0.35)", + "rgba(0, 222, 254, 0.46)", + "rgba(0, 221, 253, 0.58)", + "rgba(30, 223, 255, 0.69)", + "rgba(89, 224, 252, 0.8)", + "rgba(118, 228, 253, 0.9)", + "#8ae8ff", + "#b8f0ff", + "#def7ff", + "#ffffff", + ], + "light": [ + "#ffffff", + "#fbfeff", + "#f4fcff", + "#eafaff", + "#dff7ff", + "#d3f5ff", + "#c4f2ff", + "#b4efff", + "#a1ecff", + "#8ae8ff", + "#159ab0", + "#004e59", + "#000506", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(55, 205, 255, 0.02)", + "rgba(35, 195, 255, 0.05)", + "rgba(22, 199, 255, 0.09)", + "rgba(9, 193, 255, 0.13)", + "rgba(11, 199, 255, 0.18)", + "rgba(9, 201, 255, 0.24)", + "rgba(5, 202, 255, 0.3)", + "rgba(1, 204, 255, 0.37)", + "rgba(1, 205, 255, 0.46)", + "rgba(1, 145, 169, 0.92)", + "#004e59", + "#000506", + ], + }, + "bnw": { + "dark": [ + "#000000", + "#111111", + "#333333", + "#555555", + "#666666", + "#888888", + "#aaaaaa", + "#cccccc", + "#dddddd", + "#eeeeee", + "#ffffff", + "#ffffff", + "#ffffff", + ], + "darkA": [ + "rgba(255, 255, 255, 0.02)", + "rgba(255, 255, 255, 0.08)", + "rgba(255, 255, 255, 0.16)", + "rgba(255, 255, 255, 0.22)", + "rgba(255, 255, 255, 0.36)", + "rgba(255, 255, 255, 0.48)", + "rgba(255, 255, 255, 0.6)", + "rgba(255, 255, 255, 0.72)", + "rgba(255, 255, 255, 0.84)", + "rgba(255, 255, 255, 0.88)", + "rgba(255, 255, 255, 0.92)", + "rgba(255, 255, 255, 0.96)", + "rgba(255, 255, 255, 0.98)", + ], + "light": [ + "#ffffff", + "#f5f5f5", + "#eeeeee", + "#cccccc", + "#aaaaaa", + "#888888", + "#666666", + "#444444", + "#333333", + "#222222", + "#111111", + "#111111", + "#111111", + ], + "lightA": [ + "rgba(0, 0, 0, 0.02)", + "rgba(0, 0, 0, 0.08)", + "rgba(0, 0, 0, 0.16)", + "rgba(0, 0, 0, 0.22)", + "rgba(0, 0, 0, 0.36)", + "rgba(0, 0, 0, 0.48)", + "rgba(0, 0, 0, 0.6)", + "rgba(0, 0, 0, 0.72)", + "rgba(0, 0, 0, 0.84)", + "rgba(0, 0, 0, 0.88)", + "rgba(0, 0, 0, 0.92)", + "rgba(0, 0, 0, 0.96)", + "rgba(0, 0, 0, 0.98)", + ], + }, + "cyan": { + "dark": [ + "#000503", + "#00221c", + "#003930", + "#005245", + "#006c5b", + "#008772", + "#2fa28a", + "#55bca4", + "#75d7be", + "#95f3d9", + "#bdf7e4", + "#dffcf0", + "#ffffff", + ], + "darkA": [ + "rgba(0, 250, 150, 0.02)", + "rgba(0, 243, 200, 0.14)", + "rgba(0, 248, 209, 0.23)", + "rgba(0, 248, 209, 0.33)", + "rgba(0, 251, 212, 0.43)", + "rgba(0, 255, 215, 0.53)", + "rgba(73, 253, 216, 0.64)", + "rgba(115, 254, 222, 0.74)", + "rgba(138, 253, 224, 0.85)", + "rgba(155, 253, 226, 0.96)", + "rgba(195, 255, 235, 0.97)", + "rgba(225, 255, 242, 0.99)", + "#ffffff", + ], + "light": [ + "#ffffff", + "#f9fffb", + "#effff8", + "#e3fff4", + "#d8fef0", + "#ccfcec", + "#c0fae8", + "#b3f8e3", + "#a5f6de", + "#95f3d9", + "#2fa28a", + "#005245", + "#000503", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(55, 255, 122, 0.03)", + "rgba(26, 255, 155, 0.07)", + "rgba(0, 255, 155, 0.11)", + "rgba(11, 249, 161, 0.16)", + "rgba(0, 240, 160, 0.2)", + "rgba(3, 235, 163, 0.25)", + "rgba(2, 232, 162, 0.3)", + "rgba(5, 230, 163, 0.36)", + "rgba(3, 226, 165, 0.42)", + "rgba(1, 142, 112, 0.82)", + "#005245", + "#000503", + ], + }, + "geekblue": { + "dark": [ + "#000216", + "#001343", + "#00225c", + "#003176", + "#00418f", + "#0052a8", + "#0264c1", + "#1877d5", + "#288aea", + "#369eff", + "#88bffb", + "#c5dffd", + "#ffffff", + ], + "darkA": [ + "rgba(0, 22, 244, 0.09)", + "rgba(0, 70, 248, 0.27)", + "rgba(0, 92, 249, 0.37)", + "rgba(0, 104, 251, 0.47)", + "rgba(0, 116, 255, 0.56)", + "rgba(0, 124, 255, 0.66)", + "rgba(3, 132, 254, 0.76)", + "rgba(29, 142, 254, 0.84)", + "rgba(43, 150, 254, 0.92)", + "#369eff", + "rgba(137, 193, 254, 0.99)", + "#c5dffd", + "#ffffff", + ], + "light": [ + "#ffffff", + "#f8faff", + "#eaf3ff", + "#daeaff", + "#c7e0ff", + "#b1d5ff", + "#9ac9ff", + "#7fbcff", + "#60aeff", + "#369eff", + "#0264c1", + "#003176", + "#000216", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(22, 88, 255, 0.03)", + "rgba(22, 122, 255, 0.09)", + "rgba(8, 115, 255, 0.15)", + "rgba(0, 114, 255, 0.22)", + "rgba(3, 120, 255, 0.31)", + "rgba(3, 120, 255, 0.4)", + "rgba(4, 124, 255, 0.51)", + "rgba(3, 126, 255, 0.63)", + "rgba(1, 132, 255, 0.79)", + "#0264c1", + "#003176", + "#000216", + ], + }, + "gold": { + "dark": [ + "#070300", + "#271a00", + "#3f2c00", + "#593f00", + "#745400", + "#906a00", + "#ac8100", + "#c99811", + "#e4b12f", + "#ffcb47", + "#ffdd90", + "#ffeecd", + "#ffffff", + ], + "darkA": [ + "rgba(233, 100, 0, 0.03)", + "rgba(244, 163, 0, 0.16)", + "rgba(252, 176, 0, 0.25)", + "rgba(254, 180, 0, 0.35)", + "rgba(252, 183, 0, 0.46)", + "rgba(253, 186, 0, 0.57)", + "rgba(253, 190, 0, 0.68)", + "rgba(254, 192, 22, 0.79)", + "rgba(253, 197, 52, 0.9)", + "#ffcb47", + "#ffdd90", + "#ffeecd", + "#ffffff", + ], + "light": [ + "#ffffff", + "#fffcff", + "#fff8f2", + "#fff4e2", + "#ffefd0", + "#ffe9bb", + "#ffe3a4", + "#ffdb8b", + "#ffd46d", + "#ffcb47", + "#ac8100", + "#593f00", + "#070300", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(255, 105, 255, 0.02)", + "rgba(255, 138, 38, 0.06)", + "rgba(255, 163, 13, 0.12)", + "rgba(255, 171, 8, 0.19)", + "rgba(255, 174, 3, 0.27)", + "rgba(255, 177, 2, 0.36)", + "rgba(255, 177, 3, 0.46)", + "rgba(255, 181, 3, 0.58)", + "rgba(255, 184, 3, 0.73)", + "#ac8100", + "#593f00", + "#070300", + ], + }, + "gray": { + "dark": [ + "#000000", + "#111111", + "#222222", + "#2d2d2d", + "#333333", + "#444444", + "#555555", + "#666666", + "#6f6f6f", + "#777777", + "#aaaaaa", + "#dddddd", + "#ffffff", + ], + "darkA": [ + "rgba(255, 255, 255, 0.02)", + "rgba(255, 255, 255, 0.06)", + "rgba(255, 255, 255, 0.10)", + "rgba(255, 255, 255, 0.16)", + "rgba(255, 255, 255, 0.24)", + "rgba(255, 255, 255, 0.28)", + "rgba(255, 255, 255, 0.32)", + "rgba(255, 255, 255, 0.38)", + "rgba(255, 255, 255, 0.44)", + "rgba(255, 255, 255, 0.5)", + "rgba(255, 255, 255, 0.66)", + "rgba(255, 255, 255, 0.84)", + "#ffffff", + ], + "light": [ + "#ffffff", + "#f8f8f8", + "#eeeeee", + "#e3e3e3", + "#dddddd", + "#cccccc", + "#bbbbbb", + "#aaaaaa", + "#999999", + "#888888", + "#666666", + "#333333", + "#080808", + ], + "lightA": [ + "rgba(0, 0, 0, 0.015)", + "rgba(0, 0, 0, 0.03)", + "rgba(0, 0, 0, 0.06)", + "rgba(0, 0, 0, 0.12)", + "rgba(0, 0, 0, 0.18)", + "rgba(0, 0, 0, 0.24)", + "rgba(0, 0, 0, 0.32)", + "rgba(0, 0, 0, 0.38)", + "rgba(0, 0, 0, 0.44)", + "rgba(0, 0, 0, 0.5)", + "rgba(0, 0, 0, 0.68)", + "rgba(0, 0, 0, 0.84)", + "rgba(0, 0, 0, 0.98)", + ], + }, + "green": { + "dark": [ + "#000503", + "#001d12", + "#002d1d", + "#003f28", + "#005232", + "#00653c", + "#007944", + "#1b8d4d", + "#3ba05a", + "#55b467", + "#96cd92", + "#cde6c3", + "#ffffff", + ], + "darkA": [ + "rgba(0, 250, 150, 0.02)", + "rgba(0, 242, 150, 0.12)", + "rgba(0, 250, 161, 0.18)", + "rgba(0, 252, 160, 0.25)", + "rgba(0, 248, 152, 0.33)", + "rgba(0, 252, 150, 0.4)", + "rgba(0, 252, 142, 0.48)", + "rgba(48, 252, 137, 0.56)", + "rgba(94, 254, 143, 0.63)", + "rgba(120, 254, 145, 0.71)", + "rgba(185, 253, 180, 0.81)", + "rgba(225, 253, 214, 0.91)", + "#ffffff", + ], + "light": [ + "#ffffff", + "#f4fdeb", + "#e7f8dd", + "#d8f2ce", + "#c7eabd", + "#b4e1ac", + "#a0d79b", + "#89cc8a", + "#71c179", + "#55b467", + "#007944", + "#003f28", + "#000503", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(117, 230, 5, 0.08)", + "rgba(84, 205, 12, 0.14)", + "rgba(60, 190, 10, 0.2)", + "rgba(40, 174, 1, 0.26)", + "rgba(28, 164, 3, 0.33)", + "rgba(18, 155, 5, 0.4)", + "rgba(4, 146, 6, 0.47)", + "rgba(1, 144, 16, 0.56)", + "rgba(1, 143, 28, 0.67)", + "#007944", + "#003f28", + "#000503", + ], + }, + "lime": { + "dark": [ + "#020400", + "#142100", + "#253700", + "#374f00", + "#4b6800", + "#608200", + "#769d00", + "#8fb81b", + "#a9d42f", + "#c4f042", + "#daf685", + "#eefbbe", + "#ffffff", + ], + "darkA": [ + "rgba(100, 200, 0, 0.02)", + "rgba(154, 254, 0, 0.13)", + "rgba(168, 250, 0, 0.22)", + "rgba(177, 255, 0, 0.31)", + "rgba(183, 254, 0, 0.41)", + "rgba(188, 255, 0, 0.51)", + "rgba(190, 253, 0, 0.62)", + "rgba(196, 252, 37, 0.73)", + "rgba(204, 255, 57, 0.83)", + "rgba(209, 255, 70, 0.94)", + "rgba(225, 254, 137, 0.97)", + "rgba(240, 254, 192, 0.99)", + "#ffffff", + ], + "light": [ + "#ffffff", + "#feffeb", + "#f9ffd8", + "#f2ffc1", + "#ebfdaf", + "#e4fc9b", + "#ddf987", + "#d5f773", + "#cdf35c", + "#c4f042", + "#769d00", + "#374f00", + "#020400", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(242, 255, 5, 0.08)", + "rgba(218, 255, 11, 0.16)", + "rgba(203, 255, 7, 0.25)", + "rgba(193, 249, 5, 0.32)", + "rgba(187, 247, 5, 0.4)", + "rgba(183, 242, 0, 0.47)", + "rgba(179, 240, 0, 0.55)", + "rgba(177, 236, 0, 0.64)", + "rgba(175, 235, 0, 0.74)", + "#769d00", + "#374f00", + "#020400", + ], + }, + "magenta": { + "dark": [ + "#100002", + "#350011", + "#4b001e", + "#63002d", + "#79093f", + "#8e1752", + "#a32466", + "#b8317b", + "#ce3e91", + "#e34ba9", + "#f38bcb", + "#fec5e8", + "#ffffff", + ], + "darkA": [ + "rgba(229, 0, 29, 0.07)", + "rgba(252, 0, 81, 0.21)", + "rgba(250, 0, 100, 0.3)", + "rgba(254, 0, 115, 0.39)", + "rgba(252, 19, 131, 0.48)", + "rgba(254, 41, 146, 0.56)", + "rgba(255, 56, 159, 0.64)", + "rgba(252, 67, 168, 0.73)", + "rgba(254, 77, 179, 0.81)", + "rgba(255, 84, 190, 0.89)", + "rgba(253, 145, 211, 0.96)", + "#fec5e8", + "#ffffff", + ], + "light": [ + "#ffffff", + "#fff7f9", + "#ffeaf4", + "#ffdaee", + "#ffc7e7", + "#ffb2df", + "#ff99d6", + "#f980ca", + "#ef67ba", + "#e34ba9", + "#a32466", + "#63002d", + "#100002", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(255, 55, 105, 0.04)", + "rgba(255, 22, 133, 0.09)", + "rgba(255, 8, 142, 0.15)", + "rgba(255, 0, 146, 0.22)", + "rgba(255, 7, 152, 0.31)", + "rgba(255, 0, 153, 0.4)", + "rgba(243, 1, 149, 0.5)", + "rgba(228, 2, 140, 0.6)", + "rgba(216, 1, 134, 0.71)", + "rgba(148, 0, 77, 0.86)", + "#63002d", + "#100002", + ], + }, + "orange": { + "dark": [ + "#080300", + "#271400", + "#3d2000", + "#552d00", + "#6f3a00", + "#8a4700", + "#a75400", + "#c66100", + "#e37013", + "#ff802b", + "#ffae87", + "#ffd7c8", + "#ffffff", + ], + "darkA": [ + "rgba(200, 75, 0, 0.04)", + "rgba(244, 125, 0, 0.16)", + "rgba(254, 133, 0, 0.24)", + "rgba(250, 132, 0, 0.34)", + "rgba(252, 132, 0, 0.44)", + "rgba(251, 129, 0, 0.55)", + "rgba(253, 127, 0, 0.66)", + "rgba(254, 124, 0, 0.78)", + "rgba(255, 126, 21, 0.89)", + "#ff802b", + "#ffae87", + "#ffd7c8", + "#ffffff", + ], + "light": [ + "#ffffff", + "#fff9f8", + "#fff0ec", + "#ffe6dd", + "#ffd9ca", + "#ffcbb5", + "#ffbb9c", + "#ffaa7f", + "#ff975c", + "#ff802b", + "#a75400", + "#552d00", + "#080300", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(255, 55, 22, 0.03)", + "rgba(255, 67, 17, 0.08)", + "rgba(255, 76, 12, 0.14)", + "rgba(255, 74, 3, 0.21)", + "rgba(255, 76, 0, 0.29)", + "rgba(255, 81, 1, 0.39)", + "rgba(255, 88, 4, 0.51)", + "rgba(255, 93, 0, 0.64)", + "rgba(255, 102, 0, 0.83)", + "#a75400", + "#552d00", + "#080300", + ], + }, + "purple": { + "dark": [ + "#0d000b", + "#2e002a", + "#42003e", + "#560053", + "#670e66", + "#781e78", + "#892b8a", + "#9a399e", + "#ab46b2", + "#bd54c6", + "#d590da", + "#edc7ee", + "#ffffff", + ], + "darkA": [ + "rgba(217, 0, 183, 0.06)", + "rgba(242, 0, 221, 0.19)", + "rgba(254, 0, 238, 0.26)", + "rgba(253, 0, 244, 0.34)", + "rgba(251, 34, 249, 0.41)", + "rgba(255, 64, 255, 0.47)", + "rgba(249, 78, 251, 0.55)", + "rgba(248, 92, 255, 0.62)", + "rgba(244, 100, 254, 0.7)", + "rgba(242, 108, 254, 0.78)", + "rgba(248, 167, 253, 0.86)", + "rgba(252, 212, 253, 0.94)", + "#ffffff", + ], + "light": [ + "#ffffff", + "#fff6fb", + "#ffe7fd", + "#fdd6fe", + "#f6c4f8", + "#eeb1f1", + "#e49ce8", + "#d886de", + "#cb6ed2", + "#bd54c6", + "#892b8a", + "#560053", + "#0d000b", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(255, 30, 155, 0.04)", + "rgba(255, 15, 235, 0.1)", + "rgba(243, 14, 249, 0.17)", + "rgba(218, 9, 226, 0.24)", + "rgba(200, 3, 210, 0.31)", + "rgba(186, 1, 196, 0.39)", + "rgba(174, 3, 186, 0.48)", + "rgba(164, 1, 176, 0.57)", + "rgba(156, 0, 170, 0.67)", + "rgba(113, 0, 114, 0.83)", + "#560053", + "#0d000b", + ], + }, + "red": { + "dark": [ + "#0f0006", + "#34001d", + "#4b002b", + "#640039", + "#7a0c46", + "#911b53", + "#a72860", + "#bf356e", + "#d7427b", + "#f04f88", + "#ff8eab", + "#ffc9d3", + "#ffffff", + ], + "darkA": [ + "rgba(250, 0, 100, 0.06)", + "rgba(248, 0, 138, 0.21)", + "rgba(250, 0, 143, 0.3)", + "rgba(250, 0, 142, 0.4)", + "rgba(254, 25, 146, 0.48)", + "rgba(254, 47, 146, 0.57)", + "rgba(253, 61, 145, 0.66)", + "rgba(255, 71, 147, 0.75)", + "rgba(253, 78, 145, 0.85)", + "rgba(255, 84, 145, 0.94)", + "#ff8eab", + "#ffc9d3", + "#ffffff", + ], + "light": [ + "#ffffff", + "#fff7f7", + "#ffeced", + "#ffdde2", + "#ffccd5", + "#ffb8c7", + "#ffa2b8", + "#ff88a8", + "#fe6998", + "#f04f88", + "#a72860", + "#640039", + "#0f0006", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(255, 55, 55, 0.04)", + "rgba(255, 17, 30, 0.08)", + "rgba(255, 12, 48, 0.14)", + "rgba(255, 0, 45, 0.2)", + "rgba(255, 1, 55, 0.28)", + "rgba(255, 4, 63, 0.37)", + "rgba(255, 2, 70, 0.47)", + "rgba(253, 1, 80, 0.59)", + "rgba(233, 0, 83, 0.69)", + "rgba(151, 2, 68, 0.85)", + "#640039", + "#0f0006", + ], + }, + "volcano": { + "dark": [ + "#0c0100", + "#2f0a00", + "#451200", + "#5d1900", + "#762000", + "#8e2a07", + "#a53716", + "#bc4424", + "#d45132", + "#ec5e41", + "#ff9480", + "#ffcbc3", + "#ffffff", + ], + "darkA": [ + "rgba(240, 20, 0, 0.05)", + "rgba(247, 53, 0, 0.19)", + "rgba(246, 64, 0, 0.28)", + "rgba(251, 68, 0, 0.37)", + "rgba(251, 68, 0, 0.47)", + "rgba(254, 75, 12, 0.56)", + "rgba(254, 85, 34, 0.65)", + "rgba(254, 92, 49, 0.74)", + "rgba(255, 98, 60, 0.83)", + "rgba(254, 101, 70, 0.93)", + "#ff9480", + "#ffcbc3", + "#ffffff", + ], + "light": [ + "#ffffff", + "#fff7f6", + "#ffece9", + "#ffded9", + "#ffcec5", + "#ffbbaf", + "#ffa695", + "#ff8e78", + "#fb745a", + "#ec5e41", + "#a53716", + "#5d1900", + "#0c0100", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(255, 55, 30, 0.04)", + "rgba(255, 44, 11, 0.09)", + "rgba(255, 35, 2, 0.15)", + "rgba(255, 42, 3, 0.23)", + "rgba(255, 43, 5, 0.32)", + "rgba(255, 43, 3, 0.42)", + "rgba(255, 42, 0, 0.53)", + "rgba(249, 41, 1, 0.65)", + "rgba(230, 40, 2, 0.75)", + "rgba(157, 38, 2, 0.92)", + "#5d1900", + "#0c0100", + ], + }, + "yellow": { + "dark": [ + "#050400", + "#251d00", + "#3e3300", + "#584a00", + "#736300", + "#8e7d00", + "#ab9800", + "#c7b426", + "#e3d142", + "#ffef5c", + "#fff594", + "#fffad3", + "#ffffff", + ], + "darkA": [ + "rgba(250, 200, 0, 0.02)", + "rgba(247, 193, 0, 0.15)", + "rgba(248, 204, 0, 0.25)", + "rgba(251, 211, 0, 0.35)", + "rgba(250, 215, 0, 0.46)", + "rgba(254, 223, 0, 0.56)", + "rgba(255, 227, 0, 0.67)", + "rgba(255, 231, 49, 0.78)", + "rgba(255, 235, 74, 0.89)", + "#ffef5c", + "#fff594", + "#fffad3", + "#ffffff", + ], + "light": [ + "#ffffff", + "#fffeff", + "#fffcff", + "#fffbf1", + "#fffada", + "#fff9c2", + "#fff7aa", + "#fff592", + "#fff279", + "#ffef5c", + "#ab9800", + "#584a00", + "#050400", + ], + "lightA": [ + "rgba(255, 255, 255, 0.01)", + "rgba(255, 155, 255, 0.01)", + "rgba(255, 105, 255, 0.02)", + "rgba(255, 188, 22, 0.06)", + "rgba(255, 222, 8, 0.15)", + "rgba(255, 230, 1, 0.24)", + "rgba(255, 231, 5, 0.34)", + "rgba(255, 232, 2, 0.43)", + "rgba(255, 230, 2, 0.53)", + "rgba(255, 230, 0, 0.64)", + "#ab9800", + "#584a00", + "#050400", + ], + }, +} +`; diff --git a/tests/index.test.ts b/tests/index.test.ts deleted file mode 100644 index 4b8bda6e..00000000 --- a/tests/index.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { hello } from '@ant-design/pro-chat'; - -test('hello', () => { - expect(hello()).toBe('world'); -}); diff --git a/tsconfig.json b/tsconfig.json index cf951918..e487b65e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,9 +11,8 @@ "types": ["vitest/globals"], "paths": { "@@/*": [".dumi/tmp/*"], - "@/*": ["src"], - "@ant-design/pro-chat": ["src"], - "@ant-design/pro-chat/*": ["src/*", "*"] + "@/*": ["src/*"], + "@ant-design/pro-chat": ["src"] } } }