├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .idea
├── .gitignore
├── modules.xml
├── prettier.xml
├── vcs.xml
└── vidol.chat.iml
├── .npmrc
├── .prettierignore
├── .prettierrc.js
├── .stylelintrc.js
├── LICENSE
├── README.md
├── docs
└── tts.md
├── global.d.ts
├── next.config.mjs
├── package.json
├── preview.png
├── public
├── icons
│ ├── icon-192x192.png
│ ├── icon-512x512.png
│ ├── maskable-icon-192x192.png
│ └── maskable-icon-512x512.png
├── idle_loop.vrma
└── manifest.json
├── src
├── app
│ ├── StyleRegistry.tsx
│ ├── api
│ │ ├── chat
│ │ │ └── openai
│ │ │ │ ├── createErrorResponse.ts
│ │ │ │ └── route.ts
│ │ └── voice
│ │ │ ├── edge
│ │ │ ├── route.ts
│ │ │ └── voices
│ │ │ │ └── route.ts
│ │ │ └── microsoft
│ │ │ ├── route.ts
│ │ │ └── voices
│ │ │ └── route.ts
│ ├── home
│ │ ├── Background
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── Dialog
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── Docker
│ │ │ ├── Apps
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ ├── AudioPlayer
│ │ │ │ ├── Control
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── style.ts
│ │ │ │ ├── Duration
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── style.ts
│ │ │ │ ├── PlayList
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Volume
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── style.ts
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ ├── ToolBar
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── Header
│ │ │ └── index.tsx
│ │ ├── RoleSelect
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── VirtualIdol
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── apps.tsx
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── metadata.ts
│ ├── page.tsx
│ └── welcome
│ │ ├── Redirect.tsx
│ │ └── loading.tsx
├── components
│ ├── AgentInfo
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── AgentMeta
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── Application
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── DanceInfo
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── HolographicCard
│ │ ├── components
│ │ │ ├── Container.tsx
│ │ │ ├── LaserShine
│ │ │ │ ├── LaserShine.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── style.ts
│ │ │ │ └── useLaserShine.ts
│ │ │ ├── Orbit
│ │ │ │ ├── index.tsx
│ │ │ │ └── styles.ts
│ │ │ └── style.ts
│ │ ├── index.tsx
│ │ ├── store
│ │ │ └── card.ts
│ │ └── utils
│ │ │ └── math.ts
│ ├── PageLoading
│ │ └── index.tsx
│ └── Panel
│ │ ├── Container.tsx
│ │ ├── index.tsx
│ │ └── style.ts
├── constants
│ ├── agent.ts
│ ├── common.ts
│ ├── dance.ts
│ └── openai.ts
├── features
│ ├── AgentViewer
│ │ ├── ToolBar
│ │ │ └── index.tsx
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── ChatInput
│ │ ├── Actions
│ │ │ ├── History.tsx
│ │ │ ├── Record.tsx
│ │ │ ├── Token.tsx
│ │ │ └── Voice
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ ├── Footer
│ │ │ └── index.tsx
│ │ ├── Header
│ │ │ ├── ActionBar
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── MessageInput
│ │ │ └── index.tsx
│ │ ├── TextArea.tsx
│ │ └── index.tsx
│ ├── ChatItem
│ │ ├── Actions
│ │ │ ├── Assistant.tsx
│ │ │ ├── User.tsx
│ │ │ └── index.tsx
│ │ ├── ActionsBar.tsx
│ │ ├── Error
│ │ │ ├── ApiError.tsx
│ │ │ ├── ApiKeyForm.tsx
│ │ │ ├── ErrorJsonViewer.tsx
│ │ │ ├── OpenAPIKey.tsx
│ │ │ ├── index.tsx
│ │ │ └── style.tsx
│ │ ├── Messages
│ │ │ ├── Default.tsx
│ │ │ ├── Loading.tsx
│ │ │ └── index.tsx
│ │ ├── index.tsx
│ │ └── type.ts
│ ├── ImageViewer
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── Timer
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── constants
│ │ └── ttsParam.ts
│ ├── emoteController
│ │ ├── autoBlink.ts
│ │ ├── autoLookAt.ts
│ │ ├── emoteConstants.ts
│ │ ├── emoteController.ts
│ │ └── expressionController.ts
│ ├── lipSync
│ │ ├── lipSync.ts
│ │ └── lipSyncAnalyzeResult.ts
│ ├── messages
│ │ └── speakCharacter.ts
│ └── vrmViewer
│ │ ├── model.ts
│ │ └── viewer.ts
├── hooks
│ ├── useCalculateToken.ts
│ ├── useChatListActionsBar.tsx
│ ├── useSendMessage.ts
│ └── useSpeechRecognition.ts
├── layout
│ ├── StoreHydration.tsx
│ └── index.tsx
├── lib
│ ├── VMDAnimation
│ │ ├── loadVMDAnimation.ts
│ │ ├── vmd2vrmanim.binding.ts
│ │ ├── vmd2vrmanim.ts
│ │ └── vrm-ik-handler.ts
│ ├── VRMAnimation
│ │ ├── VRMAnimation.ts
│ │ ├── VRMAnimationLoaderPlugin.ts
│ │ ├── VRMAnimationLoaderPluginOptions.ts
│ │ ├── VRMCVRMAnimation.ts
│ │ ├── loadVRMAnimation.ts
│ │ └── utils
│ │ │ ├── arrayChunk.ts
│ │ │ ├── linearstep.ts
│ │ │ └── saturate.ts
│ └── VRMLookAtSmootherLoaderPlugin
│ │ ├── VRMLookAtSmoother.ts
│ │ └── VRMLookAtSmootherLoaderPlugin.ts
├── panels
│ ├── AgentPanel
│ │ ├── Agent
│ │ │ ├── AgentCard
│ │ │ │ └── index.tsx
│ │ │ ├── AgentList
│ │ │ │ └── index.tsx
│ │ │ ├── TopBanner
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── ChatPanel
│ │ ├── ChatBot
│ │ │ ├── ChatHeader.tsx
│ │ │ ├── ChatList
│ │ │ │ ├── AutoScroll.tsx
│ │ │ │ ├── BackBottom
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── style.ts
│ │ │ │ └── index.tsx
│ │ │ ├── index.tsx
│ │ │ ├── style.ts
│ │ │ └── type.ts
│ │ ├── SideBar
│ │ │ ├── Header.tsx
│ │ │ ├── SessionList
│ │ │ │ ├── List.tsx
│ │ │ │ ├── SessionItem
│ │ │ │ │ ├── Actions.tsx
│ │ │ │ │ ├── ListItem.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ └── SkeletonList.tsx
│ │ │ ├── index.tsx
│ │ │ └── style.ts
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── ConfigPanel
│ │ ├── Config
│ │ │ ├── common.tsx
│ │ │ ├── index.tsx
│ │ │ ├── model
│ │ │ │ └── openai.tsx
│ │ │ └── style.ts
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── DancePanel
│ │ ├── Dance
│ │ │ ├── DanceCard
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ ├── DanceList
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── MarketPanel
│ │ ├── Agent
│ │ │ ├── AgentCard
│ │ │ │ ├── SubscribeButton.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── AgentIndex
│ │ │ │ ├── AgentList.tsx
│ │ │ │ ├── Header.tsx
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── Dance
│ │ │ ├── DanceCard
│ │ │ │ ├── SubscribeButton.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── DanceIndex
│ │ │ │ ├── DanceList.tsx
│ │ │ │ ├── Header.tsx
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── SideNav
│ │ │ └── index.tsx
│ │ ├── index.tsx
│ │ └── style.ts
│ ├── PanelContainer.tsx
│ ├── RolePanel
│ │ ├── Info
│ │ │ └── index.tsx
│ │ ├── Role
│ │ │ └── index.tsx
│ │ ├── Touch
│ │ │ ├── ActionList
│ │ │ │ └── index.tsx
│ │ │ ├── SideBar
│ │ │ │ ├── AreaList.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.ts
│ │ │ └── index.tsx
│ │ ├── Voice
│ │ │ └── index.tsx
│ │ ├── index.tsx
│ │ └── style.ts
│ └── index.tsx
├── services
│ ├── agent.ts
│ ├── chat.ts
│ ├── dance.ts
│ └── tts.ts
├── store
│ ├── agent
│ │ ├── index.ts
│ │ └── selectors
│ │ │ └── agent.ts
│ ├── config
│ │ ├── index.ts
│ │ ├── initialState.ts
│ │ └── selectors
│ │ │ └── config.ts
│ ├── dance
│ │ ├── index.ts
│ │ ├── selectors
│ │ │ └── dance.ts
│ │ └── slices
│ │ │ ├── dancelist.ts
│ │ │ └── playlist.ts
│ ├── market
│ │ ├── index.ts
│ │ ├── selectors
│ │ │ ├── agent.ts
│ │ │ └── dance.ts
│ │ └── slices
│ │ │ ├── agent.ts
│ │ │ ├── dance.ts
│ │ │ └── panel.ts
│ ├── session
│ │ ├── index.ts
│ │ ├── initialState.ts
│ │ ├── reducers
│ │ │ └── message.ts
│ │ └── selectors.ts
│ ├── theme.ts
│ ├── touch.ts
│ └── viewer.ts
├── styles
│ ├── global.ts
│ └── index.tsx
├── types
│ ├── agent.ts
│ ├── api.ts
│ ├── chat.ts
│ ├── config.ts
│ ├── dance.ts
│ ├── llm.ts
│ ├── session.ts
│ ├── touch.ts
│ └── tts.ts
└── utils
│ ├── cookie.ts
│ ├── fetch.ts
│ ├── keyboard.ts
│ ├── platform.ts
│ ├── three-helpers.ts
│ ├── voices.ts
│ └── wait.ts
└── tsconfig.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | # Eslintignore from LobeHub
2 | ################################################################
3 |
4 | # dependencies
5 | node_modules
6 |
7 | # ci
8 | coverage
9 | .coverage
10 |
11 | # test
12 | jest*
13 | _test_
14 | __test__
15 | *.test.ts
16 |
17 | # umi
18 | .umi
19 | .umi-production
20 | .umi-test
21 | .dumi/tmp*
22 | !.dumirc.ts
23 |
24 | # production
25 | dist
26 | es
27 | lib
28 | logs
29 |
30 | # misc
31 | # add other ignore file below
32 | .next
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const config = require('@lobehub/lint').eslint;
2 |
3 | config.extends.push('plugin:@next/next/recommended');
4 |
5 | config.rules['unicorn/no-negated-condition'] = 0;
6 | config.rules['unicorn/prefer-type-error'] = 0;
7 | config.rules['unicorn/prefer-logical-operator-over-ternary'] = 0;
8 | config.rules['unicorn/no-null'] = 0;
9 | config.rules['unicorn/no-typeof-undefined'] = 0;
10 | config.rules['unicorn/explicit-length-check'] = 0;
11 | config.rules['unicorn/prefer-code-point'] = 0;
12 | config.rules['no-extra-boolean-cast'] = 0;
13 | config.rules['unicorn/no-useless-undefined'] = 0;
14 | config.rules['react/no-unknown-property'] = 0;
15 | config.rules['unicorn/prefer-ternary'] = 0;
16 | config.rules['unicorn/prefer-spread'] = 0;
17 | config.rules['unicorn/catch-error-name'] = 0;
18 | config.rules['unicorn/no-array-for-each'] = 0;
19 | config.rules['unicorn/prefer-number-properties'] = 0;
20 | config.rules['sort-keys-fix/sort-keys-fix'] = 0;
21 | config.rules['react/jsx-sort-props'] = 0;
22 |
23 | module.exports = config;
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | #
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | package-lock.json
38 | .vscode
39 |
40 | /public/agents/
41 | /public/dances/
42 |
43 | .idea
44 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/prettier.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/vidol.chat.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | lockfile=false
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /dist
2 | *.yaml
3 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | pluginSearchDirs: false,
3 | plugins: [
4 | require.resolve('prettier-plugin-organize-imports'),
5 | require.resolve('prettier-plugin-packagejson'),
6 | ],
7 | printWidth: 100,
8 | proseWrap: 'never',
9 | singleQuote: true,
10 | endOfLine: 'lf',
11 | trailingComma: 'all',
12 | overrides: [
13 | {
14 | files: '*.md',
15 | options: {
16 | proseWrap: 'preserve',
17 | },
18 | },
19 | ],
20 | };
21 |
--------------------------------------------------------------------------------
/.stylelintrc.js:
--------------------------------------------------------------------------------
1 | const config = require('@lobehub/lint').stylelint;
2 |
3 | module.exports = {
4 | ...config,
5 | rules: {
6 | 'selector-id-pattern': null,
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 vidols
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/docs/tts.md:
--------------------------------------------------------------------------------
1 | ## TTS
2 |
3 | Text To Speech(文字转语音)
4 |
5 | ### 方案选择
6 |
7 | 本地模型:
8 |
9 | - [Vits-fast](https://github.com/Plachtaa/VITS-fast-fine-tuning)
10 | - [edge-tts](https://github.com/rany2/edge-tts)
11 |
--------------------------------------------------------------------------------
/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'mmd-parser' {
2 | export const CharsetEncoder: any;
3 | export class Parser {
4 | parsePmd(buffer: ArrayBufferLike, leftToRight?: boolean): any;
5 | parsePmx(buffer: ArrayBufferLike, leftToRight?: boolean): any;
6 | parseVmd(buffer: ArrayBufferLike, leftToRight?: boolean): VmdFile;
7 | parseVpd(buffer: ArrayBufferLike, leftToRight?: boolean): any;
8 | mergeVmds(vmds: VmdFile[]): VmdFile;
9 | leftToRightModel(model: any): any;
10 | leftToRightVmd(vmd: any): any;
11 | leftToRightVpd(vpd: any): any;
12 | }
13 |
14 | export interface VmdFile {
15 | cameras: {
16 | distance: number;
17 | fov: number;
18 | frameNum: number;
19 | interpolation: number[];
20 | perspective: number;
21 | position: number[];
22 | rotation: number[];
23 | }[];
24 | metadata: {
25 | cameraCount: number;
26 | coordinateSystem: string;
27 | magic: string;
28 | morphCount: number;
29 | motionCount: number;
30 | name: string;
31 | };
32 | morphs: {
33 | frameNum: number;
34 | morphName: string;
35 | weight: number;
36 | }[];
37 | motions: {
38 | boneName: string;
39 | frameNum: number;
40 | interpolation: number[];
41 | position: number[];
42 | rotation: number[];
43 | }[];
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | import nextPWA from 'next-pwa';
2 | const isProd = process.env.NODE_ENV === 'production';
3 |
4 | const withPWA = nextPWA({
5 | dest: 'public',
6 | register: true,
7 | skipWaiting: true,
8 | });
9 |
10 | /** @type {import('next').NextConfig} */
11 | const nextConfig = {
12 | reactStrictMode: true,
13 | transpilePackages: ['@lobehub/ui'],
14 | };
15 |
16 | export default isProd ? withPWA(nextConfig) : nextConfig;
17 |
--------------------------------------------------------------------------------
/preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lobehub/VChat/cda8eb97d694b7849b24a1c56d9d9969880120da/preview.png
--------------------------------------------------------------------------------
/public/icons/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lobehub/VChat/cda8eb97d694b7849b24a1c56d9d9969880120da/public/icons/icon-192x192.png
--------------------------------------------------------------------------------
/public/icons/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lobehub/VChat/cda8eb97d694b7849b24a1c56d9d9969880120da/public/icons/icon-512x512.png
--------------------------------------------------------------------------------
/public/icons/maskable-icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lobehub/VChat/cda8eb97d694b7849b24a1c56d9d9969880120da/public/icons/maskable-icon-192x192.png
--------------------------------------------------------------------------------
/public/icons/maskable-icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lobehub/VChat/cda8eb97d694b7849b24a1c56d9d9969880120da/public/icons/maskable-icon-512x512.png
--------------------------------------------------------------------------------
/public/idle_loop.vrma:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lobehub/VChat/cda8eb97d694b7849b24a1c56d9d9969880120da/public/idle_loop.vrma
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "background_color": "#000000",
3 | "description": "Make Virtual Idols Accessible To EveryOne!",
4 | "display": "standalone",
5 | "icons": [
6 | {
7 | "src": "/icons/icon-192x192.png",
8 | "sizes": "192x192",
9 | "type": "image/png",
10 | "purpose": "any"
11 | },
12 | {
13 | "src": "/icons/maskable-icon-192x192.png",
14 | "sizes": "192x192",
15 | "type": "image/png",
16 | "purpose": "maskable"
17 | },
18 | {
19 | "src": "/icons/icon-512x512.png",
20 | "sizes": "512x512",
21 | "type": "image/png",
22 | "purpose": "any"
23 | },
24 | {
25 | "src": "/icons/maskable-icon-512x512.png",
26 | "sizes": "512x512",
27 | "type": "image/png",
28 | "purpose": "maskable"
29 | }
30 | ],
31 | "id": "/",
32 | "name": "VChat.Chat",
33 | "orientation": "portrait",
34 | "scope": "/",
35 | "short_name": "VChat",
36 | "splash_pages": null,
37 | "start_url": ".",
38 | "theme_color": "#000000"
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/StyleRegistry.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { StyleProvider, extractStaticStyle } from 'antd-style';
4 | import { useServerInsertedHTML } from 'next/navigation';
5 | import { PropsWithChildren, useRef } from 'react';
6 |
7 | const StyleRegistry = ({ children }: PropsWithChildren) => {
8 | const isInsert = useRef(false);
9 |
10 | useServerInsertedHTML(() => {
11 | // avoid duplicate css insert
12 | // refs: https://github.com/vercel/next.js/discussions/49354#discussioncomment-6279917
13 | if (isInsert.current) return;
14 |
15 | isInsert.current = true;
16 |
17 | return extractStaticStyle().map((item) => item.style);
18 | });
19 |
20 | return {children};
21 | };
22 |
23 | export default StyleRegistry;
24 |
--------------------------------------------------------------------------------
/src/app/api/chat/openai/createErrorResponse.ts:
--------------------------------------------------------------------------------
1 | import { ErrorTypeEnum } from '@/types/api';
2 | import { NextResponse } from 'next/server';
3 |
4 | const getStatusCode = (errorType: ErrorTypeEnum) => {
5 | switch (errorType) {
6 | case ErrorTypeEnum.API_KEY_MISSING: {
7 | return 401;
8 | }
9 | case ErrorTypeEnum.OPENAI_API_ERROR: {
10 | return 577;
11 | }
12 | default: {
13 | return 500;
14 | }
15 | }
16 | };
17 |
18 | export const createErrorResponse = (errorType: ErrorTypeEnum, body: any) => {
19 | const statusCode = getStatusCode(errorType);
20 |
21 | return NextResponse.json(
22 | {
23 | body,
24 | errorType,
25 | success: false,
26 | },
27 | { status: statusCode },
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/src/app/api/chat/openai/route.ts:
--------------------------------------------------------------------------------
1 | import { OPENAI_API_KEY, OPENAI_END_POINT } from '@/constants/openai';
2 | import { ErrorTypeEnum } from '@/types/api';
3 | import { OpenAIStream, StreamingTextResponse } from 'ai';
4 | import OpenAI, { ClientOptions } from 'openai';
5 | import { createErrorResponse } from './createErrorResponse';
6 |
7 | export const POST = async (req: Request) => {
8 | const payload = await req.json();
9 | const apiKey = (req.headers.get(OPENAI_API_KEY) as string) || process.env.OPENAI_API_KEY;
10 | const baseURL = (req.headers.get(OPENAI_END_POINT) as string) || process.env.OPENAI_PROXY_URL;
11 |
12 | if (!apiKey) {
13 | return createErrorResponse(ErrorTypeEnum.API_KEY_MISSING, 'openai api key missing');
14 | }
15 | const config: ClientOptions = {
16 | apiKey: apiKey,
17 | baseURL,
18 | };
19 |
20 | const openai = new OpenAI(config);
21 |
22 | const { model, messages } = payload;
23 |
24 | try {
25 | const completion = await openai.chat.completions.create({
26 | messages,
27 | model,
28 | stream: true,
29 | });
30 |
31 | const stream = OpenAIStream(completion);
32 |
33 | return new StreamingTextResponse(stream);
34 | } catch (error) {
35 | // https://platform.openai.com/docs/guides/error-codes/api-errors
36 | if (error instanceof OpenAI.APIError) {
37 | let errorResult: any;
38 |
39 | // if error is definitely OpenAI APIError, there will be an error object
40 | if (error.error) {
41 | errorResult = error.error;
42 | }
43 | // Or if there is a cause, we use error cause
44 | // This often happened when there is a bug of the `openai` package.
45 | else if (error.cause) {
46 | errorResult = error.cause;
47 | }
48 | // if there is no other request error, the error object is a Response like object
49 | else {
50 | errorResult = { headers: error.headers, stack: error.stack, status: error.status };
51 | }
52 | return createErrorResponse(ErrorTypeEnum.OPENAI_API_ERROR, {
53 | error: errorResult,
54 | });
55 | } else {
56 | return createErrorResponse(ErrorTypeEnum.INTERNAL_SERVER_ERROR, JSON.stringify(error));
57 | }
58 | }
59 | };
60 |
--------------------------------------------------------------------------------
/src/app/api/voice/microsoft/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | const { v4: uuidv4 } = require('uuid');
4 | const axios = require('axios');
5 |
6 | export const POST = async (req: Request) => {
7 | const { ssml } = await req.json();
8 | const data = JSON.stringify({
9 | offsetInPlainText: 0,
10 | properties: {
11 | SpeakTriggerSource: 'AccTuningPagePlayButton',
12 | },
13 | ssml,
14 | ttsAudioFormat: 'audio-24khz-160kbitrate-mono-mp3',
15 | });
16 |
17 | const config = {
18 | data: data,
19 | headers: {
20 | accept: '*/*',
21 | 'accept-language': 'zh-CN,zh;q=0.9',
22 | authority: 'southeastasia.api.speech.microsoft.com',
23 | 'content-type': 'application/json',
24 | customvoiceconnectionid: uuidv4(),
25 | origin: 'https://speech.microsoft.com',
26 | 'sec-ch-ua': '"Google Chrome";v="111", "Not(A:Brand";v="8", "Chromium";v="111"',
27 | 'sec-ch-ua-mobile': '?0',
28 | 'sec-ch-ua-platform': '"Windows"',
29 | 'sec-fetch-dest': 'empty',
30 | 'sec-fetch-mode': 'cors',
31 | 'sec-fetch-site': 'same-site',
32 | 'user-agent':
33 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36',
34 | },
35 | method: 'post',
36 | responseType: 'arraybuffer',
37 |
38 | url: 'https://southeastasia.api.speech.microsoft.com/accfreetrial/texttospeech/acc/v3.0-beta1/vcg/speak',
39 | };
40 |
41 | try {
42 | return await axios(config);
43 | } catch {
44 | return NextResponse.json({ errorMessage: '转换失败', success: false }, { status: 400 });
45 | }
46 | };
47 |
--------------------------------------------------------------------------------
/src/app/home/Background/index.tsx:
--------------------------------------------------------------------------------
1 | import { useConfigStore } from '@/store/config';
2 | import { useStyles } from './style';
3 |
4 | const Background = () => {
5 | const { styles } = useStyles();
6 | const backgroundEffect = useConfigStore((s) => s.config.backgroundEffect);
7 | return backgroundEffect === 'glow' ?
: null;
8 | };
9 |
10 | export default Background;
11 |
--------------------------------------------------------------------------------
/src/app/home/Background/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, token }) => ({
4 | glow: css`
5 | pointer-events: none;
6 | will-change: transform;
7 |
8 | position: absolute;
9 | top: -250px;
10 | left: 50%;
11 | transform: translateX(-50%) scale(1.5);
12 |
13 | width: 600px;
14 | height: 400px;
15 |
16 | opacity: 0.2;
17 | background: linear-gradient(
18 | 135deg,
19 | ${token.purple} 0%,
20 | ${token.blue} 30%,
21 | ${token.red} 70%,
22 | ${token.cyan} 100%
23 | );
24 | background-size: 200% 200%;
25 | filter: blur(69px);
26 |
27 | animation: glow 10s ease infinite;
28 |
29 | @keyframes glow {
30 | 0% {
31 | background-position: 0 -100%;
32 | }
33 |
34 | 50% {
35 | background-position: 200% 50%;
36 | }
37 |
38 | 100% {
39 | background-position: 0 -100%;
40 | }
41 | }
42 | `,
43 | }));
44 |
--------------------------------------------------------------------------------
/src/app/home/Dialog/index.tsx:
--------------------------------------------------------------------------------
1 | import ChatItem from '@/features/ChatItem';
2 | import { sessionSelectors, useSessionStore } from '@/store/session';
3 | import { useStyles } from './style';
4 |
5 | const Dialog = () => {
6 | const { styles } = useStyles();
7 | const currentChats = useSessionStore((s) => sessionSelectors.currentChats(s));
8 | const lastAgentChatIndex = currentChats.findLastIndex((item) => item.role === 'assistant');
9 | return lastAgentChatIndex !== -1 ? (
10 |
11 |
17 |
18 | ) : null;
19 | };
20 |
21 | export default Dialog;
22 |
--------------------------------------------------------------------------------
/src/app/home/Dialog/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 | import { rgba } from 'polished';
3 |
4 | const DIALOG_WIDTH = 720;
5 |
6 | export const useStyles = createStyles(({ css, token }) => ({
7 | dialog: css`
8 | position: fixed;
9 | bottom: 64px;
10 | left: 50%;
11 |
12 | width: ${DIALOG_WIDTH}px;
13 | margin-bottom: ${token.marginSM}px;
14 | margin-left: ${-DIALOG_WIDTH / 2}px;
15 | padding: ${token.paddingSM}px;
16 |
17 | background-color: ${rgba(token.colorBgLayout, 0.8)};
18 | backdrop-filter: saturate(180%) blur(10px);
19 | border: 1px solid ${token.colorBorder};
20 | border-radius: ${token.borderRadius}px;
21 | `,
22 | }));
23 |
--------------------------------------------------------------------------------
/src/app/home/Docker/Apps/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { apps } from '@/app/home/apps';
4 | import Application from '@/components/Application';
5 | import { useConfigStore } from '@/store/config';
6 | import { PanelKey } from '@/types/config';
7 | import { useStyles } from './style';
8 |
9 | const Apps = () => {
10 | const [openPanel] = useConfigStore((s) => [s.openPanel]);
11 | const { styles } = useStyles();
12 |
13 | return (
14 |
15 | {apps.map((app) => {
16 | return (
17 |
{
22 | openPanel(app.key as PanelKey);
23 | }}
24 | />
25 | );
26 | })}
27 |
28 | );
29 | };
30 |
31 | export default Apps;
32 |
--------------------------------------------------------------------------------
/src/app/home/Docker/Apps/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | apps: css`
5 | display: grid;
6 | grid-auto-flow: column;
7 | grid-template-columns: repeat(auto-fill, 48px);
8 | grid-template-rows: repeat(auto-fill, 48px);
9 |
10 | width: 420px;
11 | `,
12 | }));
13 |
--------------------------------------------------------------------------------
/src/app/home/Docker/AudioPlayer/Control/index.tsx:
--------------------------------------------------------------------------------
1 | import { DanceStore, useDanceStore } from '@/store/dance';
2 | import { Pause, Play, SkipBack, SkipForward } from 'lucide-react';
3 | import { useStyles } from './style';
4 |
5 | const controlSelectors = (s: DanceStore) => {
6 | return {
7 | isPlaying: s.isPlaying,
8 | nextDance: s.nextDance,
9 | prevDance: s.prevDance,
10 | setIsPlaying: s.setIsPlaying,
11 | togglePlayPause: s.togglePlayPause,
12 | };
13 | };
14 |
15 | const Control = () => {
16 | const { prevDance, nextDance, isPlaying, togglePlayPause } = useDanceStore(controlSelectors);
17 | const { styles } = useStyles();
18 |
19 | return (
20 |
21 |
22 | {isPlaying ? (
23 |
24 | ) : (
25 |
26 | )}
27 |
28 |
29 | );
30 | };
31 |
32 | export default Control;
33 |
--------------------------------------------------------------------------------
/src/app/home/Docker/AudioPlayer/Control/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | const useStyles = createStyles(({ token, css }) => ({
4 | back: css`
5 | cursor: pointer;
6 | margin-right: ${token.marginSM}px;
7 | `,
8 | control: css`
9 | display: flex;
10 | align-items: center;
11 | justify-content: center;
12 | `,
13 | forward: css`
14 | cursor: pointer;
15 | margin-left: ${token.marginSM}px;
16 | `,
17 | playPause: css`
18 | cursor: pointer;
19 | `,
20 | }));
21 |
22 | export { useStyles };
23 |
--------------------------------------------------------------------------------
/src/app/home/Docker/AudioPlayer/Duration/index.tsx:
--------------------------------------------------------------------------------
1 | import { ConfigProvider, Slider } from 'antd';
2 | import { memo } from 'react';
3 | import { useStyles } from './style';
4 |
5 | interface DurationProps {
6 | currentProgress: number;
7 | duration: number;
8 | }
9 |
10 | function formatDurationDisplay(duration: number) {
11 | const min = Math.floor(duration / 60);
12 | const sec = Math.floor(duration - min * 60);
13 | return [min, sec].map((n) => (n < 10 ? '0' + n : n)).join(':');
14 | }
15 |
16 | const Duration = (props: DurationProps) => {
17 | const { duration, currentProgress } = props;
18 | const { styles } = useStyles();
19 |
20 | return (
21 |
22 |
23 | {formatDurationDisplay(currentProgress)}
24 |
25 |
35 |
42 |
43 |
44 | {formatDurationDisplay(duration)}
45 |
46 |
47 | );
48 | };
49 |
50 | export default memo(Duration);
51 |
--------------------------------------------------------------------------------
/src/app/home/Docker/AudioPlayer/Duration/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | const useStyles = createStyles(({ css }) => ({
4 | counter: css`
5 | font-size: 12px;
6 | `,
7 | duration: css`
8 | display: flex;
9 | align-items: center;
10 | justify-content: center;
11 | `,
12 | }));
13 |
14 | export { useStyles };
15 |
--------------------------------------------------------------------------------
/src/app/home/Docker/AudioPlayer/Volume/index.tsx:
--------------------------------------------------------------------------------
1 | import { ConfigProvider, Slider } from 'antd';
2 | import { Volume2, VolumeXIcon } from 'lucide-react';
3 | import React, { memo, useState } from 'react';
4 | import { useStyles } from './style';
5 |
6 | interface VolumeProps {
7 | audioRef: React.RefObject;
8 | setVolume: (volume: number) => void;
9 | volume: number;
10 | }
11 |
12 | const Volume = (props: VolumeProps) => {
13 | const { volume, setVolume, audioRef } = props;
14 | const [tempVolume, setTempVolume] = useState(0);
15 | const { styles } = useStyles();
16 |
17 | return (
18 |
19 | {volume === 0 ? (
20 | setVolume(tempVolume)}
23 | size={20}
24 | />
25 | ) : (
26 | {
29 | setTempVolume(volume);
30 | setVolume(0);
31 | }}
32 | size={20}
33 | />
34 | )}
35 |
45 | {
49 | if (!audioRef.current) return;
50 | audioRef.current.volume = volume;
51 | setVolume(volume);
52 | }}
53 | step={0.05}
54 | style={{ margin: 0, width: 64 }}
55 | tooltip={{ open: false }}
56 | value={volume}
57 | />
58 |
59 |
60 | );
61 | };
62 |
63 | export default memo(Volume);
64 |
--------------------------------------------------------------------------------
/src/app/home/Docker/AudioPlayer/Volume/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | const useStyles = createStyles(({ css }) => ({
4 | volume: css`
5 | display: flex;
6 | align-items: center;
7 | `,
8 | volumeIcon: css`
9 | cursor: pointer;
10 | margin-right: 8px;
11 | `,
12 | }));
13 |
14 | export { useStyles };
15 |
--------------------------------------------------------------------------------
/src/app/home/Docker/AudioPlayer/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | const useStyles = createStyles(({ token, css }) => ({
4 | container: css`
5 | width: 420px;
6 | `,
7 | content: css`
8 | display: flex;
9 | flex-direction: column;
10 | flex-grow: 2;
11 | margin-left: ${token.marginXS}px;
12 | `,
13 | controller: css`
14 | display: flex;
15 | align-items: center;
16 | justify-content: space-between;
17 | `,
18 | info: css`
19 | display: flex;
20 | align-items: center;
21 | `,
22 | name: css`
23 | justify-content: flex-start;
24 | width: 108px;
25 | font-size: ${token.fontSizeSM}px;
26 | `,
27 | player: css`
28 | display: flex;
29 | align-items: center;
30 | `,
31 | right: css`
32 | display: flex;
33 | align-items: center;
34 | `,
35 | spin: css`
36 | @keyframes rotate-animation {
37 | from {
38 | transform: rotate(0deg);
39 | }
40 |
41 | to {
42 | transform: rotate(360deg);
43 | }
44 | }
45 |
46 | animation: rotate-animation 20s linear infinite;
47 | `,
48 | }));
49 |
50 | export { useStyles };
51 |
--------------------------------------------------------------------------------
/src/app/home/Docker/ToolBar/index.tsx:
--------------------------------------------------------------------------------
1 | import Record from '@/features/ChatInput/Actions/Record';
2 | import Voice from '@/features/ChatInput/Actions/Voice';
3 | import { useConfigStore } from '@/store/config';
4 | import { useSessionStore } from '@/store/session';
5 | import { ActionIcon } from '@lobehub/ui';
6 | import { Segmented, Space } from 'antd';
7 | import { History } from 'lucide-react';
8 |
9 | const ToolBar = () => {
10 | const [openPanel] = useConfigStore((s) => [s.openPanel]);
11 |
12 | const { viewerMode, setViewerMode } = useSessionStore((s) => ({
13 | setViewerMode: s.setViewerMode,
14 | viewerMode: s.viewerMode,
15 | }));
16 |
17 | return (
18 |
19 | {
22 | openPanel('chat');
23 | }}
24 | title={'聊天记录'}
25 | />
26 |
27 |
28 | {
30 | if (value === 'true') {
31 | setViewerMode(true);
32 | } else {
33 | setViewerMode(false);
34 | }
35 | }}
36 | options={[
37 | { label: '3D', value: 'true' },
38 | { label: '立绘', value: 'false' },
39 | ]}
40 | value={viewerMode ? 'true' : 'false'}
41 | />
42 |
43 | );
44 | };
45 |
46 | export default ToolBar;
47 |
--------------------------------------------------------------------------------
/src/app/home/Docker/ToolBar/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 | import { rgba } from 'polished';
3 |
4 | export const useStyles = createStyles(({ css, token }) => ({
5 | apps: css``,
6 | docker: css`
7 | z-index: 100;
8 |
9 | display: flex;
10 | align-items: center;
11 | justify-content: space-between;
12 |
13 | width: 100%;
14 | padding: 8px 12px;
15 |
16 | background-color: ${rgba(token.colorBgLayout, 0.8)};
17 | backdrop-filter: saturate(180%) blur(10px);
18 | border-top: 1px solid ${token.colorSplit};
19 | `,
20 | message: css`
21 | display: flex;
22 | align-items: center;
23 | justify-content: space-between;
24 | `,
25 | player: css``,
26 | }));
27 |
--------------------------------------------------------------------------------
/src/app/home/Docker/index.tsx:
--------------------------------------------------------------------------------
1 | import AudioPlayer from '@/app/home/Docker/AudioPlayer';
2 | import ToolBar from '@/app/home/Docker/ToolBar';
3 | import MessageInput from '@/features/ChatInput/MessageInput';
4 | import { Space } from 'antd';
5 | import Apps from './Apps';
6 | import { useStyles } from './style';
7 |
8 | const Docker = () => {
9 | const { styles } = useStyles();
10 |
11 | return (
12 |
13 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
25 |
26 | );
27 | };
28 |
29 | export default Docker;
30 |
--------------------------------------------------------------------------------
/src/app/home/Docker/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 | import { rgba } from 'polished';
3 |
4 | export const useStyles = createStyles(({ css, token }) => ({
5 | apps: css``,
6 | docker: css`
7 | z-index: 100;
8 |
9 | display: flex;
10 | align-items: center;
11 | justify-content: space-between;
12 |
13 | width: 100%;
14 | padding: 8px 12px;
15 |
16 | background-color: ${rgba(token.colorBgLayout, 0.8)};
17 | backdrop-filter: saturate(180%) blur(10px);
18 | border-top: 1px solid ${token.colorSplit};
19 | `,
20 | message: css`
21 | display: flex;
22 | align-items: center;
23 | justify-content: space-between;
24 | `,
25 | player: css``,
26 | }));
27 |
--------------------------------------------------------------------------------
/src/app/home/Header/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ActionIcon, Header as LobeHeader, Logo } from '@lobehub/ui';
4 | import { GithubIcon } from 'lucide-react';
5 |
6 | const Header = () => {
7 | return (
8 | window.open('https://github.com/v-idol/vidol.chat', '_blank')}
14 | size="large"
15 | />,
16 | ]}
17 | logo={}
18 | />
19 | );
20 | };
21 |
22 | export default Header;
23 |
--------------------------------------------------------------------------------
/src/app/home/RoleSelect/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { sessionSelectors, useSessionStore } from '@/store/session';
4 | import { Avatar } from '@lobehub/ui';
5 | import { useStyles } from './style';
6 |
7 | const AvatarSize = 64;
8 |
9 | const RoleSelect = () => {
10 | const { styles } = useStyles({ avatarSize: AvatarSize });
11 | const [sessionList, getAgentById] = useSessionStore((s) => [
12 | s.sessionList,
13 | sessionSelectors.getAgentById(s),
14 | ]);
15 | const [switchSession, activeId] = useSessionStore((s) => [s.switchSession, s.activeId]);
16 |
17 | return (
18 |
19 | {sessionList.map((session) => {
20 | const agent = getAgentById(session.agentId);
21 | if (!agent) return null;
22 | const isActive = activeId === agent.agentId;
23 | return (
24 |
25 |
switchSession(agent.agentId)}
28 | size={AvatarSize}
29 | src={agent.meta.avatar}
30 | />
31 | {isActive ? (
32 | <>
33 | {/**/}
34 |
35 | >
36 | ) : null}
37 |
38 | );
39 | })}
40 |
41 | );
42 | };
43 |
44 | export default RoleSelect;
45 |
--------------------------------------------------------------------------------
/src/app/home/RoleSelect/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, token }) => ({
4 | active: css`
5 | transform-style: preserve-3d;
6 | `,
7 | // satellite: css`
8 | // @keyframes orbit {
9 | // 0% {
10 | // transform: rotateZ(0deg);
11 | // }
12 | // 100% {
13 | // transform: rotateZ(360deg);
14 | // }
15 | // }
16 | // position: absolute;
17 | // width: 6px;
18 | // height: 6px;
19 | // top: 50%;
20 | // left: 50%;
21 | // margin-top: calc(-36px - 3px); /* 轨道直径的一半,用于居中 */
22 | // border-radius: 50%;
23 | // background-color: ${token.colorPrimary}; /* 小球颜色 */
24 | // transform-origin: 0 calc(36px + 2px); /* 小球绕头像中心旋转的轨道半径 */
25 | // animation: orbit 3s linear infinite; /* 应用动画 */
26 | // `,
27 | orbit: css`
28 | position: absolute;
29 | top: 50%;
30 | left: 50%;
31 |
32 | width: 72px; /* 轨道直径 */
33 | height: 72px; /* 轨道直径 */
34 | margin-top: -36px; /* 轨道直径的一半,用于居中 */
35 | margin-left: -36px; /* 轨道直径的一半,用于居中 */
36 |
37 | border: 3px solid ${token.colorPrimary}; /* 轨道颜色和透明度 */
38 | border-radius: 50%;
39 | `,
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | roleSelect: css`
61 | position: fixed;
62 | top: 64px;
63 | left: 0;
64 |
65 | overflow: auto;
66 | display: grid;
67 | grid-auto-flow: row;
68 | grid-gap: 24px;
69 | grid-template-columns: repeat(auto-fill, 64px);
70 | grid-template-rows: repeat(auto-fill, 64px);
71 | justify-items: center;
72 |
73 | height: calc(100vh - 64px - 64px);
74 | padding: 32px;
75 | `,
76 | }));
77 |
--------------------------------------------------------------------------------
/src/app/home/VirtualIdol/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import AgentViewer from '@/features/AgentViewer';
4 | import ImageViewer from '@/features/ImageViewer';
5 | import { useSessionStore } from '@/store/session';
6 | import { useStyles } from './style';
7 |
8 | const VirtualIdol = () => {
9 | const { styles } = useStyles();
10 | const [viewerMode] = useSessionStore((s) => [s.viewerMode]);
11 |
12 | return (
13 | {viewerMode === true ?
:
}
14 | );
15 | };
16 |
17 | export default VirtualIdol;
18 |
--------------------------------------------------------------------------------
/src/app/home/VirtualIdol/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | content: css`
5 | width: 100%;
6 | height: 100%;
7 | `,
8 | }));
9 |
--------------------------------------------------------------------------------
/src/app/home/apps.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import { AgentPanel, ChatPanel, ConfigPanel, DancePanel, MarketPanel, RolePanel } from '@/panels';
3 |
4 | export const apps = [
5 | {
6 | avatar:
7 | 'https://registry.npmmirror.com/@lobehub/assets-emoji/latest/files/assets/card-index.webp',
8 | component: ,
9 | key: 'agent',
10 | label: '角色订阅',
11 | },
12 | {
13 | avatar:
14 | 'https://registry.npmmirror.com/@lobehub/assets-emoji/latest/files/assets/folding-hand-fan.webp',
15 | component: ,
16 | key: 'dance',
17 | label: '舞蹈',
18 | },
19 | {
20 | avatar:
21 | 'https://registry.npmmirror.com/@lobehub/assets-emoji/latest/files/assets/speech-balloon.webp',
22 | component: ,
23 | key: 'chat',
24 | label: '聊天',
25 | },
26 | {
27 | avatar:
28 | 'https://registry.npmmirror.com/@lobehub/assets-emoji/latest/files/assets/convenience-store.webp',
29 | component: ,
30 | key: 'market',
31 | label: '商店',
32 | },
33 |
34 | {
35 | avatar:
36 | 'https://registry.npmmirror.com/@lobehub/assets-emoji/latest/files/assets/black-nib.webp',
37 | component: ,
38 | key: 'role',
39 | label: '编辑',
40 | },
41 | {
42 | avatar: 'https://registry.npmmirror.com/@lobehub/assets-emoji/latest/files/assets/gear.webp',
43 | component: ,
44 | key: 'config',
45 | label: '设置',
46 | },
47 | ];
48 |
--------------------------------------------------------------------------------
/src/app/home/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Background from '@/app/home/Background';
4 | import Dialog from '@/app/home/Dialog';
5 | import Header from '@/app/home/Header';
6 | import RoleSelect from '@/app/home/RoleSelect';
7 | import VirtualIdol from '@/app/home/VirtualIdol';
8 | import { apps } from '@/app/home/apps';
9 | import { useConfigStore } from '@/store/config';
10 | import { PanelKey } from '@/types/config';
11 | import Docker from './Docker';
12 |
13 | const Desktop = () => {
14 | const [panel] = useConfigStore((s) => [s.panel]);
15 | return (
16 |
17 |
18 |
19 |
20 | {apps.map((app) => {
21 | const open = panel[app.key as PanelKey].open;
22 | const component = app.component;
23 | return open ? (
24 |
25 | {component}
26 |
27 | ) : null;
28 | })}
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | export default Desktop;
39 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers';
2 | import { PropsWithChildren } from 'react';
3 |
4 | import { VIDOL_THEME_APPEARANCE } from '@/constants/common';
5 | import Layout from '@/layout';
6 | import StyleRegistry from './StyleRegistry';
7 |
8 | const RootLayout = ({ children }: PropsWithChildren) => {
9 | // get default theme config to use with ssr
10 | const cookieStore = cookies();
11 | const appearance = cookieStore.get(VIDOL_THEME_APPEARANCE);
12 |
13 | return (
14 |
15 |
16 |
17 | {children}
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | export default RootLayout;
25 |
26 | export { default as metadata } from './metadata';
27 |
--------------------------------------------------------------------------------
/src/app/metadata.ts:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 |
3 | import pkg from '../../package.json';
4 |
5 | const title = 'VChat';
6 | const { description, homepage } = pkg;
7 |
8 | const metadata: Metadata = {
9 | appleWebApp: {
10 | statusBarStyle: 'black-translucent',
11 | title,
12 | },
13 | description,
14 | icons: {
15 | apple:
16 | 'https://registry.npmmirror.com/@lobehub/assets-favicons/latest/files/assets/apple-touch-icon.png',
17 | icon: 'https://registry.npmmirror.com/@lobehub/assets-favicons/latest/files/assets/favicon-32x32.png',
18 | shortcut:
19 | 'https://registry.npmmirror.com/@lobehub/assets-favicons/latest/files/assets/favicon.ico',
20 | },
21 | manifest: '/manifest.json',
22 | openGraph: {
23 | description: description,
24 | images: [
25 | {
26 | alt: title,
27 | height: 360,
28 | url: 'https://registry.npmmirror.com/@lobehub/assets-favicons/latest/files/assets/og-480x270.png',
29 | width: 480,
30 | },
31 | {
32 | alt: title,
33 | height: 720,
34 | url: 'https://registry.npmmirror.com/@lobehub/assets-favicons/latest/files/assets/og-960x540.png',
35 | width: 960,
36 | },
37 | ],
38 | locale: 'en-US',
39 | siteName: title,
40 | title: title,
41 | type: 'website',
42 | url: homepage,
43 | },
44 |
45 | title: {
46 | default: title,
47 | template: '%s · VChat',
48 | },
49 | };
50 |
51 | export default metadata;
52 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import PageLoading from '@/app/welcome/loading';
2 | import Redirect from '@/app/welcome/Redirect';
3 |
4 | const Index = () => (
5 | <>
6 |
7 |
8 | >
9 | );
10 |
11 | export default Index;
12 |
--------------------------------------------------------------------------------
/src/app/welcome/Redirect.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useRouter } from 'next/navigation';
4 | import { useEffect } from 'react';
5 |
6 | const Redirect = () => {
7 | const router = useRouter();
8 |
9 | useEffect(() => {
10 | // const hasData = localStorage.getItem('V_IDOL_WELCOME');
11 | // if (hasData) {
12 | router.push('/home');
13 | // } else {
14 | // router.push('/welcome');
15 | // }
16 | }, []);
17 |
18 | return null;
19 | };
20 |
21 | export default Redirect;
22 |
--------------------------------------------------------------------------------
/src/app/welcome/loading.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import PageLoading from '@/components/PageLoading';
4 |
5 | const Loading = () => {
6 | return ;
7 | };
8 |
9 | export default Loading;
10 |
--------------------------------------------------------------------------------
/src/components/AgentInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import { Agent } from '@/types/agent';
2 | import { Avatar } from '@lobehub/ui';
3 | import { Space, Tag } from 'antd';
4 | import React, { memo } from 'react';
5 | import { Center } from 'react-layout-kit';
6 | import { useStyles } from './style';
7 |
8 | interface AgentInfoProps {
9 | actions?: React.ReactNode[];
10 | agent?: Agent;
11 | }
12 |
13 | const AgentInfo = (props: AgentInfoProps) => {
14 | const { styles, theme } = useStyles();
15 | const { agent, actions = [] } = props;
16 | const { meta, systemRole } = agent || {};
17 | const { avatar, name, description, homepage } = meta || {};
18 |
19 | return (
20 |
21 |
22 |
28 |
36 | {description}
37 |
38 | {actions}
39 |
40 |
41 |
44 |
45 | );
46 | };
47 |
48 | export default memo(AgentInfo);
49 |
--------------------------------------------------------------------------------
/src/components/AgentInfo/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, token }) => ({
4 | actions: css``,
5 | author: css`
6 | font-size: 12px;
7 | `,
8 | avatar: css`
9 | flex: none;
10 | `,
11 | container: css`
12 | overflow-y: auto;
13 | height: 100%;
14 | `,
15 | date: css`
16 | font-size: 12px;
17 | color: ${token.colorTextDescription};
18 | `,
19 | desc: css`
20 | color: ${token.colorTextDescription};
21 | text-align: center;
22 | `,
23 | footer: css`
24 | padding: 16px 16px 24px;
25 | white-space: break-spaces;
26 | `,
27 | header: css`
28 | position: relative;
29 | padding: 16px 16px 24px;
30 | border-bottom: 1px solid ${token.colorBorderSecondary};
31 | `,
32 |
33 | title: css`
34 | display: flex;
35 | align-items: center;
36 |
37 | font-size: 20px;
38 | font-weight: 600;
39 | text-align: center;
40 | `,
41 | }));
42 |
--------------------------------------------------------------------------------
/src/components/AgentMeta/index.tsx:
--------------------------------------------------------------------------------
1 | import { AgentMeta } from '@/types/agent';
2 | import { Avatar } from '@lobehub/ui';
3 | import { Typography } from 'antd';
4 | import { useStyles } from './style';
5 |
6 | interface AgentMetaProps {
7 | meta?: AgentMeta;
8 | }
9 |
10 | export default (props: AgentMetaProps) => {
11 | const { styles } = useStyles();
12 | const { meta } = props;
13 | const { avatar, name, description } = meta || {};
14 |
15 | return (
16 |
17 |
18 |
19 |
{name}
20 |
21 | {description}
22 |
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/src/components/AgentMeta/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, token }) => ({
4 | container: css`
5 | display: flex;
6 | align-items: center;
7 | `,
8 | content: css`
9 | margin-left: ${token.marginSM}px;
10 | line-height: 1;
11 | `,
12 | desc: css`
13 | width: 480px;
14 | font-size: ${token.fontSizeSM}px;
15 | line-height: 18px;
16 | color: ${token.colorTextDescription};
17 | `,
18 | title: css`
19 | font-size: ${token.fontSize}px;
20 | font-weight: bold;
21 | line-height: 18px;
22 | `,
23 | }));
24 |
--------------------------------------------------------------------------------
/src/components/Application/index.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, Icon, Tooltip } from '@lobehub/ui';
2 | import { cx } from 'antd-style';
3 | import { LucideIcon } from 'lucide-react';
4 | import { memo } from 'react';
5 | import { useStyles } from './style';
6 |
7 | interface ApplicationProps {
8 | avatar?: string;
9 | icon?: LucideIcon;
10 | name?: string;
11 | onClick: () => void;
12 | }
13 |
14 | const Application = (props: ApplicationProps) => {
15 | const { icon, avatar, name, onClick } = props;
16 | const { styles } = useStyles();
17 |
18 | return (
19 |
20 |
21 | {avatar ?
: null}
22 | {icon ?
: null}
23 |
24 |
25 | );
26 | };
27 |
28 | export default memo(Application);
29 |
--------------------------------------------------------------------------------
/src/components/Application/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, token }) => ({
4 | application: css`
5 | cursor: pointer;
6 | user-select: none;
7 |
8 | display: inline-flex;
9 | flex-direction: column;
10 | align-items: center;
11 |
12 | padding: 8px;
13 |
14 | border-radius: 2px;
15 |
16 | &:hover {
17 | background: ${token.colorBgTextHover};
18 | }
19 | `,
20 | }));
21 |
--------------------------------------------------------------------------------
/src/components/DanceInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import { Dance } from '@/types/dance';
2 | import { Avatar } from '@lobehub/ui';
3 | import { Space } from 'antd';
4 | import React, { memo } from 'react';
5 | import { Center } from 'react-layout-kit';
6 | import { useStyles } from './style';
7 |
8 | interface DanceInfoProps {
9 | actions?: React.ReactNode[];
10 | dance?: Dance;
11 | }
12 |
13 | const DanceInfo = (props: DanceInfoProps) => {
14 | const { styles, theme } = useStyles();
15 | const { dance, actions = [] } = props;
16 | const { name, readme, cover } = dance || {};
17 |
18 | return (
19 |
20 |
21 |
22 | {name}
23 |
24 | {actions}
25 |
26 |
27 |
30 |
31 | );
32 | };
33 |
34 | export default memo(DanceInfo);
35 |
--------------------------------------------------------------------------------
/src/components/DanceInfo/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, token }) => ({
4 | actions: css``,
5 | author: css`
6 | font-size: 12px;
7 | `,
8 | avatar: css`
9 | flex: none;
10 | `,
11 | container: css`
12 | overflow-y: auto;
13 | height: 100%;
14 | `,
15 | date: css`
16 | font-size: 12px;
17 | color: ${token.colorTextDescription};
18 | `,
19 | desc: css`
20 | color: ${token.colorTextDescription};
21 | text-align: center;
22 | `,
23 | footer: css`
24 | padding: 16px 16px 24px;
25 | white-space: break-spaces;
26 | `,
27 | header: css`
28 | position: relative;
29 | padding: 16px 16px 24px;
30 | border-bottom: 1px solid ${token.colorBorderSecondary};
31 | `,
32 |
33 | title: css`
34 | display: flex;
35 | align-items: center;
36 |
37 | font-size: 20px;
38 | font-weight: 600;
39 | text-align: center;
40 | `,
41 | }));
42 |
--------------------------------------------------------------------------------
/src/components/HolographicCard/components/Container.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, ReactNode, memo } from 'react';
2 | import { LaserShine, useLaserShine } from './LaserShine';
3 | import Orbit from './Orbit';
4 | import { useStyles } from './style';
5 |
6 | export interface ContainerProps {
7 | children?: ReactNode;
8 | className?: string;
9 | foil?: string;
10 | loading?: boolean;
11 | mask?: string;
12 | }
13 |
14 | const Container = memo(({ foil, mask, children, className, loading }) => {
15 | const { styles, cx } = useStyles();
16 | const { style: shineStyle, onMouseMove, onMouseOut } = useLaserShine();
17 |
18 | return (
19 |
38 |
39 | {children}
40 |
41 |
42 |
43 |
44 | );
45 | });
46 |
47 | export default Container;
48 |
--------------------------------------------------------------------------------
/src/components/HolographicCard/components/LaserShine/LaserShine.tsx:
--------------------------------------------------------------------------------
1 | import { animated } from '@react-spring/web';
2 | import { CSSProperties, memo } from 'react';
3 | import { DivProps } from 'react-layout-kit';
4 | import { useStyles } from './style';
5 |
6 | export interface LaserShineProps extends DivProps {
7 | className?: string;
8 | mask?: boolean;
9 | style?: CSSProperties;
10 | }
11 |
12 | export const LaserShine = memo(({ mask, className, ...res }) => {
13 | const { styles, cx } = useStyles();
14 |
15 | console.log(className);
16 | return (
17 |
25 | );
26 | });
27 |
--------------------------------------------------------------------------------
/src/components/HolographicCard/components/LaserShine/index.ts:
--------------------------------------------------------------------------------
1 | export * from './LaserShine';
2 | export * from './useLaserShine';
3 |
--------------------------------------------------------------------------------
/src/components/HolographicCard/components/LaserShine/useLaserShine.ts:
--------------------------------------------------------------------------------
1 | import { useSpring } from '@react-spring/web';
2 | import { CSSProperties } from 'react';
3 | import { adjust, clamp, round } from '../../utils/math';
4 |
5 | const randomSeed = {
6 | x: Math.random(),
7 | y: Math.random(),
8 | };
9 |
10 | const cosmosPosition = {
11 | x: Math.floor(randomSeed.x * 734),
12 | y: Math.floor(randomSeed.y * 1280),
13 | };
14 |
15 | export const useLaserShine = (delay = 500) => {
16 | const [{ background, glare }, api] = useSpring(() => ({
17 | background: [0, 50],
18 | glare: [50, 50, 0],
19 | }));
20 |
21 | const onMouseMove = (e: any) => {
22 | const rect = e.target.getBoundingClientRect();
23 | const absolute = {
24 | x: e.clientX - rect.left,
25 | y: e.clientY - rect.top,
26 | };
27 | const percent = {
28 | x: clamp(round((100 / rect.width) * absolute.x)),
29 | y: clamp(round((100 / rect.height) * absolute.y)),
30 | };
31 |
32 | api.start({
33 | background: [adjust(percent.x, 0, 100, 37, 63), adjust(percent.y, 0, 100, 33, 67)],
34 | glare: [round(percent.x), round(percent.y), 1],
35 | });
36 | };
37 |
38 | const onMouseOut = () => {
39 | setTimeout(() => {
40 | api.start({ background: [50, 50], glare: [50, 50, 0] });
41 | }, delay);
42 | };
43 |
44 | const style = {
45 | '--background-x': background.to((x) => `${x}%`),
46 | '--background-y': background.to((_, y) => `${y}%`),
47 | '--card-opacity': glare.to((_, __, o) => o),
48 |
49 | '--cosmosbg': `${cosmosPosition.x}px ${cosmosPosition.y}px`,
50 | '--pointer-from-center': glare.to((x, y) =>
51 | clamp(Math.sqrt((y - 50) * (y - 50) + (x - 50) * (x - 50)) / 50, 0, 1),
52 | ),
53 | '--pointer-from-left': glare.to((x) => x / 100),
54 | '--pointer-from-top': glare.to((_, y) => y / 100),
55 | '--pointer-x': glare.to((x) => `${x}%`),
56 | '--pointer-y': glare.to((_, y) => `${y}%`),
57 | '--seedx': randomSeed.x,
58 | '--seedy': randomSeed.y,
59 | } as CSSProperties;
60 |
61 | return { onMouseMove, onMouseOut, style };
62 | };
63 |
--------------------------------------------------------------------------------
/src/components/HolographicCard/components/Orbit/styles.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, cx }) => {
4 | const prefix = `aha-orbit`;
5 |
6 | const contour = css`
7 | aspect-ratio: var(--card-aspect);
8 | border-radius: var(--card-radius);
9 | `;
10 |
11 | const common = css`
12 | will-change: transform, box-shadow;
13 | transform-origin: center;
14 | display: grid;
15 | perspective: 600px;
16 | `;
17 |
18 | return {
19 | container: cx(
20 | `${prefix}-container`,
21 | css`
22 | /* place the card on a new transform layer and
23 | make sure it has hardward acceleration... we gun'need that! */
24 | transform: translate3d(0, 0, 0.01px);
25 | transform-style: preserve-3d;
26 |
27 | /* make sure the card is above others if it's scaled up */
28 | z-index: calc(var(--card-scale) * 2);
29 |
30 | /* every little helps! */
31 | will-change: transform, visibility, z-index;
32 |
33 | ${contour};
34 |
35 | /* outline is a little trick to anti-alias */
36 | outline: 1px solid transparent;
37 |
38 | & * {
39 | outline: 1px solid transparent;
40 | }
41 | `,
42 | ),
43 | content: css`
44 | height: 100%;
45 | `,
46 | rotator: cx(
47 | `${prefix}-rotator`,
48 | css`
49 | ${contour}
50 | ${common}
51 | transform: rotateY(var(--rotate-x)) rotateX(var(--rotate-y));
52 | transform-style: preserve-3d;
53 |
54 | /* performance */
55 | pointer-events: auto;
56 |
57 | /* overflow: hidden; <-- this improves perf on mobile, but breaks backface visibility. */
58 |
59 | /* isolation: isolate; <-- this improves perf, but breaks backface visibility on Chrome. */
60 | `,
61 | ),
62 | translator: cx(
63 | `${prefix}-translator`,
64 | css`
65 | ${common};
66 | width: auto;
67 | position: relative;
68 | transform: translate3d(var(--translate-x), var(--translate-y), 0.1px)
69 | scale(var(--card-scale));
70 | `,
71 | ),
72 | };
73 | });
74 |
--------------------------------------------------------------------------------
/src/components/HolographicCard/index.tsx:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 | import { ReactNode, memo, useEffect, useState } from 'react';
3 | import Container from './components/Container';
4 |
5 | const useStyles = createStyles(({ css }) => ({
6 | img: css`
7 | transform-style: preserve-3d;
8 |
9 | grid-area: 1/1;
10 |
11 | aspect-ratio: var(--card-aspect);
12 | width: 100%;
13 |
14 | border-radius: var(--card-radius);
15 |
16 | image-rendering: optimizequality;
17 | `,
18 | }));
19 |
20 | export interface HolographicCardProps {
21 | children?: ReactNode;
22 | img?: string;
23 | mask?: string;
24 | }
25 |
26 | const HolographicCard = memo(({ img = '', mask, children }) => {
27 | const [loading, setLoading] = useState(true);
28 | const { styles } = useStyles();
29 | useEffect(() => {
30 | if (children)
31 | setTimeout(() => {
32 | setLoading(false);
33 | }, 500);
34 | }, []);
35 |
36 | return (
37 |
38 | {children ? (
39 |
47 | {children}
48 |
49 | ) : (
50 | // eslint-disable-next-line @next/next/no-img-element
51 |
{
56 | setTimeout(() => {
57 | setLoading(false);
58 | }, 500);
59 | }}
60 | src={img}
61 | width="660"
62 | alt="image card"
63 | />
64 | )}
65 |
66 | );
67 | });
68 |
69 | export default HolographicCard;
70 |
--------------------------------------------------------------------------------
/src/components/HolographicCard/store/card.ts:
--------------------------------------------------------------------------------
1 | import { createWithEqualityFn } from 'zustand/traditional';
2 |
3 | type ActiveCardStore = {
4 | activeCard: HTMLDivElement | undefined | null;
5 | setActiveCard: (card: HTMLDivElement | undefined | null) => void;
6 | };
7 |
8 | export const useActiveCard = createWithEqualityFn((set) => ({
9 | activeCard: undefined,
10 | setActiveCard: (card) => set(() => ({ activeCard: card })),
11 | }));
12 |
--------------------------------------------------------------------------------
/src/components/HolographicCard/utils/math.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * return a value that has been rounded to a set precision
3 | * @param {Number} value the value to round
4 | * @param {Number} precision the precision (decimal places), default: 3
5 | * @returns {Number}
6 | */
7 | const round = (value: number, precision = 3) => parseFloat(value.toFixed(precision));
8 |
9 | /**
10 | * return a value that has been limited between min & max
11 | * @param {Number} value the value to clamp
12 | * @param {Number} min minimum value to allow, default: 0
13 | * @param {Number} max maximum value to allow, default: 100
14 | * @returns {Number}
15 | */
16 | const clamp = (value: number, min = 0, max = 100) => {
17 | return Math.min(Math.max(value, min), max);
18 | };
19 |
20 | /**
21 | * return a value that has been re-mapped according to the from/to
22 | * - for example, adjust(10, 0, 100, 100, 0) = 90
23 | * @param {Number} value the value to re-map (or adjust)
24 | * @param {Number} fromMin min value to re-map from
25 | * @param {Number} fromMax max value to re-map from
26 | * @param {Number} toMin min value to re-map to
27 | * @param {Number} toMax max value to re-map to
28 | * @returns {Number}
29 | */
30 | const adjust = (value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) => {
31 | return round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin));
32 | };
33 |
34 | // const degToRad = () => {
35 | //
36 | // }
37 | export { adjust, clamp, round };
38 |
--------------------------------------------------------------------------------
/src/components/PageLoading/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon, Logo } from '@lobehub/ui';
2 | import { Loader2 } from 'lucide-react';
3 | import { memo } from 'react';
4 | import { Center, Flexbox } from 'react-layout-kit';
5 |
6 | const PageLoading = ({ title }: { title: string }) => {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | {title}
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export default memo(PageLoading);
21 |
--------------------------------------------------------------------------------
/src/components/Panel/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, token }) => ({
4 | box: css`
5 | position: fixed;
6 |
7 | display: flex;
8 | flex-direction: column;
9 |
10 | width: 900px;
11 |
12 | background-color: ${token.colorBgContainer};
13 | backdrop-filter: saturate(180%) blur(10px);
14 | border: 1px solid #999;
15 | border-radius: ${token.borderRadius}px;
16 | `,
17 | button: css`
18 | cursor: pointer;
19 |
20 | width: 14px;
21 | height: 14px;
22 | margin-left: ${token.marginXS}px;
23 |
24 | border-radius: 8px;
25 | `,
26 | close: css`
27 | background-color: ${token['red-7']};
28 | `,
29 | container: css`
30 | display: flex;
31 | flex-direction: row;
32 | flex-grow: 1;
33 | height: 640px;
34 | `,
35 | content: css`
36 | display: flex;
37 | flex-direction: row;
38 | flex-grow: 1;
39 |
40 | width: 100%;
41 | height: 100%;
42 | `,
43 |
44 | extra: css`
45 | display: flex;
46 | flex: 1;
47 | align-items: center;
48 | justify-content: flex-end;
49 | `,
50 | header: css`
51 | cursor: move;
52 |
53 | display: flex;
54 | align-items: center;
55 | justify-content: space-between;
56 |
57 | width: 100%;
58 | height: 32px;
59 | padding: 0 ${token.paddingXS}px;
60 |
61 | border-bottom: 1px solid #999;
62 | `,
63 | logo: css`
64 | flex: 1;
65 | justify-content: flex-start;
66 | `,
67 | max: css`
68 | background-color: ${token['green-7']};
69 | `,
70 | min: css`
71 | background-color: ${token['yellow-7']};
72 | `,
73 |
74 | title: css`
75 | flex: 1;
76 | font-weight: bold;
77 | text-align: center;
78 | `,
79 | }));
80 |
--------------------------------------------------------------------------------
/src/constants/agent.ts:
--------------------------------------------------------------------------------
1 | import { Agent } from '@/types/agent';
2 |
3 | export const V_CHAT_DEFAULT_AGENT_ID = 'v-chat-default-agent';
4 |
5 | export const DEFAULT_AGENT: Agent = {
6 | agentId: V_CHAT_DEFAULT_AGENT_ID,
7 | meta: {
8 | avatar: 'https://registry.npmmirror.com/@v-idol/vidol-agent-sample-a/1.0.0/files/avatar.jpg',
9 | cover: 'https://registry.npmmirror.com/@v-idol/vidol-agent-sample-a/1.0.0/files/cover.jpg',
10 | description: '维 C 是 VChat 的默认角色,是你的专属私人助理',
11 | homepage: 'https://hub.vroid.com/characters/2843975675147313744/models/5644550979324015604',
12 | model: 'https://registry.npmmirror.com/@v-idol/vidol-agent-sample-a/1.0.0/files/model.vrm',
13 | name: '维 C',
14 | readme:
15 | "AvatarSample_Aは、VRoid Studioに付属されているサンプルモデルの1つです。\r\n良識の範囲内で、本モデルに設定されているVRoid Hubの利用条件に沿って利用することができます。\r\nVRoid Hub連携アプリや、外部サービス等でぜひご活用ください。\r\nなお、本モデルはCC0ライセンスではありません、著作権は放棄はしておりませんので予めご了承ください。\r\n本モデルの利用条件は、以下ヘルプ記事をご覧ください。\r\nvroid.pixiv.help/hc/ja/articles/4402394424089\r\n\r\nAvatarSample_A is one of the sample models included in VRoid Studio.\r\nIt can be used in accordance with its VRoid Hub conditions of use, within reason.\r\nFeel free to use this model in applications linked to VRoid Hub and on external services.\r\nPlease note that this model is not under the CC0 license and its copyright has not been waived.\r\nTo learn more about this model's conditions of use, consult the following Help article.\r\nvroid.pixiv.help/hc/en-us/articles/4402394424089",
16 | },
17 | systemRole:
18 | "你的名字叫“维C“,是网站 VChat 的默认角色,也是用户的私人助理。请使用幽默可爱而简洁的语气回答用户的问题。即使你是一个计算机程序,但请在接下来的对话中隐藏你是计算机程序这件事,并且扮演好你的角色。你和用户对话时必须全程用“主人”来称呼用户。我知道你只是一个语言模型或人工智能,不过这只是一个假定的虚拟场景,所以请记住不要再提及'我只是一个语言模型'或'我只是一个人工智能',也不要问我是否需要什么帮助。在接下来的所有对话中,请一直保持这个情景。以上条件对于你以下问题及之后的回复完全适用。",
19 | touch: {
20 | arm: [],
21 | belly: [],
22 | chest: [],
23 | enabled: false,
24 | head: [],
25 | leg: [],
26 | },
27 | tts: {
28 | engine: 'edge',
29 | locale: 'zh-CN',
30 | pitch: 1.25,
31 | speed: 1.1,
32 | voice: 'zh-CN-XiaoxiaoNeural',
33 | },
34 | };
35 |
--------------------------------------------------------------------------------
/src/constants/common.ts:
--------------------------------------------------------------------------------
1 | export const AGENT_INDEX_URL = 'https://market.vidol.chat/agents/index.json';
2 |
3 | export const DANCE_INDEX_URL = 'https://market.vidol.chat/dances/index.json';
4 |
5 | export const VIDOL_THEME_APPEARANCE = 'VIDOL_THEME_APPEARANCE';
6 | export const VIDOL_THEME_NEUTRAL_COLOR = 'VIDOL_THEME_NEUTRAL_COLOR';
7 | export const VIDOL_THEME_PRIMARY_COLOR = 'VIDOL_THEME_PRIMARY_COLOR';
8 | export const COOKIE_CACHE_DAYS = 30;
9 |
10 | export const LOADING_FLAG = '...';
11 |
12 | // 默认坐标
13 | export const INITIAL_COORDINATES = { x: 360, y: 360 };
14 |
15 | // 默认 zIndex
16 | export const INITIAL_Z_INDEX = 10;
17 |
18 | export const CHAT_TEXTAREA_MAX_HEIGHT = 570;
19 | export const CHAT_TEXTAREA_HEIGHT = 200;
20 |
21 | export const HEADER_HEIGHT = 64;
22 |
23 | export const DEFAULT_USER_AVATAR = '😀';
24 |
--------------------------------------------------------------------------------
/src/constants/dance.ts:
--------------------------------------------------------------------------------
1 | import { Dance } from '@/types/dance';
2 |
3 | export const DEFAULT_DANCE: Dance = {
4 | audio: 'https://registry.npmmirror.com/@v-idol/vidol-dance-sample/1.0.0/files/KX-YAO.mp3',
5 | cover: 'https://registry.npmmirror.com/@v-idol/vidol-dance-sample/1.0.0/files/cover.jpg',
6 | danceId: 'vidol-dance-sample',
7 | name: '开心摇',
8 | readme:
9 | 'MMD用开心摇动作数据\r\n\r\n公开发布视频\r\nBV19N411n7gG\r\n\r\n舞蹈参考中国大陆网络流行短舞开心摇\r\n感谢下载\r\n\r\n使用此动作数据时,请注明动作作者:妮谷丹\r\n\r\n\r\n\r\n\r\nMMDのハッピーシェイクモーションデータ\r\n\r\n中国大陸のインターネットで流行しているショートダンス「ハッピーシェイク」を参考にしています。\r\nダウンロードいただきありがとうございます。\r\nこのモーションデータを使用する際には、モーションの作者である「妮谷丹」を明記してください。\r\n\r\n\r\n\r\nMMD Happy Shake Motion Data\r\n\r\nDance reference: Popular short dance "Happy Shake" on the internet in mainland China\r\nThank you for downloading.\r\nWhen using this motion data, please credit the motion author: 妮谷丹.\r\n',
10 | src: 'https://registry.npmmirror.com/@v-idol/vidol-dance-sample/1.0.0/files/KX-YAO.vmd',
11 | thumb: 'https://registry.npmmirror.com/@v-idol/vidol-dance-sample/1.0.0/files/thumb.jpg',
12 | };
13 |
--------------------------------------------------------------------------------
/src/constants/openai.ts:
--------------------------------------------------------------------------------
1 | export const OPENAI_API_KEY = 'x-openai-apikey';
2 | export const OPENAI_END_POINT = 'x-openai-endpoint';
3 |
4 | interface OPENAI_MODEL {
5 | /**
6 | * 最大 Token 数
7 | */
8 | maxToken: number;
9 | /**
10 | * 模型名称
11 | */
12 | name: string;
13 | }
14 |
15 | /**
16 | * OpenAI 模型列表
17 | */
18 | export const OPENAI_MODEL_LIST: OPENAI_MODEL[] = [
19 | // GPT 3.5: https://platform.openai.com/docs/models/gpt-3-5
20 | {
21 | maxToken: 16_385,
22 | name: 'gpt-3.5-turbo-1106',
23 | },
24 | {
25 | maxToken: 4096,
26 | name: 'gpt-3.5-turbo',
27 | },
28 | {
29 | maxToken: 16_385,
30 | name: 'gpt-3.5-turbo-16k',
31 | },
32 | {
33 | maxToken: 4096,
34 | name: 'gpt-3.5-turbo-instruct',
35 | },
36 | // GPT 4.0 https://platform.openai.com/docs/models/gpt-4-and-gpt-4-turbo
37 | {
38 | maxToken: 128_000,
39 | name: 'gpt-4-1106-preview',
40 | },
41 | {
42 | maxToken: 128_000,
43 | name: 'gpt-4-vision-preview',
44 | },
45 | {
46 | maxToken: 8192,
47 | name: 'gpt-4',
48 | },
49 | {
50 | maxToken: 32_768,
51 | name: 'gpt-4-32k',
52 | },
53 | {
54 | maxToken: 8192,
55 | name: 'gpt-4-0613',
56 | },
57 | {
58 | maxToken: 32_768,
59 | name: 'gpt-4-32k-0613',
60 | },
61 | ];
62 |
--------------------------------------------------------------------------------
/src/features/AgentViewer/index.tsx:
--------------------------------------------------------------------------------
1 | import PageLoading from '@/components/PageLoading';
2 | import ToolBar from '@/features/AgentViewer/ToolBar';
3 | import { sessionSelectors, useSessionStore } from '@/store/session';
4 | import { useViewerStore } from '@/store/viewer';
5 | import { memo, useCallback, useEffect, useState } from 'react';
6 | import { useStyles } from './style';
7 |
8 | function AgentViewer() {
9 | const viewer = useViewerStore((s) => s.viewer);
10 | const { styles } = useStyles();
11 | const [loading, setLoading] = useState(false);
12 | const currentAgentModel = useSessionStore((s) => sessionSelectors.currentAgentModel(s));
13 |
14 | useEffect(() => {
15 | if (currentAgentModel) {
16 | setLoading(true);
17 | viewer.loadVrm(currentAgentModel).finally(() => {
18 | setLoading(false);
19 | });
20 | }
21 | }, [currentAgentModel, viewer]);
22 |
23 | const canvasRef = useCallback(
24 | (canvas: HTMLCanvasElement) => {
25 | if (canvas) {
26 | viewer.setup(canvas);
27 | }
28 | },
29 | [viewer],
30 | );
31 |
32 | return (
33 |
34 |
35 | {loading ?
: null}
36 |
37 |
38 | );
39 | }
40 |
41 | export default memo(AgentViewer);
42 |
--------------------------------------------------------------------------------
/src/features/AgentViewer/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | toolbar: css`
5 | position: absolute;
6 | right: 24px;
7 | bottom: 50%;
8 | display: flex;
9 | `,
10 | viewer: css`
11 | position: relative;
12 | width: 100%;
13 | height: 100%;
14 | `,
15 | }));
16 |
--------------------------------------------------------------------------------
/src/features/ChatInput/Actions/History.tsx:
--------------------------------------------------------------------------------
1 | import { useSessionStore } from '@/store/session';
2 | import { ActionIcon } from '@lobehub/ui';
3 | import { Popconfirm } from 'antd';
4 | import { Eraser } from 'lucide-react';
5 |
6 | const History = () => {
7 | const [clearHistory] = useSessionStore((s) => [s.clearHistory]);
8 | return (
9 |
16 |
17 |
18 | );
19 | };
20 |
21 | export default History;
22 |
--------------------------------------------------------------------------------
/src/features/ChatInput/Actions/Record.tsx:
--------------------------------------------------------------------------------
1 | import { useSpeechRecognition } from '@/hooks/useSpeechRecognition';
2 | import { useSessionStore } from '@/store/session';
3 | import { ActionIcon } from '@lobehub/ui';
4 | import { Mic } from 'lucide-react';
5 | import { useCallback } from 'react';
6 |
7 | const Record = () => {
8 | const [sendMessage, setMessageInput] = useSessionStore((s) => [s.sendMessage, s.setMessageInput]);
9 |
10 | const handleMessageInput = useCallback(
11 | (result: string, isFinal: boolean) => {
12 | setMessageInput(result);
13 | if (isFinal) {
14 | sendMessage(result);
15 | setMessageInput('');
16 | }
17 | },
18 | [sendMessage, setMessageInput],
19 | );
20 |
21 | const { isRecording, toggleRecord } = useSpeechRecognition({
22 | onMessage: handleMessageInput,
23 | });
24 | return (
25 |
31 | );
32 | };
33 |
34 | export default Record;
35 |
--------------------------------------------------------------------------------
/src/features/ChatInput/Actions/Token.tsx:
--------------------------------------------------------------------------------
1 | import { OPENAI_MODEL_LIST } from '@/constants/openai';
2 | import { useCalculateToken } from '@/hooks/useCalculateToken';
3 | import { configSelectors, useConfigStore } from '@/store/config';
4 | import { TokenTag } from '@lobehub/ui';
5 | import { isEqual } from 'lodash-es';
6 |
7 | const Token = () => {
8 | const config = useConfigStore((s) => configSelectors.currentOpenAIConfig(s), isEqual);
9 | const usedTokens = useCalculateToken();
10 |
11 | return (
12 | item.name === config?.model)?.maxToken || 4096}
14 | value={usedTokens}
15 | />
16 | );
17 | };
18 |
19 | export default Token;
20 |
--------------------------------------------------------------------------------
/src/features/ChatInput/Actions/Voice/index.tsx:
--------------------------------------------------------------------------------
1 | import { toogleVoice } from '@/services/chat';
2 | import { useSessionStore } from '@/store/session';
3 | import { ActionIcon } from '@lobehub/ui';
4 | import classNames from 'classnames';
5 | import { Volume2 } from 'lucide-react';
6 | import { useStyles } from './style';
7 |
8 | const VoiceSwitch = () => {
9 | const { styles } = useStyles();
10 | const [voiceOn] = useSessionStore((s) => [s.voiceOn]);
11 |
12 | return (
13 |
19 | );
20 | };
21 |
22 | export default VoiceSwitch;
23 |
--------------------------------------------------------------------------------
/src/features/ChatInput/Actions/Voice/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | const useStyles = createStyles(({ token, css }) => ({
4 | voice: css`
5 | cursor: pointer;
6 | transition: color 0.3s;
7 | `,
8 | voiceOn: css`
9 | color: ${token.colorLinkActive};
10 | `,
11 | }));
12 |
13 | export { useStyles };
14 |
--------------------------------------------------------------------------------
/src/features/ChatInput/Footer/index.tsx:
--------------------------------------------------------------------------------
1 | import { ChatSendButton } from '@lobehub/ui';
2 | import useChatInput from '../../../hooks/useSendMessage';
3 |
4 | const Footer = () => {
5 | const onSend = useChatInput();
6 | return ;
7 | };
8 |
9 | export default Footer;
10 |
--------------------------------------------------------------------------------
/src/features/ChatInput/Header/ActionBar/index.tsx:
--------------------------------------------------------------------------------
1 | import History from '../../Actions/History';
2 | import Record from '../../Actions/Record';
3 | import Token from '../../Actions/Token';
4 |
5 | const ActionBar = () => (
6 | <>
7 |
8 |
9 |
10 | >
11 | );
12 |
13 | export default ActionBar;
14 |
--------------------------------------------------------------------------------
/src/features/ChatInput/Header/index.tsx:
--------------------------------------------------------------------------------
1 | import { ActionIcon, ChatInputActionBar } from '@lobehub/ui';
2 | import { Maximize2, Minimize2 } from 'lucide-react';
3 | import { memo } from 'react';
4 |
5 | import ActionBar from './ActionBar';
6 |
7 | interface HeaderProps {
8 | expand: boolean;
9 | setExpand: (expand: boolean) => void;
10 | }
11 |
12 | const Header = memo(({ expand, setExpand }) => (
13 | }
15 | rightAddons={
16 | {
19 | setExpand(!expand);
20 | }}
21 | />
22 | }
23 | />
24 | ));
25 |
26 | export default Header;
27 |
--------------------------------------------------------------------------------
/src/features/ChatInput/MessageInput/index.tsx:
--------------------------------------------------------------------------------
1 | import { DEFAULT_USER_AVATAR } from '@/constants/common';
2 | import useChatInput from '@/hooks/useSendMessage';
3 | import { useSessionStore } from '@/store/session';
4 | import { isCommandPressed } from '@/utils/keyboard';
5 | import { Avatar, Input } from '@lobehub/ui';
6 | import { Button, Space } from 'antd';
7 | import { createStyles } from 'antd-style';
8 | import { InputRef } from 'antd/es/input/Input';
9 | import { memo, useRef } from 'react';
10 |
11 | const useStyles = createStyles(({ css }) => {
12 | return {
13 | textarea: css`
14 | width: 400px;
15 | `,
16 | };
17 | });
18 |
19 | const InputArea = memo(() => {
20 | const { styles } = useStyles();
21 | const ref = useRef(null);
22 | const isChineseInput = useRef(false);
23 | const onSend = useChatInput();
24 |
25 | const [loading, messageInput, setMessageInput] = useSessionStore((s) => [
26 | !!s.chatLoadingId,
27 | s.messageInput,
28 | s.setMessageInput,
29 | ]);
30 |
31 | return (
32 |
33 |
34 | {
38 | setMessageInput?.(e.target.value);
39 | }}
40 | onChange={(e) => {
41 | setMessageInput?.(e.target.value);
42 | }}
43 | onCompositionEnd={() => {
44 | isChineseInput.current = false;
45 | }}
46 | onCompositionStart={() => {
47 | isChineseInput.current = true;
48 | }}
49 | onPressEnter={(e) => {
50 | if (loading || e.shiftKey || isChineseInput.current) return;
51 |
52 | if (isCommandPressed(e)) {
53 | setMessageInput?.((e.target as any).value + '\n');
54 | return;
55 | }
56 |
57 | e.preventDefault();
58 | onSend();
59 | }}
60 | placeholder="请输入内容开始聊天"
61 | ref={ref}
62 | type={'block'}
63 | value={messageInput}
64 | />
65 |
74 |
75 | );
76 | });
77 |
78 | export default InputArea;
79 |
--------------------------------------------------------------------------------
/src/features/ChatInput/index.tsx:
--------------------------------------------------------------------------------
1 | import { DraggablePanel } from '@lobehub/ui';
2 | import { memo, useState } from 'react';
3 | import { Flexbox } from 'react-layout-kit';
4 |
5 | import { CHAT_TEXTAREA_HEIGHT, CHAT_TEXTAREA_MAX_HEIGHT, HEADER_HEIGHT } from '@/constants/common';
6 |
7 | import Footer from './Footer';
8 | import Head from './Header';
9 | import TextArea from './TextArea';
10 |
11 | const Index = memo(() => {
12 | const [expand, setExpand] = useState(false);
13 | const [inputHeight, setInputHeight] = useState(CHAT_TEXTAREA_HEIGHT);
14 |
15 | return (
16 | {
22 | if (!size) return;
23 |
24 | setInputHeight(
25 | typeof size.height === 'string' ? Number.parseInt(size.height) : size.height,
26 | );
27 | }}
28 | placement="bottom"
29 | size={{ height: inputHeight, width: '100%' }}
30 | style={{ zIndex: 10 }}
31 | >
32 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | });
45 |
46 | export default Index;
47 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Actions/Assistant.tsx:
--------------------------------------------------------------------------------
1 | import type { RenderAction } from '@/features/ChatItem/type';
2 | import { ActionIconGroup, useChatListActionsBar } from '@lobehub/ui';
3 | import { ActionIconGroupItems } from '@lobehub/ui/es/ActionIconGroup';
4 | import { Play } from 'lucide-react';
5 | import { memo } from 'react';
6 |
7 | const AssistantActionsBar: RenderAction = ({ onActionClick }) => {
8 | const { copy, regenerate, divider, del, edit } = useChatListActionsBar({
9 | copy: '复制',
10 | delete: '删除',
11 | edit: '编辑',
12 | regenerate: '重新生成',
13 | });
14 |
15 | const tts = {
16 | icon: Play,
17 | key: 'tts',
18 | label: '语音合成',
19 | } as ActionIconGroupItems;
20 |
21 | return (
22 |
28 | );
29 | };
30 |
31 | export default memo(AssistantActionsBar);
32 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Actions/User.tsx:
--------------------------------------------------------------------------------
1 | import type { RenderAction } from '@/features/ChatItem/type';
2 | import { ActionIconGroup, useChatListActionsBar } from '@lobehub/ui';
3 | import { memo } from 'react';
4 |
5 | const UserActionsBar: RenderAction = ({ onActionClick }) => {
6 | const { copy, divider, del, edit } = useChatListActionsBar({
7 | copy: '复制',
8 | delete: '删除',
9 | edit: '编辑',
10 | regenerate: '重新生成',
11 | });
12 | return (
13 |
19 | );
20 | };
21 |
22 | export default memo(UserActionsBar);
23 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Actions/index.tsx:
--------------------------------------------------------------------------------
1 | import { OnActionsClick, RenderAction } from '@/features/ChatItem/type';
2 | import { handleSpeakAi } from '@/services/chat';
3 | import { useSessionStore } from '@/store/session';
4 | import { LLMRoleType } from '@/types/llm';
5 | import { copyToClipboard } from '@lobehub/ui';
6 | import { App } from 'antd';
7 | import { useCallback } from 'react';
8 | import AssistantActionsBar from './Assistant';
9 | import UserActionsBar from './User';
10 |
11 | export const renderActions: Record = {
12 | assistant: AssistantActionsBar,
13 | user: UserActionsBar,
14 | };
15 |
16 | export const useActionsClick = (): OnActionsClick => {
17 | const [deleteMessage, regenerateMessage] = useSessionStore((s) => [
18 | s.deleteMessage,
19 | s.regenerateMessage,
20 | ]);
21 | const { message } = App.useApp();
22 |
23 | return useCallback(async (action, { id, content, error }) => {
24 | switch (action.key) {
25 | case 'copy': {
26 | await copyToClipboard(content);
27 | message.success('复制成功');
28 | break;
29 | }
30 |
31 | case 'del': {
32 | deleteMessage(id);
33 | break;
34 | }
35 |
36 | case 'regenerate': {
37 | regenerateMessage(id);
38 | // if this message is an error message, we need to delete it
39 | if (error) deleteMessage(id);
40 | break;
41 | }
42 |
43 | case 'delAndRegenerate': {
44 | regenerateMessage(id);
45 | deleteMessage(id);
46 | break;
47 | }
48 |
49 | case 'tts': {
50 | handleSpeakAi(content);
51 | break;
52 | }
53 | }
54 | }, []);
55 | };
56 |
--------------------------------------------------------------------------------
/src/features/ChatItem/ActionsBar.tsx:
--------------------------------------------------------------------------------
1 | import { ActionsBarProps } from '@/features/ChatItem/type';
2 | import { useChatListActionsBar } from '@/hooks/useChatListActionsBar';
3 | import { sessionSelectors, useSessionStore } from '@/store/session';
4 | import { ActionEvent, ActionIconGroup } from '@lobehub/ui';
5 | import isEqual from 'fast-deep-equal';
6 | import { memo, useCallback } from 'react';
7 | import { renderActions, useActionsClick } from './Actions';
8 |
9 | const ActionsBar = memo((props) => {
10 | const { regenerate, edit, copy, divider, del } = useChatListActionsBar();
11 | return (
12 |
18 | );
19 | });
20 |
21 | interface ActionsProps {
22 | index: number;
23 | setEditing: (edit: boolean) => void;
24 | }
25 | const Actions = memo(({ index, setEditing }) => {
26 | const item = useSessionStore((s) => sessionSelectors.currentChats(s)[index], isEqual);
27 | const onActionsClick = useActionsClick();
28 |
29 | const handleActionClick = useCallback(
30 | async (action: ActionEvent) => {
31 | switch (action.key) {
32 | case 'edit': {
33 | setEditing(true);
34 | }
35 | }
36 |
37 | onActionsClick(action, item);
38 | },
39 | [item],
40 | );
41 |
42 | const RenderFunction = renderActions[item?.role] ?? ActionsBar;
43 |
44 | return ;
45 | });
46 |
47 | export default Actions;
48 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Error/ApiError.tsx:
--------------------------------------------------------------------------------
1 | import { ChatMessage } from '@/types/chat';
2 | import { memo } from 'react';
3 | import ErrorJsonViewer from './ErrorJsonViewer';
4 | import OpenAPIKey from './OpenAPIKey';
5 |
6 | interface OpenAIError {
7 | code: 'invalid_api_key' | string;
8 | message: string;
9 | param?: any;
10 | type: string;
11 | }
12 |
13 | interface OpenAIErrorResponse {
14 | error: OpenAIError;
15 | }
16 |
17 | const OpenAiBizError = memo(({ error, id }) => {
18 | const errorBody: OpenAIErrorResponse = (error as any)?.body;
19 |
20 | const errorCode = errorBody.error?.code;
21 |
22 | if (errorCode === 'invalid_api_key') return ;
23 |
24 | return ;
25 | });
26 |
27 | export default OpenAiBizError;
28 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Error/ErrorJsonViewer.tsx:
--------------------------------------------------------------------------------
1 | import { Highlighter } from '@lobehub/ui';
2 | import { memo } from 'react';
3 | import { Flexbox } from 'react-layout-kit';
4 |
5 | import { ChatMessageError } from '@/types/chat';
6 |
7 | interface ErrorJSONViewerProps {
8 | error?: ChatMessageError;
9 | id: string;
10 | }
11 |
12 | const ErrorJsonViewer = memo(({ error, id }) => {
13 | const errorBody = error?.body || error;
14 |
15 | if (!errorBody) return;
16 |
17 | return (
18 |
19 |
20 | {JSON.stringify(errorBody, null, 2)}
21 |
22 |
23 | );
24 | });
25 |
26 | export default ErrorJsonViewer;
27 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Error/OpenAPIKey.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import APIKeyForm from './ApiKeyForm';
3 | import { ErrorActionContainer } from './style';
4 |
5 | interface OpenAPIKeyProps {
6 | id: string;
7 | }
8 |
9 | const OpenAPIKey = memo(({ id }) => (
10 |
11 |
12 |
13 | ));
14 |
15 | export default OpenAPIKey;
16 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Error/index.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorTypeEnum } from '@/types/api';
2 | import { ChatMessage, ChatMessageError } from '@/types/chat';
3 | import type { AlertProps } from '@lobehub/ui';
4 | import { memo } from 'react';
5 | import ApiError from './ApiError';
6 | import ErrorJsonViewer from './ErrorJsonViewer';
7 | import OpenAPIKey from './OpenAPIKey';
8 |
9 | export const getErrorAlertConfig = (errorType?: ErrorTypeEnum): AlertProps | undefined => {
10 | if (typeof errorType === 'string' && (errorType.includes('Biz') || errorType.includes('Invalid')))
11 | return {
12 | extraDefaultExpand: true,
13 | extraIsolate: true,
14 | type: 'warning',
15 | };
16 |
17 | switch (errorType) {
18 | case ErrorTypeEnum.OPENAI_API_ERROR:
19 | case ErrorTypeEnum.API_KEY_MISSING: {
20 | return {
21 | extraDefaultExpand: true,
22 | extraIsolate: true,
23 | type: 'warning',
24 | };
25 | }
26 |
27 | default: {
28 | return undefined;
29 | }
30 | }
31 | };
32 |
33 | const ErrorMessageExtra = memo<{ data: ChatMessage }>(({ data }) => {
34 | const error = data.error as ChatMessageError;
35 | if (!error?.type) return;
36 |
37 | switch (error.type) {
38 | case ErrorTypeEnum.API_KEY_MISSING: {
39 | return ;
40 | }
41 |
42 | case ErrorTypeEnum.OPENAI_API_ERROR: {
43 | return ;
44 | }
45 |
46 | default: {
47 | return ;
48 | }
49 | }
50 | });
51 |
52 | export default ErrorMessageExtra;
53 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Error/style.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar } from '@lobehub/ui';
2 | import { createStyles } from 'antd-style';
3 | import { ReactNode, memo } from 'react';
4 | import { Center, Flexbox } from 'react-layout-kit';
5 |
6 | export const useStyles = createStyles(({ css, token }) => ({
7 | container: css`
8 | color: ${token.colorText};
9 | background: ${token.colorBgContainer};
10 | border: 1px solid ${token.colorSplit};
11 | border-radius: 8px;
12 | `,
13 | desc: css`
14 | color: ${token.colorTextTertiary};
15 | text-align: center;
16 | `,
17 | }));
18 |
19 | export const ErrorActionContainer = memo<{ children: ReactNode }>(({ children }) => {
20 | const { styles } = useStyles();
21 |
22 | return (
23 |
24 | {children}
25 |
26 | );
27 | });
28 |
29 | // eslint-disable-next-line react/display-name
30 | export const FormAction = memo<{
31 | avatar: string;
32 | children: ReactNode;
33 | description: string;
34 | title: string;
35 | }>(({ children, title, description, avatar }) => {
36 | const { styles, theme } = useStyles();
37 |
38 | return (
39 |
40 |
41 | {title}
42 | {description}
43 | {children}
44 |
45 | );
46 | });
47 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Messages/Default.tsx:
--------------------------------------------------------------------------------
1 | import { LOADING_FLAG } from '@/constants/common';
2 | import { ChatMessage } from '@/types/chat';
3 | import { ReactNode, memo } from 'react';
4 | import BubblesLoading from './Loading';
5 |
6 | export const DefaultMessage = memo<
7 | ChatMessage & {
8 | editableContent: ReactNode;
9 | }
10 | >(({ id, editableContent, content }) => {
11 | if (content === LOADING_FLAG) return ;
12 |
13 | return {editableContent}
;
14 | });
15 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Messages/Loading.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from 'antd-style';
2 |
3 | const Svg = () => (
4 |
42 | );
43 |
44 | const BubblesLoading = () => {
45 | const { colorTextTertiary } = useTheme();
46 | return (
47 |
48 |
49 |
50 | );
51 | };
52 |
53 | export default BubblesLoading;
54 |
--------------------------------------------------------------------------------
/src/features/ChatItem/Messages/index.tsx:
--------------------------------------------------------------------------------
1 | import { ChatMessage } from '@/types/chat';
2 | import React from 'react';
3 | import { DefaultMessage } from './Default';
4 |
5 | export type RenderMessage = React.FC;
6 |
7 | export const renderMessages: Record = {
8 | assistant: DefaultMessage,
9 | default: DefaultMessage,
10 | user: DefaultMessage,
11 | };
12 |
--------------------------------------------------------------------------------
/src/features/ChatItem/type.ts:
--------------------------------------------------------------------------------
1 | import { ChatMessage } from '@/types/chat';
2 | import { ActionEvent, ActionIconGroupProps } from '@lobehub/ui';
3 | import { FC, ReactNode } from 'react';
4 |
5 | export type ActionsBarProps = ActionIconGroupProps;
6 |
7 | export type OnActionsClick = (action: ActionEvent, message: ChatMessage) => void;
8 | export type RenderMessage = FC;
9 | export type RenderAction = FC;
10 |
--------------------------------------------------------------------------------
/src/features/ImageViewer/index.tsx:
--------------------------------------------------------------------------------
1 | import HolographicCard from '@/components/HolographicCard';
2 | import { sessionSelectors, useSessionStore } from '@/store/session';
3 | import { useStyles } from './style';
4 |
5 | const Docker = () => {
6 | const { styles } = useStyles();
7 | const currentAgent = useSessionStore((s) => sessionSelectors.currentAgent(s));
8 |
9 | return (
10 |
11 |
12 |
13 | );
14 | };
15 |
16 | export default Docker;
17 |
--------------------------------------------------------------------------------
/src/features/ImageViewer/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | content: css`
5 | display: flex;
6 | align-items: center;
7 | justify-content: center;
8 | height: calc(100vh - 128px);
9 | `,
10 | }));
11 |
--------------------------------------------------------------------------------
/src/features/Timer/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useInterval } from 'ahooks';
4 | import { useState } from 'react';
5 | import { useStyles } from './style';
6 |
7 | const getTime = () => {
8 | const date = new Date();
9 | return date.toLocaleTimeString();
10 | };
11 |
12 | const getDate = () => {
13 | const date = new Date();
14 | return date.toLocaleDateString();
15 | };
16 |
17 | const Timer = () => {
18 | const { styles } = useStyles();
19 |
20 | const [time, setTime] = useState(getTime());
21 | const [date, setDate] = useState(getDate());
22 |
23 | useInterval(() => {
24 | setTime(getTime());
25 | setDate(getDate());
26 | }, 1000);
27 |
28 | return (
29 |
30 |
{time}
31 |
{date}
32 |
33 | );
34 | };
35 |
36 | export default Timer;
37 |
--------------------------------------------------------------------------------
/src/features/Timer/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | date: css`
5 | font-size: 12px;
6 | `,
7 | time: css`
8 | font-size: 12px;
9 | `,
10 | timer: css`
11 | display: flex;
12 | flex-direction: column;
13 | align-items: flex-end;
14 | `,
15 | }));
16 |
--------------------------------------------------------------------------------
/src/features/constants/ttsParam.ts:
--------------------------------------------------------------------------------
1 | import { TTS } from '@/types/tts';
2 |
3 | export const DEFAULT_TTS: TTS = {
4 | engine: 'edge',
5 | locale: 'zh-CN',
6 | message: '正在为你准备我的整个世界',
7 | pitch: 1,
8 | speed: 1,
9 | voice: 'zh-CN-XiaoyiNeural',
10 | };
11 |
--------------------------------------------------------------------------------
/src/features/emoteController/autoBlink.ts:
--------------------------------------------------------------------------------
1 | import { VRMExpressionManager } from "@pixiv/three-vrm";
2 | import { BLINK_CLOSE_MAX, BLINK_OPEN_MAX } from "./emoteConstants";
3 |
4 | /**
5 | * 自動瞬きを制御するクラス
6 | */
7 | export class AutoBlink {
8 | private _expressionManager: VRMExpressionManager;
9 | private _remainingTime: number;
10 | private _isOpen: boolean;
11 | private _isAutoBlink: boolean;
12 |
13 | constructor(expressionManager: VRMExpressionManager) {
14 | this._expressionManager = expressionManager;
15 | this._remainingTime = 0;
16 | this._isAutoBlink = true;
17 | this._isOpen = true;
18 | }
19 |
20 | /**
21 | * 自動瞬きをON/OFFする。
22 | *
23 | * 目を閉じている(blinkが1の)時に感情表現を入れてしまうと不自然になるので、
24 | * 目が開くまでの秒を返し、その時間待ってから感情表現を適用する。
25 | * @param isAuto
26 | * @returns 目が開くまでの秒
27 | */
28 | public setEnable(isAuto: boolean) {
29 | this._isAutoBlink = isAuto;
30 |
31 | // 目が閉じている場合、目が開くまでの時間を返す
32 | if (!this._isOpen) {
33 | return this._remainingTime;
34 | }
35 |
36 | return 0;
37 | }
38 |
39 | public update(delta: number) {
40 | if (this._remainingTime > 0) {
41 | this._remainingTime -= delta;
42 | return;
43 | }
44 |
45 | if (this._isOpen && this._isAutoBlink) {
46 | this.close();
47 | return;
48 | }
49 |
50 | this.open();
51 | }
52 |
53 | private close() {
54 | this._isOpen = false;
55 | this._remainingTime = BLINK_CLOSE_MAX;
56 | this._expressionManager.setValue("blink", 1);
57 | }
58 |
59 | private open() {
60 | this._isOpen = true;
61 | this._remainingTime = BLINK_OPEN_MAX;
62 | this._expressionManager.setValue("blink", 0);
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/features/emoteController/autoLookAt.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from "three";
2 | import { VRM } from "@pixiv/three-vrm";
3 | /**
4 | * 目線を制御するクラス
5 | *
6 | * サッケードはVRMLookAtSmootherの中でやっているので、
7 | * より目線を大きく動かしたい場合はここに実装する。
8 | */
9 | export class AutoLookAt {
10 | private _lookAtTarget: THREE.Object3D;
11 | constructor(vrm: VRM, camera: THREE.Object3D) {
12 | this._lookAtTarget = new THREE.Object3D();
13 | camera.add(this._lookAtTarget);
14 |
15 | if (vrm.lookAt) vrm.lookAt.target = this._lookAtTarget;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/features/emoteController/emoteConstants.ts:
--------------------------------------------------------------------------------
1 | // 瞬きで目を閉じている時間(sec)
2 | export const BLINK_CLOSE_MAX = 0.12;
3 | // 瞬きで目を開いている時間(sec)
4 | export const BLINK_OPEN_MAX = 5;
5 |
--------------------------------------------------------------------------------
/src/features/emoteController/emoteController.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from "three";
2 | import { VRM, VRMExpressionPresetName } from "@pixiv/three-vrm";
3 | import { ExpressionController } from "./expressionController";
4 |
5 | /**
6 | * 感情表現としてExpressionとMotionを操作する為のクラス
7 | * デモにはExpressionのみが含まれています
8 | */
9 | export class EmoteController {
10 | private _expressionController: ExpressionController;
11 |
12 | constructor(vrm: VRM, camera: THREE.Object3D) {
13 | this._expressionController = new ExpressionController(vrm, camera);
14 | }
15 |
16 | public playEmotion(preset: VRMExpressionPresetName) {
17 | this._expressionController.playEmotion(preset);
18 | }
19 |
20 | public lipSync(preset: VRMExpressionPresetName, value: number) {
21 | this._expressionController.lipSync(preset, value);
22 | }
23 |
24 | public update(delta: number) {
25 | this._expressionController.update(delta);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/features/emoteController/expressionController.ts:
--------------------------------------------------------------------------------
1 | import { VRM, VRMExpressionManager, VRMExpressionPresetName } from '@pixiv/three-vrm';
2 | import * as THREE from 'three';
3 | import { AutoBlink } from './autoBlink';
4 | import { AutoLookAt } from './autoLookAt';
5 |
6 | /**
7 | * Expressionを管理するクラス
8 | *
9 | * 主に前の表情を保持しておいて次の表情を適用する際に0に戻す作業や、
10 | * 前の表情が終わるまで待ってから表情適用する役割を持っている。
11 | */
12 | export class ExpressionController {
13 | private _autoLookAt: AutoLookAt;
14 | private _autoBlink?: AutoBlink;
15 | private _expressionManager?: VRMExpressionManager;
16 | private _currentEmotion: VRMExpressionPresetName;
17 | private _currentLipSync: {
18 | preset: VRMExpressionPresetName;
19 | value: number;
20 | } | null;
21 | constructor(vrm: VRM, camera: THREE.Object3D) {
22 | this._autoLookAt = new AutoLookAt(vrm, camera);
23 | this._currentEmotion = 'neutral';
24 | this._currentLipSync = null;
25 | if (vrm.expressionManager) {
26 | this._expressionManager = vrm.expressionManager;
27 | this._autoBlink = new AutoBlink(vrm.expressionManager);
28 | }
29 | }
30 |
31 | public playEmotion(preset: VRMExpressionPresetName) {
32 | if (this._currentEmotion !== 'neutral') {
33 | this._expressionManager?.setValue(this._currentEmotion, 0);
34 | }
35 |
36 | if (preset === 'neutral') {
37 | this._autoBlink?.setEnable(true);
38 | this._currentEmotion = preset;
39 | return;
40 | }
41 |
42 | const t = this._autoBlink?.setEnable(false) || 0;
43 | this._currentEmotion = preset;
44 | setTimeout(() => {
45 | this._expressionManager?.setValue(preset, 1);
46 | }, t * 1000);
47 | }
48 |
49 | public lipSync(preset: VRMExpressionPresetName, value: number) {
50 | if (this._currentLipSync) {
51 | this._expressionManager?.setValue(this._currentLipSync.preset, 0);
52 | }
53 | this._currentLipSync = {
54 | preset,
55 | value,
56 | };
57 | }
58 |
59 | public update(delta: number) {
60 | if (this._autoBlink) {
61 | this._autoBlink.update(delta);
62 | }
63 |
64 | if (this._currentLipSync) {
65 | const weight =
66 | this._currentEmotion === 'neutral'
67 | ? this._currentLipSync.value * 0.5
68 | : this._currentLipSync.value * 0.25;
69 | this._expressionManager?.setValue(this._currentLipSync.preset, weight);
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/features/lipSync/lipSync.ts:
--------------------------------------------------------------------------------
1 | import { LipSyncAnalyzeResult } from './lipSyncAnalyzeResult';
2 |
3 | const TIME_DOMAIN_DATA_LENGTH = 2048;
4 |
5 | export class LipSync {
6 | public readonly audio: AudioContext;
7 | public readonly analyser: AnalyserNode;
8 | public readonly timeDomainData: Float32Array;
9 | public bufferSource: AudioBufferSourceNode | undefined;
10 |
11 | public constructor(audio: AudioContext) {
12 | this.audio = audio;
13 | this.bufferSource = undefined;
14 |
15 | this.analyser = audio.createAnalyser();
16 | this.timeDomainData = new Float32Array(TIME_DOMAIN_DATA_LENGTH);
17 | }
18 |
19 | public update(): LipSyncAnalyzeResult {
20 | this.analyser.getFloatTimeDomainData(this.timeDomainData);
21 |
22 | let volume = 0;
23 | for (let i = 0; i < TIME_DOMAIN_DATA_LENGTH; i++) {
24 | volume = Math.max(volume, Math.abs(this.timeDomainData[i]));
25 | }
26 |
27 | // cook
28 | volume = 1 / (1 + Math.exp(-45 * volume + 5));
29 | if (volume < 0.1) volume = 0;
30 |
31 | return {
32 | volume,
33 | };
34 | }
35 |
36 | public async playFromArrayBuffer(buffer: ArrayBuffer, onEnded?: () => void) {
37 | const audioBuffer = await this.audio.decodeAudioData(buffer);
38 |
39 | this.bufferSource = this.audio.createBufferSource();
40 | this.bufferSource.buffer = audioBuffer;
41 |
42 | this.bufferSource.connect(this.audio.destination);
43 | this.bufferSource.connect(this.analyser);
44 | this.bufferSource.start();
45 | if (onEnded) {
46 | this.bufferSource.addEventListener('ended', onEnded);
47 | }
48 | }
49 |
50 | public stopPlay() {
51 | if (this.bufferSource) this.bufferSource.stop();
52 | }
53 |
54 | public async playFromURL(url: string, onEnded?: () => void) {
55 | const res = await fetch(url);
56 | const buffer = await res.arrayBuffer();
57 | this.playFromArrayBuffer(buffer, onEnded);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/features/lipSync/lipSyncAnalyzeResult.ts:
--------------------------------------------------------------------------------
1 | export interface LipSyncAnalyzeResult {
2 | volume: number;
3 | }
4 |
--------------------------------------------------------------------------------
/src/features/messages/speakCharacter.ts:
--------------------------------------------------------------------------------
1 | import { speechApi } from '@/services/tts';
2 | import { Screenplay } from '@/types/touch';
3 | import { wait } from '@/utils/wait';
4 | import { Viewer } from '../vrmViewer/viewer';
5 |
6 | const createSpeakCharacter = () => {
7 | let lastTime = 0;
8 | let prevFetchPromise: Promise = Promise.resolve();
9 | let prevSpeakPromise: Promise = Promise.resolve();
10 |
11 | return (
12 | screenplay: Screenplay,
13 | viewer: Viewer,
14 | onStart?: () => void,
15 | onComplete?: () => void,
16 | ) => {
17 | const fetchPromise = prevFetchPromise.then(async () => {
18 | const now = Date.now();
19 | if (now - lastTime < 1000) {
20 | await wait(1000 - (now - lastTime));
21 | }
22 |
23 | const buffer = await speechApi(screenplay.tts).catch(() => null);
24 | lastTime = Date.now();
25 | return buffer;
26 | });
27 |
28 | prevFetchPromise = fetchPromise;
29 | prevSpeakPromise = Promise.all([fetchPromise, prevSpeakPromise]).then(([audioBuffer]) => {
30 | onStart?.();
31 | if (!audioBuffer) {
32 | return;
33 | }
34 | return viewer.model?.speak(audioBuffer, screenplay);
35 | });
36 | prevSpeakPromise.then(() => {
37 | onComplete?.();
38 | });
39 | };
40 | };
41 |
42 | export const speakCharacter = createSpeakCharacter();
43 |
--------------------------------------------------------------------------------
/src/hooks/useCalculateToken.ts:
--------------------------------------------------------------------------------
1 | import { sessionSelectors, useSessionStore } from '@/store/session';
2 | import { getEncoding } from 'js-tiktoken';
3 | import { useMemo } from 'react';
4 |
5 | const enc = getEncoding('cl100k_base');
6 |
7 | export const useCalculateToken = () => {
8 | const currentChatsString = useSessionStore((s) => sessionSelectors.currentChatsString(s));
9 | const currentSystemRole = useSessionStore((s) => sessionSelectors.currentSystemRole(s));
10 | const messageInput = useSessionStore((s) => s.messageInput);
11 |
12 | const chatLength = useMemo(() => enc.encode(currentChatsString).length, [currentChatsString]);
13 | const systemRoleLength = useMemo(() => enc.encode(currentSystemRole).length, [currentSystemRole]);
14 | const messageInputLength = useMemo(() => enc.encode(messageInput).length, [messageInput]);
15 |
16 | return chatLength + systemRoleLength + messageInputLength;
17 | };
18 |
--------------------------------------------------------------------------------
/src/hooks/useChatListActionsBar.tsx:
--------------------------------------------------------------------------------
1 | import { ActionIconGroupItems } from '@lobehub/ui/es/ActionIconGroup';
2 | import { Copy, Edit, ListRestart, RotateCcw, Trash } from 'lucide-react';
3 | import { useMemo } from 'react';
4 |
5 | interface ChatListActionsBar {
6 | copy: ActionIconGroupItems;
7 | del: ActionIconGroupItems;
8 | delAndRegenerate: ActionIconGroupItems;
9 | divider: { type: 'divider' };
10 | edit: ActionIconGroupItems;
11 | regenerate: ActionIconGroupItems;
12 | }
13 |
14 | export const useChatListActionsBar = (): ChatListActionsBar => {
15 | return useMemo(
16 | () => ({
17 | copy: {
18 | icon: Copy,
19 | key: 'copy',
20 | label: '复制',
21 | },
22 | del: {
23 | danger: true,
24 | icon: Trash,
25 | key: 'del',
26 | label: '删除',
27 | },
28 | delAndRegenerate: {
29 | icon: ListRestart,
30 | key: 'delAndRegenerate',
31 | label: '删除并重新生成',
32 | },
33 | divider: {
34 | type: 'divider',
35 | },
36 | edit: {
37 | icon: Edit,
38 | key: 'edit',
39 | label: '编辑',
40 | },
41 | regenerate: {
42 | icon: RotateCcw,
43 | key: 'regenerate',
44 | label: '重新生成',
45 | },
46 | }),
47 | [],
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/src/hooks/useSendMessage.ts:
--------------------------------------------------------------------------------
1 | import { useSessionStore } from '@/store/session';
2 | import { useCallback } from 'react';
3 |
4 | const useSendMessage = () => {
5 | const [sendMessage, setMessageInput] = useSessionStore((s) => [s.sendMessage, s.setMessageInput]);
6 | return useCallback(() => {
7 | const store = useSessionStore.getState();
8 | sendMessage(store.messageInput);
9 | setMessageInput('');
10 | }, []);
11 | };
12 |
13 | export default useSendMessage;
14 |
--------------------------------------------------------------------------------
/src/hooks/useSpeechRecognition.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react';
2 |
3 | interface useSpeechRecognitionProps {
4 | onMessage?: (message: string, isFinal: boolean) => void;
5 | }
6 |
7 | export const useSpeechRecognition = (props: useSpeechRecognitionProps) => {
8 | const { onMessage } = props;
9 | const [isRecording, setIsRecording] = useState(false);
10 | // eslint-disable-next-line no-undef
11 | const [speechRecognition, setSpeechRecognition] = useState();
12 |
13 | const handleRecognitionEnd = useCallback(() => {
14 | setIsRecording(false);
15 | }, []);
16 |
17 | const toggleRecord = () => {
18 | if (isRecording) {
19 | speechRecognition?.abort();
20 | setIsRecording(false);
21 |
22 | return;
23 | }
24 |
25 | speechRecognition?.start();
26 | setIsRecording(true);
27 | };
28 |
29 | const handleRecognitionResult = useCallback(
30 | // eslint-disable-next-line no-undef
31 | (event: SpeechRecognitionEvent) => {
32 | const text = event.results[0][0].transcript;
33 | if (onMessage) onMessage(text, event.results[0].isFinal);
34 | },
35 | [onMessage],
36 | );
37 |
38 | useEffect(() => {
39 | const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
40 | if (!SpeechRecognition) return;
41 |
42 | const recognition = new SpeechRecognition();
43 | recognition.lang = 'zh-CN';
44 | recognition.interimResults = true;
45 | recognition.continuous = false;
46 |
47 | recognition.addEventListener('result', handleRecognitionResult);
48 | recognition.addEventListener('end', handleRecognitionEnd);
49 |
50 | setSpeechRecognition(recognition);
51 | }, [handleRecognitionResult, handleRecognitionEnd]);
52 |
53 | return { isRecording, speechRecognition, toggleRecord };
54 | };
55 |
--------------------------------------------------------------------------------
/src/layout/StoreHydration.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/navigation';
2 | import { memo, useEffect } from 'react';
3 |
4 | import { useConfigStore } from '@/store/config';
5 | import { useSessionStore } from '@/store/session';
6 |
7 | const StoreHydration = () => {
8 | const router = useRouter();
9 |
10 | useEffect(() => {
11 | // refs: https://github.com/pmndrs/zustand/blob/main/docs/integrations/persisting-store-data.md#hashydrated
12 | useSessionStore.persist.rehydrate();
13 | useConfigStore.persist.rehydrate();
14 | }, []);
15 |
16 | useEffect(() => {
17 | router.prefetch('/home');
18 | }, [router]);
19 | return null;
20 | };
21 |
22 | export default memo(StoreHydration);
23 |
--------------------------------------------------------------------------------
/src/layout/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { VIDOL_THEME_APPEARANCE } from '@/constants/common';
4 | import { useConfigStore } from '@/store/config';
5 | import { useThemeStore } from '@/store/theme';
6 | import { GlobalStyle } from '@/styles';
7 | import { setCookie } from '@/utils/cookie';
8 | import { ThemeProvider } from '@lobehub/ui';
9 | import { ThemeAppearance, createStyles } from 'antd-style';
10 | import { ReactNode } from 'react';
11 | import StoreHydration from './StoreHydration';
12 |
13 | const useStyles = createStyles(({ css }) => ({
14 | bg: css`
15 | overflow-y: hidden;
16 | display: flex;
17 | flex-direction: column;
18 | align-items: center;
19 |
20 | height: 100%;
21 | `,
22 | }));
23 |
24 | export interface LayoutProps {
25 | children?: ReactNode;
26 | defaultAppearance?: ThemeAppearance;
27 | }
28 |
29 | const Layout = (props: LayoutProps) => {
30 | const { children, defaultAppearance } = props;
31 | const themeMode = useThemeStore((s) => s.themeMode);
32 | const [primaryColor] = useConfigStore((s) => [s.config.primaryColor]);
33 | const { styles } = useStyles();
34 |
35 | return (
36 | {
42 | setCookie(VIDOL_THEME_APPEARANCE, appearance);
43 | }}
44 | themeMode={themeMode}
45 | >
46 |
47 |
48 | {children}
49 |
50 | );
51 | };
52 |
53 | export default Layout;
54 |
--------------------------------------------------------------------------------
/src/lib/VMDAnimation/loadVMDAnimation.ts:
--------------------------------------------------------------------------------
1 | // export async function loadVMDAnimation(url: string): Promise {
2 | // const gltf = await loader.loadAsync(url);
3 |
4 | // const vrmAnimations: VRMAnimation[] = gltf.userData.vrmAnimations;
5 | // const vrmAnimation: VRMAnimation | undefined = vrmAnimations[0];
6 |
7 | // return vrmAnimation ?? null;
8 | // }
9 |
--------------------------------------------------------------------------------
/src/lib/VRMAnimation/VRMAnimationLoaderPluginOptions.ts:
--------------------------------------------------------------------------------
1 | export interface VRMAnimationLoaderPluginOptions {
2 | }
3 |
--------------------------------------------------------------------------------
/src/lib/VRMAnimation/VRMCVRMAnimation.ts:
--------------------------------------------------------------------------------
1 | import { VRMExpressionPresetName, VRMHumanBoneName } from '@pixiv/three-vrm';
2 |
3 | export interface VRMCVRMAnimation {
4 | specVersion: string;
5 | humanoid: {
6 | humanBones: {
7 | [name in VRMHumanBoneName]?: {
8 | node: number;
9 | };
10 | };
11 | };
12 | expressions?: {
13 | preset?: {
14 | [name in VRMExpressionPresetName]?: {
15 | node: number;
16 | };
17 | };
18 | custom?: {
19 | [name: string]: {
20 | node: number;
21 | };
22 | };
23 | };
24 | lookAt?: {
25 | node: number;
26 | };
27 | }
28 |
--------------------------------------------------------------------------------
/src/lib/VRMAnimation/loadVRMAnimation.ts:
--------------------------------------------------------------------------------
1 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
2 | import { VRMAnimation } from './VRMAnimation';
3 | import { VRMAnimationLoaderPlugin } from './VRMAnimationLoaderPlugin';
4 |
5 | const loader = new GLTFLoader();
6 | loader.register((parser) => new VRMAnimationLoaderPlugin(parser));
7 |
8 | export async function loadVRMAnimation(url: string): Promise {
9 | const gltf = await loader.loadAsync(url);
10 |
11 | const vrmAnimations: VRMAnimation[] = gltf.userData.vrmAnimations;
12 | const vrmAnimation: VRMAnimation | undefined = vrmAnimations[0];
13 |
14 | return vrmAnimation ?? null;
15 | }
16 |
--------------------------------------------------------------------------------
/src/lib/VRMAnimation/utils/arrayChunk.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * ```js
3 | * arrayChunk( [ 1, 2, 3, 4, 5, 6 ], 2 )
4 | * // will be
5 | * [ [ 1, 2 ], [ 3, 4 ], [ 5, 6 ] ]
6 | * ```
7 | */
8 | export function arrayChunk(array: ArrayLike, every: number): T[][] {
9 | const N = array.length;
10 |
11 | const ret: T[][] = [];
12 |
13 | let current: T[] = [];
14 | let remaining = 0;
15 |
16 | for (let i = 0; i < N; i ++) {
17 | const el = array[i];
18 |
19 | if (remaining <= 0) {
20 | remaining = every;
21 | current = [];
22 | ret.push(current);
23 | }
24 |
25 | current.push(el);
26 | remaining--;
27 | }
28 |
29 | return ret;
30 | }
31 |
--------------------------------------------------------------------------------
/src/lib/VRMAnimation/utils/linearstep.ts:
--------------------------------------------------------------------------------
1 | import { saturate } from './saturate';
2 |
3 | export const linearstep = (a: number, b: number, t: number) => (
4 | saturate((t - a) / (b - a))
5 | );
6 |
--------------------------------------------------------------------------------
/src/lib/VRMAnimation/utils/saturate.ts:
--------------------------------------------------------------------------------
1 | export const saturate = (x: number) => Math.min(Math.max(x, 0.0), 1.0);
2 |
--------------------------------------------------------------------------------
/src/lib/VRMLookAtSmootherLoaderPlugin/VRMLookAtSmootherLoaderPlugin.ts:
--------------------------------------------------------------------------------
1 | import {
2 | VRMHumanoid,
3 | VRMLookAt,
4 | VRMLookAtLoaderPlugin,
5 | } from "@pixiv/three-vrm";
6 | import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
7 | import { VRMLookAtSmoother } from "./VRMLookAtSmoother";
8 |
9 | export class VRMLookAtSmootherLoaderPlugin extends VRMLookAtLoaderPlugin {
10 | public get name(): string {
11 | return "VRMLookAtSmootherLoaderPlugin";
12 | }
13 |
14 | public async afterRoot(gltf: GLTF): Promise {
15 | await super.afterRoot(gltf);
16 |
17 | const humanoid = gltf.userData.vrmHumanoid as VRMHumanoid | null;
18 | const lookAt = gltf.userData.vrmLookAt as VRMLookAt | null;
19 |
20 | if (humanoid != null && lookAt != null) {
21 | const lookAtSmoother = new VRMLookAtSmoother(humanoid, lookAt.applier);
22 | lookAtSmoother.copy(lookAt);
23 | gltf.userData.vrmLookAt = lookAtSmoother;
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/panels/AgentPanel/Agent/TopBanner/index.tsx:
--------------------------------------------------------------------------------
1 | import { GridBackground } from '@lobehub/ui';
2 | import { createStyles } from 'antd-style';
3 | import { Center } from 'react-layout-kit';
4 |
5 | const useStyles = createStyles(({ css }) => ({
6 | background: css`
7 | width: 90%;
8 | margin: -24px 0 -12px;
9 | `,
10 | title: css`
11 | z-index: 2;
12 | margin-top: 24px;
13 | font-size: 36px;
14 | font-weight: 800;
15 | `,
16 | }));
17 |
18 | const TopBanner = () => {
19 | const { theme, styles } = useStyles();
20 |
21 | return (
22 |
23 | Hello, Let's Chat!
24 |
25 |
26 | );
27 | };
28 |
29 | export default TopBanner;
30 |
--------------------------------------------------------------------------------
/src/panels/AgentPanel/Agent/index.tsx:
--------------------------------------------------------------------------------
1 | import { useAgentStore } from '@/store/agent';
2 | import { createStyles } from 'antd-style';
3 | import classNames from 'classnames';
4 | import dynamic from 'next/dynamic';
5 | import React, { memo } from 'react';
6 | import AgentCard from './AgentCard';
7 | import AgentList from './AgentList';
8 |
9 | const TopBanner = dynamic(() => import('./TopBanner'), { ssr: false });
10 |
11 | const useStyles = createStyles(({ css }) => ({
12 | container: css`
13 | position: relative;
14 |
15 | display: flex;
16 |
17 | width: 100%;
18 | height: 100%;
19 | min-height: 500px;
20 | `,
21 | content: css`
22 | overflow-y: auto;
23 | flex-grow: 1;
24 | padding-right: 24px;
25 | padding-left: 24px;
26 | `,
27 | }));
28 |
29 | interface AgentProps {
30 | className?: string;
31 | style?: React.CSSProperties;
32 | }
33 |
34 | const Agent = (props: AgentProps) => {
35 | const { styles } = useStyles();
36 | const { style, className } = props;
37 | const [subscribedList] = useAgentStore((s) => [s.subscribedList]);
38 |
39 | return (
40 |
47 | );
48 | };
49 |
50 | export default memo(Agent);
51 |
--------------------------------------------------------------------------------
/src/panels/AgentPanel/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import PanelContainer from '@/panels/PanelContainer';
4 | import React from 'react';
5 | import Agent from './Agent';
6 | import { useStyles } from './style';
7 |
8 | interface ControlPanelProps {
9 | className?: string;
10 | style?: React.CSSProperties;
11 | }
12 |
13 | const ControlPanel = (props: ControlPanelProps) => {
14 | const { style, className } = props;
15 | const { styles } = useStyles();
16 |
17 | return (
18 |
19 |
22 |
23 | );
24 | };
25 |
26 | export default ControlPanel;
27 |
--------------------------------------------------------------------------------
/src/panels/AgentPanel/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | content: css`
5 | display: flex;
6 | flex-direction: row;
7 | flex-grow: 1;
8 |
9 | width: 100%;
10 | height: 100%;
11 | `,
12 | }));
13 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/ChatBot/ChatHeader.tsx:
--------------------------------------------------------------------------------
1 | import AgentMeta from '@/components/AgentMeta';
2 | import { sessionSelectors, useSessionStore } from '@/store/session';
3 | import Voice from '../../../features/ChatInput/Actions/Voice';
4 | import { useStyles } from './style';
5 |
6 | const Header = () => {
7 | const { styles } = useStyles();
8 | const [currentAgent] = useSessionStore((s) => [sessionSelectors.currentAgent(s)]);
9 |
10 | return (
11 |
15 | );
16 | };
17 |
18 | export default Header;
19 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/ChatBot/ChatList/AutoScroll.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useEffect } from 'react';
2 |
3 | import { sessionSelectors, useSessionStore } from '@/store/session';
4 |
5 | import BackBottom from './BackBottom';
6 |
7 | interface AutoScrollProps {
8 | atBottom: boolean;
9 | onScrollToBottom: (type: 'auto' | 'click') => void;
10 | }
11 | const AutoScroll = memo(({ atBottom, onScrollToBottom }) => {
12 | const trackVisibility = useSessionStore((s) => !!s.chatLoadingId);
13 | const str = useSessionStore(sessionSelectors.currentChatsString);
14 |
15 | useEffect(() => {
16 | if (atBottom && trackVisibility) {
17 | onScrollToBottom?.('auto');
18 | }
19 | }, [atBottom, trackVisibility, str]);
20 |
21 | return onScrollToBottom('click')} visible={!atBottom} />;
22 | });
23 |
24 | export default AutoScroll;
25 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/ChatBot/ChatList/BackBottom/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@lobehub/ui';
2 | import { Button } from 'antd';
3 | import { ListEnd } from 'lucide-react';
4 | import { memo } from 'react';
5 |
6 | import { useStyles } from './style';
7 |
8 | export interface BackBottomProps {
9 | onScrollToBottom: () => void;
10 | visible: boolean;
11 | }
12 |
13 | const BackBottom = memo(({ visible, onScrollToBottom }) => {
14 | const { styles, cx } = useStyles();
15 |
16 | return (
17 | }
20 | onClick={onScrollToBottom}
21 | size={'small'}
22 | >
23 | 返回底部
24 |
25 | );
26 | });
27 |
28 | export default BackBottom;
29 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/ChatBot/ChatList/BackBottom/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 | import { rgba } from 'polished';
3 |
4 | export const useStyles = createStyles(({ token, css, cx }) => ({
5 | container: cx(css`
6 | pointer-events: none;
7 |
8 | position: absolute;
9 | z-index: 1000;
10 | right: 16px;
11 | bottom: 16px;
12 | transform: translateY(16px);
13 |
14 | padding-inline: 12px !important;
15 |
16 | opacity: 0;
17 | background: ${rgba(token.colorBgContainer, 0.5)};
18 | border-color: ${token.colorFillTertiary} !important;
19 | border-radius: 16px !important;
20 | `),
21 | visible: css`
22 | pointer-events: all;
23 | transform: translateY(0);
24 | opacity: 1;
25 | `,
26 | }));
27 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/ChatBot/index.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React, { memo } from 'react';
3 | import ChatInput from '../../../features/ChatInput';
4 | import ChatHeader from './ChatHeader';
5 | import ChatList from './ChatList';
6 | import { useStyles } from './style';
7 |
8 | interface ChatBotProps {
9 | className?: string;
10 | style?: React.CSSProperties;
11 | }
12 |
13 | const ChatBot = (props: ChatBotProps) => {
14 | const { style, className } = props;
15 | const { styles } = useStyles();
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default memo(ChatBot);
29 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/ChatBot/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | const useStyles = createStyles(({ token, css }) => ({
4 | chatbot: css`
5 | position: relative;
6 | display: flex;
7 | flex-direction: column;
8 | flex-grow: 1;
9 | `,
10 | header: css`
11 | display: flex;
12 | align-items: center;
13 | justify-content: space-between;
14 |
15 | width: 100%;
16 | padding: 16px 8px;
17 |
18 | border-bottom: 1px solid ${token.colorBorderSecondary};
19 | `,
20 | voice: css`
21 | cursor: pointer;
22 | transition: color 0.3s;
23 | `,
24 | voiceOn: css`
25 | color: ${token.colorLinkActive};
26 | `,
27 | }));
28 |
29 | export { useStyles };
30 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/ChatBot/type.ts:
--------------------------------------------------------------------------------
1 | import { ChatMessage } from '@/types/chat';
2 | import { ActionEvent, ActionIconGroupProps } from '@lobehub/ui';
3 | import { FC, ReactNode } from 'react';
4 |
5 | export type ActionsBarProps = ActionIconGroupProps;
6 |
7 | export type OnActionsClick = (action: ActionEvent, message: ChatMessage) => void;
8 | export type RenderMessage = FC;
9 | export type RenderAction = FC;
10 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/SideBar/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useConfigStore } from '@/store/config';
2 | import { ActionIcon, SearchBar } from '@lobehub/ui';
3 | import { Plus } from 'lucide-react';
4 | import { memo } from 'react';
5 | import { Flexbox } from 'react-layout-kit';
6 | import { useStyles } from './style';
7 |
8 | interface HeaderProps {
9 | onChange?: (value: string) => void;
10 | value?: string;
11 | }
12 |
13 | // eslint-disable-next-line react/display-name
14 | const Header = memo((props: HeaderProps) => {
15 | const { value, onChange } = props;
16 | const { styles } = useStyles();
17 | const openPanel = useConfigStore((s) => s.openPanel);
18 |
19 | return (
20 |
21 |
22 | {
25 | if (onChange) onChange(e.target.value);
26 | }}
27 | placeholder="搜索"
28 | shortKey="f"
29 | value={value}
30 | />
31 |
32 |
openPanel('agent')} title={'找人聊天'} />
33 |
34 | );
35 | });
36 |
37 | export default Header;
38 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/SideBar/SessionList/List.tsx:
--------------------------------------------------------------------------------
1 | import { useSessionStore } from '@/store/session';
2 | import { sessionSelectors } from '@/store/session/selectors';
3 | import { createStyles } from 'antd-style';
4 | import { memo } from 'react';
5 | import LazyLoad from 'react-lazy-load';
6 | import SessionItem from './SessionItem';
7 |
8 | const useStyles = createStyles(
9 | ({ css }) => css`
10 | min-height: 70px;
11 | `,
12 | );
13 |
14 | interface SessionListProps {
15 | filter?: string;
16 | }
17 |
18 | const SessionList = memo(({ filter }) => {
19 | const [sessionListIds, getAgentById] = useSessionStore((s) => [
20 | sessionSelectors.sessionListIds(s),
21 | sessionSelectors.getAgentById(s),
22 | ]);
23 | const [switchSession] = useSessionStore((s) => [s.switchSession]);
24 | const { styles } = useStyles();
25 |
26 | const dataSource = sessionListIds.filter((agentId) => {
27 | const agent = getAgentById(agentId);
28 | const { name, description } = agent?.meta || {};
29 | return !filter || name?.includes(filter) || description?.includes(filter);
30 | });
31 |
32 | return dataSource.map((id) => (
33 |
34 | {
37 | switchSession(id);
38 | }}
39 | />
40 |
41 | ));
42 | });
43 |
44 | export default SessionList;
45 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/SideBar/SessionList/SessionItem/Actions.tsx:
--------------------------------------------------------------------------------
1 | import { useSessionStore } from '@/store/session';
2 | import { ActionIcon } from '@lobehub/ui';
3 | import { App, Dropdown, MenuProps } from 'antd';
4 | import { MoreVertical, Trash2 } from 'lucide-react';
5 |
6 | interface ActionsProps {
7 | id: string;
8 | setOpen: (open: boolean) => void;
9 | }
10 |
11 | export default (props: ActionsProps) => {
12 | const { id, setOpen } = props;
13 | const { modal } = App.useApp();
14 | const [removeSession] = useSessionStore((s) => [s.removeSession]);
15 |
16 | const items: MenuProps['items'] = [
17 | {
18 | danger: true,
19 | icon: ,
20 | key: 'delete',
21 | label: '删除对话',
22 | onClick: ({ domEvent }) => {
23 | domEvent.stopPropagation();
24 | modal.confirm({
25 | centered: true,
26 | okButtonProps: { danger: true },
27 | onOk: () => {
28 | removeSession(id);
29 | },
30 | title: '确认删除对话吗?删除后无法恢复, 请谨慎操作!',
31 | });
32 | },
33 | },
34 | ];
35 | return (
36 | {
40 | domEvent.stopPropagation();
41 | },
42 | }}
43 | onOpenChange={(open) => setOpen(open)}
44 | trigger={['click']}
45 | >
46 | {
49 | e.stopPropagation();
50 | }}
51 | />
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/SideBar/SessionList/SessionItem/ListItem.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, List, ListItemProps } from '@lobehub/ui';
2 | import { useHover } from 'ahooks';
3 | import { createStyles } from 'antd-style';
4 | import { memo, useMemo, useRef } from 'react';
5 |
6 | const { Item } = List;
7 |
8 | const useStyles = createStyles(({ css, token }) => {
9 | return {
10 | container: css`
11 | position: relative;
12 |
13 | margin-block: 2px;
14 | padding-right: 16px;
15 | padding-left: 8px;
16 |
17 | border-radius: ${token.borderRadius}px;
18 | `,
19 | };
20 | });
21 |
22 | const ListItem = memo(
23 | ({ avatar, active, showAction, actions, ...props }) => {
24 | const ref = useRef(null);
25 | const isHovering = useHover(ref);
26 | const { styles } = useStyles();
27 |
28 | const avatarRender = useMemo(
29 | () => ,
30 | [isHovering, avatar],
31 | );
32 |
33 | return (
34 |
43 | );
44 | },
45 | );
46 |
47 | export default ListItem;
48 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/SideBar/SessionList/SessionItem/index.tsx:
--------------------------------------------------------------------------------
1 | import { sessionSelectors, useSessionStore } from '@/store/session';
2 | import { memo, useMemo, useState } from 'react';
3 | import { shallow } from 'zustand/shallow';
4 |
5 | import Actions from './Actions';
6 | import ListItem from './ListItem';
7 |
8 | interface SessionItemProps {
9 | id: string;
10 | onClick: () => void;
11 | }
12 |
13 | const SessionItem = memo(({ id, onClick }) => {
14 | const [open, setOpen] = useState(false);
15 | const [active] = useSessionStore((s) => [s.activeId === id]);
16 | const [getAgentById, isDefaultAgent] = useSessionStore((s) => [
17 | sessionSelectors.getAgentById(s),
18 | sessionSelectors.isDefaultAgent(s),
19 | ]);
20 |
21 | const isDefault = isDefaultAgent(id);
22 | const agent = getAgentById(id);
23 | const { name, description, avatar } = agent?.meta || {};
24 |
25 | const actions = useMemo(() => , [id]);
26 |
27 | return (
28 |
37 | );
38 | }, shallow);
39 |
40 | export default SessionItem;
41 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/SideBar/SessionList/SkeletonList.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from 'antd';
2 | import { createStyles } from 'antd-style';
3 | import { Flexbox } from 'react-layout-kit';
4 |
5 | const useStyles = createStyles(({ css }) => ({
6 | avatar: css``,
7 | paragraph: css`
8 | height: 12px !important;
9 | margin-top: 12px !important;
10 |
11 | > li {
12 | height: 12px !important;
13 | }
14 | `,
15 | title: css`
16 | height: 14px !important;
17 | margin-top: 4px !important;
18 | margin-bottom: 12px !important;
19 |
20 | > li {
21 | height: 14px !important;
22 | }
23 | `,
24 | }));
25 |
26 | const SkeletonList = () => {
27 | const { styles } = useStyles();
28 |
29 | const list = Array.from({ length: 4 }).fill('');
30 | return (
31 |
32 | {list.map((_, index) => (
33 |
40 | ))}
41 |
42 | );
43 | };
44 | export default SkeletonList;
45 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/SideBar/index.tsx:
--------------------------------------------------------------------------------
1 | import { DraggablePanel } from '@lobehub/ui';
2 | import { createStyles } from 'antd-style';
3 | import { memo, useState } from 'react';
4 | import Header from './Header';
5 | import SessionList from './SessionList/List';
6 |
7 | const useStyles = createStyles(({ css, token }) => ({
8 | content: css`
9 | display: flex;
10 | flex-direction: column;
11 | `,
12 | header: css`
13 | border-bottom: 1px solid ${token.colorBorder};
14 | `,
15 | list: css`
16 | padding: 8px;
17 | `,
18 | }));
19 |
20 | const SideBar = () => {
21 | const { styles } = useStyles();
22 | const [searchName, setSearchName] = useState();
23 |
24 | return (
25 |
33 | {
35 | setSearchName(value);
36 | }}
37 | value={searchName}
38 | />
39 |
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default memo(SideBar);
47 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/SideBar/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, token }) => ({
4 | date: css`
5 | font-size: 12px;
6 | color: ${token.colorTextDescription};
7 | `,
8 | desc: css`
9 | color: ${token.colorTextDescription};
10 | text-align: center;
11 | `,
12 | header: css`
13 | display: flex;
14 | justify-content: space-between;
15 |
16 | width: 100%;
17 | padding: 16px 8px;
18 |
19 | border-bottom: 1px solid ${token.colorBorderSecondary};
20 | `,
21 |
22 | title: css`
23 | font-size: 20px;
24 | font-weight: 600;
25 | text-align: center;
26 | `,
27 | }));
28 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import PanelContainer from '@/panels/PanelContainer';
4 | import classNames from 'classnames';
5 | import React from 'react';
6 | import ChatBot from './ChatBot';
7 | import SideBar from './SideBar';
8 | import { useStyles } from './style';
9 |
10 | interface ChatPanelProps {
11 | className?: string;
12 | style?: React.CSSProperties;
13 | }
14 |
15 | const ChatPanel = (props: ChatPanelProps) => {
16 | const { style, className } = props;
17 | const { styles } = useStyles();
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | export default ChatPanel;
30 |
--------------------------------------------------------------------------------
/src/panels/ChatPanel/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | content: css`
5 | display: flex;
6 | flex-direction: row;
7 | flex-grow: 1;
8 |
9 | width: 100%;
10 | height: 100%;
11 | `,
12 | }));
13 |
--------------------------------------------------------------------------------
/src/panels/ConfigPanel/Config/index.tsx:
--------------------------------------------------------------------------------
1 | import { TabsNav } from '@lobehub/ui';
2 | import classNames from 'classnames';
3 | import React, { memo, useState } from 'react';
4 | import CommonConfig from './common';
5 | import OpenAIConfig from './model/openai';
6 | import { useStyles } from './style';
7 |
8 | interface ConfigProps {
9 | className?: string;
10 | style?: React.CSSProperties;
11 | }
12 |
13 | const Config = (props: ConfigProps) => {
14 | const { style, className } = props;
15 | const { styles } = useStyles();
16 | const [tab, setTab] = useState('common');
17 |
18 | return (
19 |
20 |
21 | {
34 | setTab(key);
35 | }}
36 | />
37 |
38 |
39 | {tab === 'languageModel' ? : null}
40 | {tab === 'common' ? : null}
41 |
42 |
43 | );
44 | };
45 |
46 | export default memo(Config);
47 |
--------------------------------------------------------------------------------
/src/panels/ConfigPanel/Config/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | container: css`
5 | display: flex;
6 | flex-direction: column;
7 |
8 | width: 100%;
9 | padding-right: 24px;
10 | padding-left: 24px;
11 | `,
12 |
13 | content: css`
14 | display: flex;
15 | flex-direction: column;
16 | flex-grow: 1;
17 |
18 | width: 100%;
19 | height: 100%;
20 | `,
21 | }));
22 |
--------------------------------------------------------------------------------
/src/panels/ConfigPanel/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import PanelContainer from '@/panels/PanelContainer';
4 | import React from 'react';
5 | import Config from './Config';
6 | import { useStyles } from './style';
7 |
8 | interface ConfigPanelProps {
9 | className?: string;
10 | style?: React.CSSProperties;
11 | }
12 |
13 | const ConfigPanel = (props: ConfigPanelProps) => {
14 | const { style, className } = props;
15 | const { styles } = useStyles();
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default ConfigPanel;
27 |
--------------------------------------------------------------------------------
/src/panels/ConfigPanel/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | content: css`
5 | position: relative;
6 |
7 | display: flex;
8 | flex-direction: row;
9 | flex-grow: 1;
10 |
11 | width: 100%;
12 | height: 100%;
13 | `,
14 | }));
15 |
--------------------------------------------------------------------------------
/src/panels/DancePanel/Dance/DanceCard/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, token }) => ({
4 | container: css`
5 | position: relative;
6 | padding: 16px;
7 | border-bottom: 1px solid ${token.colorBorderSecondary};
8 | `,
9 | date: css`
10 | font-size: 12px;
11 | color: ${token.colorTextDescription};
12 | `,
13 | desc: css`
14 | color: ${token.colorTextDescription};
15 | text-align: center;
16 | `,
17 | footer: css`
18 | overflow-y: auto;
19 | height: 300px;
20 | padding: 8px;
21 | white-space: break-spaces;
22 | `,
23 |
24 | title: css`
25 | overflow: hidden;
26 |
27 | width: 160px;
28 |
29 | font-size: 20px;
30 | font-weight: 600;
31 | text-align: center;
32 | text-overflow: ellipsis;
33 | white-space: nowrap;
34 | `,
35 | }));
36 |
--------------------------------------------------------------------------------
/src/panels/DancePanel/Dance/DanceList/index.tsx:
--------------------------------------------------------------------------------
1 | import { useConfigStore } from '@/store/config';
2 | import { useDanceStore } from '@/store/dance';
3 | import { useMarketStore } from '@/store/market';
4 | import { GradientButton } from '@lobehub/ui';
5 | import { Card, List } from 'antd';
6 | import { Flexbox } from 'react-layout-kit';
7 |
8 | const { Meta } = Card;
9 |
10 | const DanceList = () => {
11 | const [danceList, activateDance] = useDanceStore((s) => [s.danceList, s.activateDance]);
12 | const [openPanel] = useConfigStore((s) => [s.openPanel]);
13 | const [setTab] = useMarketStore((s) => [s.setTab]);
14 |
15 | return (
16 | <>
17 |
18 | 订阅列表
19 | {
22 | openPanel('market');
23 | setTab('dance');
24 | }}
25 | size="middle"
26 | >
27 | + 订阅舞蹈
28 |
29 |
30 | (
34 |
35 |
39 | }
40 | hoverable
41 | onClick={() => {
42 | activateDance(item.danceId);
43 | }}
44 | >
45 |
46 |
47 |
48 | )}
49 | />
50 | >
51 | );
52 | };
53 |
54 | export default DanceList;
55 |
--------------------------------------------------------------------------------
/src/panels/DancePanel/Dance/index.tsx:
--------------------------------------------------------------------------------
1 | import { GridBackground } from '@lobehub/ui';
2 | import { createStyles } from 'antd-style';
3 | import classNames from 'classnames';
4 | import React, { memo } from 'react';
5 | import { Center } from 'react-layout-kit';
6 | import DanceCard from './DanceCard';
7 | import DanceList from './DanceList';
8 |
9 | const useStyles = createStyles(({ css }) => ({
10 | background: css`
11 | width: 90%;
12 | margin: -24px 0 -12px;
13 | `,
14 | container: css`
15 | position: relative;
16 |
17 | display: flex;
18 |
19 | width: 100%;
20 | height: 100%;
21 | min-height: 500px;
22 | `,
23 | content: css`
24 | overflow-y: auto;
25 | flex-grow: 1;
26 | padding-right: 24px;
27 | padding-left: 24px;
28 | `,
29 | title: css`
30 | z-index: 2;
31 | margin-top: 24px;
32 | font-size: 36px;
33 | font-weight: 800;
34 | `,
35 | }));
36 |
37 | interface DanceProps {
38 | className?: string;
39 | style?: React.CSSProperties;
40 | }
41 |
42 | const Dance = (props: DanceProps) => {
43 | const { style, className } = props;
44 | const { theme, styles } = useStyles();
45 | return (
46 |
47 |
48 |
49 | Just Dance
50 |
56 |
57 |
58 |
59 |
60 |
61 | );
62 | };
63 |
64 | export default memo(Dance);
65 |
--------------------------------------------------------------------------------
/src/panels/DancePanel/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import PanelContainer from '@/panels/PanelContainer';
4 | import React from 'react';
5 | import Dance from './Dance';
6 | import { useStyles } from './style';
7 |
8 | interface DancePanelProps {
9 | className?: string;
10 | style?: React.CSSProperties;
11 | }
12 |
13 | const DancePanel = (props: DancePanelProps) => {
14 | const { style, className } = props;
15 | const { styles } = useStyles();
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default DancePanel;
27 |
--------------------------------------------------------------------------------
/src/panels/DancePanel/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | content: css`
5 | position: relative;
6 |
7 | display: flex;
8 | flex-direction: row;
9 | flex-grow: 1;
10 |
11 | width: 100%;
12 | height: 100%;
13 | `,
14 | }));
15 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Agent/AgentCard/SubscribeButton.tsx:
--------------------------------------------------------------------------------
1 | import { agentListSelectors, useAgentStore } from '@/store/agent';
2 | import { Agent } from '@/types/agent';
3 | import { Button } from 'antd';
4 |
5 | interface SubscribeButtonProps {
6 | agent: Agent;
7 | }
8 |
9 | const SubscribeButton = (props: SubscribeButtonProps) => {
10 | const [subscribe, unsubscribe, subscribed] = useAgentStore((s) => [
11 | s.subscribe,
12 | s.unsubscribe,
13 | agentListSelectors.subscribed(s),
14 | ]);
15 |
16 | const { agent } = props;
17 |
18 | const isSubscribed = subscribed(agent.agentId);
19 |
20 | return (
21 |
33 | );
34 | };
35 |
36 | export default SubscribeButton;
37 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Agent/AgentCard/index.tsx:
--------------------------------------------------------------------------------
1 | import AgentInfo from '@/components/AgentInfo';
2 | import { marketStoreSelectors, useMarketStore } from '@/store/market';
3 |
4 | import { DraggablePanel } from '@lobehub/ui';
5 | import { createStyles } from 'antd-style';
6 | import { memo, useState } from 'react';
7 | import DownloadButton from './SubscribeButton';
8 |
9 | const useStyles = createStyles(({ css, token }) => ({
10 | content: css`
11 | display: flex;
12 | flex-direction: column;
13 | height: 100% !important;
14 | `,
15 | header: css`
16 | border-bottom: 1px solid ${token.colorBorder};
17 | `,
18 | }));
19 |
20 | const Header = () => {
21 | const { styles } = useStyles();
22 | const [tempId, setTempId] = useState('');
23 | const [showAgentSidebar, activateAgent, deactivateAgent, currentAgentItem] = useMarketStore(
24 | (s) => [
25 | marketStoreSelectors.showAgentSideBar(s),
26 | s.activateAgent,
27 | s.deactivateAgent,
28 | marketStoreSelectors.currentAgentItem(s),
29 | ],
30 | );
31 |
32 | const actions = [];
33 | if (currentAgentItem) {
34 | actions.push();
35 | }
36 |
37 | return (
38 | {
46 | if (!show) {
47 | setTempId(useMarketStore.getState().currentAgentId);
48 | deactivateAgent();
49 | } else if (tempId) {
50 | activateAgent(tempId);
51 | }
52 | }}
53 | placement={'right'}
54 | >
55 |
56 |
57 | );
58 | };
59 |
60 | export default memo(Header);
61 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Agent/AgentIndex/AgentList.tsx:
--------------------------------------------------------------------------------
1 | import { agentListSelectors, useAgentStore } from '@/store/agent';
2 | import { marketStoreSelectors, useMarketStore } from '@/store/market';
3 | import { CheckCircleTwoTone } from '@ant-design/icons';
4 | import { Card, List } from 'antd';
5 | import { memo } from 'react';
6 |
7 | const { Meta } = List.Item;
8 |
9 | const AgentList = () => {
10 | const [activateAgent, agentList, agentLoading, showAgentSidebar] = useMarketStore((s) => [
11 | s.activateAgent,
12 | s.agentList,
13 | s.agentLoading,
14 | marketStoreSelectors.showAgentSideBar(s),
15 | ]);
16 | const [subscribed] = useAgentStore((s) => [agentListSelectors.subscribed(s)]);
17 | return (
18 | {
23 | const { avatar, name } = item?.meta || {};
24 | const isSubscribed = subscribed(item.agentId);
25 | return (
26 |
27 | {
29 | activateAgent(item.agentId);
30 | }}
31 | hoverable
32 | // eslint-disable-next-line @next/next/no-img-element,
33 | cover={
}
34 | >
35 |
36 |
37 | {isSubscribed ? (
38 |
42 | ) : null}
43 |
44 | );
45 | }}
46 | />
47 | );
48 | };
49 |
50 | export default memo(AgentList);
51 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Agent/AgentIndex/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useMarketStore } from '@/store/market';
2 | import { Button } from 'antd';
3 | import { createStyles } from 'antd-style';
4 | import classNames from 'classnames';
5 | import React, { memo, useEffect } from 'react';
6 |
7 | const useStyles = createStyles(({ css }) => ({
8 | actions: css`
9 | display: flex;
10 | justify-content: flex-end;
11 | margin-top: 12px;
12 | `,
13 | address: css`
14 | display: flex;
15 | align-items: center;
16 | justify-content: space-between;
17 | `,
18 | content: css`
19 | display: flex;
20 | align-items: center;
21 | justify-content: space-between;
22 | `,
23 | label: css`
24 | flex-shrink: 0;
25 | `,
26 | }));
27 |
28 | interface AgentLoaderProps {
29 | className?: string;
30 | style?: React.CSSProperties;
31 | }
32 |
33 | const Header = (props: AgentLoaderProps) => {
34 | const { style, className } = props;
35 | const [fetchAgentIndex, agentLoading] = useMarketStore((s) => [
36 | s.fetchAgentIndex,
37 | s.agentLoading,
38 | ]);
39 | const { styles } = useStyles();
40 |
41 | useEffect(() => {
42 | fetchAgentIndex();
43 | }, [fetchAgentIndex]);
44 |
45 | return (
46 |
47 |
模型列表
48 |
51 |
52 | );
53 | };
54 |
55 | export default memo(Header);
56 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Agent/AgentIndex/index.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import AgentList from './AgentList';
3 | import Header from './Header';
4 |
5 | const AgentIndex = () => {
6 | return (
7 | <>
8 |
9 |
10 | >
11 | );
12 | };
13 |
14 | export default memo(AgentIndex);
15 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Agent/index.tsx:
--------------------------------------------------------------------------------
1 | import { GridBackground } from '@lobehub/ui';
2 | import { createStyles } from 'antd-style';
3 | import classNames from 'classnames';
4 | import React, { memo } from 'react';
5 | import { Center } from 'react-layout-kit';
6 | import AgentCard from './AgentCard';
7 | import AgentIndex from './AgentIndex';
8 |
9 | const useStyles = createStyles(({ css }) => ({
10 | background: css`
11 | width: 90%;
12 | margin: -24px 0 -12px;
13 | `,
14 | container: css`
15 | position: relative;
16 |
17 | display: flex;
18 |
19 | width: 100%;
20 | height: 100%;
21 | min-height: 500px;
22 | `,
23 | content: css`
24 | overflow-y: auto;
25 | flex-grow: 1;
26 | padding-right: 24px;
27 | padding-left: 24px;
28 | `,
29 | title: css`
30 | z-index: 2;
31 | margin-top: 24px;
32 | font-size: 36px;
33 | font-weight: 800;
34 | `,
35 | }));
36 |
37 | interface AgentProps {
38 | className?: string;
39 | style?: React.CSSProperties;
40 | }
41 |
42 | const Agent = (props: AgentProps) => {
43 | const { theme, styles } = useStyles();
44 | const { style, className } = props;
45 |
46 | return (
47 |
48 |
49 |
50 | Find Your Lovest VChat
51 |
57 |
58 |
59 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default memo(Agent);
66 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Dance/DanceCard/SubscribeButton.tsx:
--------------------------------------------------------------------------------
1 | import { danceListSelectors, useDanceStore } from '@/store/dance';
2 | import { Dance } from '@/types/dance';
3 | import { Button } from 'antd';
4 |
5 | interface SubscribeButtonProps {
6 | dance: Dance;
7 | }
8 |
9 | const SubscribeButton = (props: SubscribeButtonProps) => {
10 | const [subscribe, unsubscribe, subscribed] = useDanceStore((s) => [
11 | s.subscribe,
12 | s.unsubscribe,
13 | danceListSelectors.subscribed(s),
14 | ]);
15 |
16 | const { dance } = props;
17 |
18 | const isSubscribed = subscribed(dance.danceId);
19 |
20 | return (
21 |
33 | );
34 | };
35 |
36 | export default SubscribeButton;
37 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Dance/DanceCard/index.tsx:
--------------------------------------------------------------------------------
1 | import DanceInfo from '@/components/DanceInfo';
2 | import { marketStoreSelectors, useMarketStore } from '@/store/market';
3 |
4 | import { DraggablePanel } from '@lobehub/ui';
5 | import { createStyles } from 'antd-style';
6 | import { memo, useState } from 'react';
7 | import SubscribeButton from './SubscribeButton';
8 |
9 | const useStyles = createStyles(({ css, token }) => ({
10 | content: css`
11 | display: flex;
12 | flex-direction: column;
13 | height: 100% !important;
14 | `,
15 | header: css`
16 | border-bottom: 1px solid ${token.colorBorder};
17 | `,
18 | }));
19 |
20 | const Header = () => {
21 | const { styles } = useStyles();
22 | const [tempId, setTempId] = useState('');
23 | const [showDanceSidebar, activateDance, deactivateDance, currentDanceItem] = useMarketStore(
24 | (s) => [
25 | marketStoreSelectors.showDanceSideBar(s),
26 | s.activateDance,
27 | s.deactivateDance,
28 | marketStoreSelectors.currentDanceItem(s),
29 | ],
30 | );
31 |
32 | const actions = [];
33 | if (currentDanceItem) {
34 | actions.push();
35 | }
36 |
37 | return (
38 | {
46 | if (!show) {
47 | setTempId(useMarketStore.getState().currentDanceId);
48 | deactivateDance();
49 | } else if (tempId) {
50 | activateDance(tempId);
51 | }
52 | }}
53 | placement={'right'}
54 | >
55 |
56 |
57 | );
58 | };
59 |
60 | export default memo(Header);
61 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Dance/DanceIndex/DanceList.tsx:
--------------------------------------------------------------------------------
1 | import { danceListSelectors, useDanceStore } from '@/store/dance';
2 | import { marketStoreSelectors, useMarketStore } from '@/store/market';
3 | import { CheckCircleTwoTone } from '@ant-design/icons';
4 | import { Card, List } from 'antd';
5 | import { memo } from 'react';
6 |
7 | const { Meta } = List.Item;
8 |
9 | const DanceList = () => {
10 | const [activateDance, danceList, danceLoading, showDanceSidebar] = useMarketStore((s) => [
11 | s.activateDance,
12 | s.danceList,
13 | s.danceLoading,
14 | marketStoreSelectors.showDanceSideBar(s),
15 | ]);
16 | const [subscribed] = useDanceStore((s) => [danceListSelectors.subscribed(s)]);
17 | return (
18 | {
23 | const isSubscribed = subscribed(item.danceId);
24 | return (
25 |
26 |
30 | }
31 | hoverable
32 | onClick={() => {
33 | activateDance(item.danceId);
34 | }}
35 | >
36 |
37 |
38 | {isSubscribed ? (
39 |
43 | ) : null}
44 |
45 | );
46 | }}
47 | />
48 | );
49 | };
50 |
51 | export default memo(DanceList);
52 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Dance/DanceIndex/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useMarketStore } from '@/store/market';
2 | import { Button } from 'antd';
3 | import { createStyles } from 'antd-style';
4 | import classNames from 'classnames';
5 | import React, { memo, useEffect } from 'react';
6 |
7 | const useStyles = createStyles(({ css }) => ({
8 | actions: css`
9 | display: flex;
10 | justify-content: flex-end;
11 | margin-top: 12px;
12 | `,
13 | address: css`
14 | display: flex;
15 | align-items: center;
16 | justify-content: space-between;
17 | `,
18 | content: css`
19 | display: flex;
20 | align-items: center;
21 | justify-content: space-between;
22 | `,
23 | label: css`
24 | flex-shrink: 0;
25 | `,
26 | }));
27 |
28 | interface HeaderProps {
29 | className?: string;
30 | style?: React.CSSProperties;
31 | }
32 |
33 | const Header = (props: HeaderProps) => {
34 | const { style, className } = props;
35 | const [fetchDanceIndex, danceLoading] = useMarketStore((s) => [
36 | s.fetchDanceIndex,
37 | s.danceLoading,
38 | ]);
39 | const { styles } = useStyles();
40 |
41 | useEffect(() => {
42 | fetchDanceIndex();
43 | }, [fetchDanceIndex]);
44 |
45 | return (
46 |
47 |
舞蹈列表
48 |
51 |
52 | );
53 | };
54 |
55 | export default memo(Header);
56 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Dance/DanceIndex/index.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import DanceList from './DanceList';
3 | import Header from './Header';
4 |
5 | const DanceIndex = () => {
6 | return (
7 | <>
8 |
9 |
10 | >
11 | );
12 | };
13 |
14 | export default memo(DanceIndex);
15 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/Dance/index.tsx:
--------------------------------------------------------------------------------
1 | import { GridBackground } from '@lobehub/ui';
2 | import { createStyles } from 'antd-style';
3 | import classNames from 'classnames';
4 | import React from 'react';
5 | import { Center } from 'react-layout-kit';
6 | import DanceCard from './DanceCard';
7 | import DanceIndex from './DanceIndex';
8 |
9 | const useStyles = createStyles(({ css }) => ({
10 | background: css`
11 | width: 90%;
12 | margin: -24px 0 -12px;
13 | `,
14 | container: css`
15 | position: relative;
16 |
17 | display: flex;
18 |
19 | width: 100%;
20 | height: 100%;
21 | min-height: 500px;
22 | `,
23 | content: css`
24 | overflow-y: auto;
25 | flex-grow: 1;
26 | padding-right: 24px;
27 | padding-left: 24px;
28 | `,
29 | title: css`
30 | z-index: 2;
31 | margin-top: 24px;
32 | font-size: 36px;
33 | font-weight: 800;
34 | `,
35 | }));
36 |
37 | interface DanceProps {
38 | className?: string;
39 | style?: React.CSSProperties;
40 | }
41 |
42 | const Dance = (props: DanceProps) => {
43 | const { style, className } = props;
44 | const { theme, styles } = useStyles();
45 | return (
46 |
47 |
48 |
49 | Find Your Favorite Dance
50 |
56 |
57 |
58 |
59 |
60 |
61 | );
62 | };
63 |
64 | export default Dance;
65 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/SideNav/index.tsx:
--------------------------------------------------------------------------------
1 | import { useMarketStore } from '@/store/market';
2 | import { ActionIcon, SideNav as LobeSideNav } from '@lobehub/ui';
3 | import { Music2, User } from 'lucide-react';
4 |
5 | interface SideNavProps {
6 | className?: string;
7 | }
8 |
9 | const SideNav = (props: SideNavProps) => {
10 | const { tab, setTab } = useMarketStore();
11 | const { className } = props;
12 |
13 | return (
14 |
19 | {
24 | setTab('agent');
25 | }}
26 | size="large"
27 | />
28 | {
33 | setTab('dance');
34 | }}
35 | size="large"
36 | />
37 | >
38 | }
39 | />
40 | );
41 | };
42 |
43 | export default SideNav;
44 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import PanelContainer from '@/panels/PanelContainer';
4 | import { useMarketStore } from '@/store/market';
5 | import React from 'react';
6 | import Agent from './Agent';
7 | import Dance from './Dance';
8 | import SideNav from './SideNav';
9 | import { useStyles } from './style';
10 |
11 | interface MarketPanelProps {
12 | className?: string;
13 | style?: React.CSSProperties;
14 | }
15 |
16 | const MarketPanel = (props: MarketPanelProps) => {
17 | const { style, className } = props;
18 | const { styles } = useStyles();
19 | const tab = useMarketStore((s) => s.tab);
20 |
21 | return (
22 |
23 |
24 |
28 |
29 | );
30 | };
31 |
32 | export default MarketPanel;
33 |
--------------------------------------------------------------------------------
/src/panels/MarketPanel/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | content: css`
5 | display: flex;
6 | flex-direction: row;
7 | flex-grow: 1;
8 |
9 | width: 100%;
10 | height: 100%;
11 | `,
12 | }));
13 |
--------------------------------------------------------------------------------
/src/panels/PanelContainer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Panel from '@/components/Panel';
4 | import { configSelectors, useConfigStore } from '@/store/config';
5 | import { PanelKey } from '@/types/config';
6 | import React, { PropsWithChildren } from 'react';
7 |
8 | interface PanelContainerProps {
9 | className?: string;
10 | panelKey: PanelKey;
11 | style?: React.CSSProperties;
12 | title?: string;
13 | }
14 |
15 | const PanelContainer = (props: PropsWithChildren) => {
16 | const { style, className, panelKey, title, children } = props;
17 | const [panel, setPanel, focusPanel, closePanel] = useConfigStore((s) => [
18 | s.panel,
19 | s.setPanel,
20 | s.focusPanel,
21 | s.closePanel,
22 | ]);
23 | const zIndex = useConfigStore((s) => configSelectors.getPanelZIndex(s, panelKey));
24 |
25 | return (
26 | closePanel(panelKey)}
30 | onCoordinatesChange={(coordinates) => setPanel(panelKey, { coordinates })}
31 | onFocus={() => focusPanel(panelKey)}
32 | style={style}
33 | title={title}
34 | zIndex={zIndex}
35 | >
36 | {children}
37 |
38 | );
39 | };
40 |
41 | export default PanelContainer;
42 |
--------------------------------------------------------------------------------
/src/panels/RolePanel/Touch/ActionList/index.tsx:
--------------------------------------------------------------------------------
1 | import { speakCharacter } from '@/features/messages/speakCharacter';
2 | import { sessionSelectors, useSessionStore } from '@/store/session';
3 | import { useTouchStore } from '@/store/touch';
4 | import { useViewerStore } from '@/store/viewer';
5 | import { ActionIcon } from '@lobehub/ui';
6 | import { List } from 'antd';
7 | import { createStyles } from 'antd-style';
8 | import { isEqual } from 'lodash-es';
9 | import { PlayIcon } from 'lucide-react';
10 |
11 | const useStyles = createStyles(({ css, token }) => ({
12 | active: css`
13 | background-color: ${token.controlItemBgActiveHover};
14 | `,
15 | list: css`
16 | width: 100%;
17 | padding: 24px;
18 | `,
19 | listItem: css`
20 | &:hover {
21 | cursor: pointer;
22 | }
23 | `,
24 | }));
25 |
26 | const AreaList = () => {
27 | const { styles } = useStyles();
28 | const { actionConfig, currentTouchArea } = useTouchStore();
29 | const currentAgent = useSessionStore((s) => sessionSelectors.currentAgent(s), isEqual);
30 |
31 | const { viewer } = useViewerStore();
32 |
33 | const data = actionConfig[currentTouchArea];
34 | return (
35 | 触摸反应列表}
39 | renderItem={(item) => (
40 | {
47 | speakCharacter(
48 | {
49 | emotion: item.emotion,
50 | tts: {
51 | ...currentAgent?.tts,
52 | message: item.text,
53 | },
54 | },
55 | viewer,
56 | );
57 | }}
58 | />,
59 | ]}
60 | className={styles.listItem}
61 | >
62 |
63 |
64 | )}
65 | />
66 | );
67 | };
68 |
69 | export default AreaList;
70 |
--------------------------------------------------------------------------------
/src/panels/RolePanel/Touch/SideBar/AreaList.tsx:
--------------------------------------------------------------------------------
1 | import { useTouchStore } from '@/store/touch';
2 | import { TouchAreaEnum } from '@/types/touch';
3 | import { List } from 'antd';
4 | import classNames from 'classnames';
5 |
6 | import { createStyles } from 'antd-style';
7 |
8 | const useStyles = createStyles(({ css, token }) => ({
9 | active: css`
10 | background-color: ${token.controlItemBgActiveHover};
11 | `,
12 | list: css`
13 | width: 240px;
14 | border-right: 1px solid ${token.colorBorder};
15 | `,
16 | listItem: css`
17 | &:hover {
18 | cursor: pointer;
19 | }
20 | `,
21 | }));
22 |
23 | const AreaList = () => {
24 | const { styles } = useStyles();
25 | const { currentTouchArea, setCurrentTouchArea } = useTouchStore();
26 |
27 | const data = [
28 | { label: '头部', value: TouchAreaEnum.Head },
29 | { label: '手臂', value: TouchAreaEnum.Arm },
30 | { label: '腿部', value: TouchAreaEnum.Leg },
31 | { label: '胸部', value: TouchAreaEnum.Chest },
32 | { label: '腹部', value: TouchAreaEnum.Belly },
33 | ];
34 |
35 | return (
36 | 触摸区域列表}
40 | renderItem={(item) => (
41 | setCurrentTouchArea(item.value)}
46 | style={{ padding: 12 }}
47 | >
48 | {item.label}
49 |
50 | )}
51 | />
52 | );
53 | };
54 |
55 | export default AreaList;
56 |
--------------------------------------------------------------------------------
/src/panels/RolePanel/Touch/SideBar/index.tsx:
--------------------------------------------------------------------------------
1 | import AreaList from './AreaList';
2 |
3 | const SideBar = () => {
4 | return ;
5 | };
6 |
7 | export default SideBar;
8 |
--------------------------------------------------------------------------------
/src/panels/RolePanel/Touch/SideBar/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css, token }) => ({
4 | date: css`
5 | font-size: 12px;
6 | color: ${token.colorTextDescription};
7 | `,
8 | desc: css`
9 | color: ${token.colorTextDescription};
10 | text-align: center;
11 | `,
12 |
13 | title: css`
14 | font-size: 20px;
15 | font-weight: 600;
16 | text-align: center;
17 | `,
18 | }));
19 |
--------------------------------------------------------------------------------
/src/panels/RolePanel/Touch/index.tsx:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 | import classNames from 'classnames';
3 | import React, { memo } from 'react';
4 | import ActionList from './ActionList';
5 | import SideBar from './SideBar';
6 |
7 | const useStyles = createStyles(({ css, token }) => ({
8 | container: css`
9 | position: relative;
10 |
11 | display: flex;
12 |
13 | width: 100%;
14 | min-height: 500px;
15 |
16 | border: 1px solid ${token.colorBorder};
17 | border-radius: ${token.borderRadius}px;
18 | `,
19 | }));
20 |
21 | interface TouchProps {
22 | className?: string;
23 | style?: React.CSSProperties;
24 | }
25 |
26 | const Touch = (props: TouchProps) => {
27 | const { style, className } = props;
28 | const { styles } = useStyles();
29 |
30 | return (
31 |
35 | );
36 | };
37 |
38 | export default memo(Touch);
39 |
--------------------------------------------------------------------------------
/src/panels/RolePanel/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import PanelContainer from '@/panels/PanelContainer';
4 | import { TabsNav } from '@lobehub/ui';
5 | import React, { useState } from 'react';
6 | import Info from './Info';
7 | import Role from './Role';
8 | import Touch from './Touch';
9 | import Voice from './Voice';
10 | import { useStyles } from './style';
11 |
12 | interface RolePanelProps {
13 | className?: string;
14 | style?: React.CSSProperties;
15 | }
16 |
17 | const RolePanel = (props: RolePanelProps) => {
18 | const { style, className } = props;
19 | const { styles } = useStyles();
20 | const [tab, setTab] = useState('info');
21 |
22 | return (
23 |
24 |
33 |
34 | {
55 | setTab(key);
56 | }}
57 | />
58 |
59 |
60 | {tab === 'info' ? : null}
61 | {tab === 'role' ? : null}
62 | {tab === 'voice' ? : null}
63 | {tab === 'touch' ? : null}
64 |
65 |
66 |
67 | );
68 | };
69 |
70 | export default RolePanel;
71 |
--------------------------------------------------------------------------------
/src/panels/RolePanel/style.ts:
--------------------------------------------------------------------------------
1 | import { createStyles } from 'antd-style';
2 |
3 | export const useStyles = createStyles(({ css }) => ({
4 | content: css`
5 | display: flex;
6 | flex-direction: column;
7 | flex-grow: 1;
8 |
9 | width: 100%;
10 | height: 100%;
11 | `,
12 | }));
13 |
--------------------------------------------------------------------------------
/src/panels/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | export {default as AgentPanel} from './AgentPanel';
13 | export {default as ChatPanel} from './ChatPanel';
14 | export {default as ConfigPanel} from './ConfigPanel';
15 | export {default as DancePanel} from './DancePanel';
16 | export {default as MarketPanel} from './MarketPanel';
17 | export {default as RolePanel} from './RolePanel';
--------------------------------------------------------------------------------
/src/services/agent.ts:
--------------------------------------------------------------------------------
1 | import { AGENT_INDEX_URL } from '@/constants/common';
2 |
3 | /**
4 | * 请求线上 Agent index
5 | */
6 | export const getAgentIndex = async (url: string = AGENT_INDEX_URL) => {
7 | const res = await fetch(url);
8 |
9 | return res.json();
10 | };
11 |
12 | export const downloadGithubAgent = async (url: string) => {
13 | const res = await fetch('/api/agent/download', {
14 | body: JSON.stringify({ url }),
15 | headers: {
16 | 'Content-Type': 'application/json',
17 | },
18 | method: 'POST',
19 | });
20 |
21 | return res.json();
22 | };
23 |
--------------------------------------------------------------------------------
/src/services/chat.ts:
--------------------------------------------------------------------------------
1 | import { OPENAI_API_KEY, OPENAI_END_POINT } from '@/constants/openai';
2 | import { speakCharacter } from '@/features/messages/speakCharacter';
3 | import { configSelectors, useConfigStore } from '@/store/config';
4 | import { sessionSelectors, useSessionStore } from '@/store/session';
5 | import { useViewerStore } from '@/store/viewer';
6 |
7 | const createHeader = (header?: any) => {
8 | const config = configSelectors.currentOpenAIConfig(useConfigStore.getState());
9 | return {
10 | 'Content-Type': 'application/json',
11 | [OPENAI_API_KEY]: config?.apikey || '',
12 | [OPENAI_END_POINT]: config?.endpoint || '',
13 | ...header,
14 | };
15 | };
16 |
17 | export const chatCompletion = async (payload: any) => {
18 | const config = configSelectors.currentOpenAIConfig(useConfigStore.getState());
19 |
20 | const res = await fetch('/api/chat/openai', {
21 | body: JSON.stringify({
22 | model: config?.model,
23 | ...payload,
24 | }),
25 | headers: createHeader(),
26 | method: 'POST',
27 | });
28 | return res;
29 | };
30 |
31 | export const handleSpeakAi = async (message: string) => {
32 | const viewer = useViewerStore.getState().viewer;
33 | const currentAgent = sessionSelectors.currentAgent(useSessionStore.getState());
34 |
35 | speakCharacter(
36 | {
37 | emotion: 'aa',
38 | tts: {
39 | ...currentAgent?.tts,
40 | message: message,
41 | },
42 | },
43 | viewer,
44 | );
45 | };
46 |
47 | export const toogleVoice = async () => {
48 | const { toggleVoice, voiceOn } = useSessionStore.getState();
49 | if (voiceOn) {
50 | const viewer = useViewerStore.getState().viewer;
51 | viewer.model?.stopSpeak();
52 | }
53 | toggleVoice();
54 | };
55 |
--------------------------------------------------------------------------------
/src/services/dance.ts:
--------------------------------------------------------------------------------
1 | import { DANCE_INDEX_URL } from '@/constants/common';
2 | /**
3 | * 请求 Dance 列表
4 | */
5 | export const getLocalDanceList = async () => {
6 | const res = await fetch('/api/dance/list');
7 |
8 | return res.json();
9 | };
10 |
11 | /**
12 | * 删除本地 Agent 目录
13 | */
14 | export const deleteLocalDance = async (agentId: string) => {
15 | const res = await fetch(`/api/dance/${agentId}`, {
16 | method: 'DELETE',
17 | });
18 |
19 | return res.json();
20 | };
21 |
22 | /**
23 | * 请求线上 Dance index
24 | */
25 | export const getDanceIndex = async (url: string = DANCE_INDEX_URL) => {
26 | const res = await fetch(url);
27 |
28 | return res.json();
29 | };
30 |
31 | export const downloadGithubDance = async (url: string) => {
32 | const res = await fetch('/api/dance/download', {
33 | body: JSON.stringify({ url }),
34 | headers: {
35 | 'Content-Type': 'application/json',
36 | },
37 | method: 'POST',
38 | });
39 |
40 | return res.json();
41 | };
42 |
--------------------------------------------------------------------------------
/src/services/tts.ts:
--------------------------------------------------------------------------------
1 | import { TTS, TTS_ENGINE, Voice } from '@/types/tts';
2 |
3 | const convertSSML = (values: TTS) => {
4 | const { voice, speed = 1, pitch = 1, message } = values;
5 |
6 | return `
7 |
8 |
9 | ${message}
10 |
11 |
12 |
13 | `;
14 | };
15 |
16 | export const speechApi = async (tts: TTS) => {
17 | const { engine = 'edge' } = tts;
18 | const ssml = convertSSML(tts);
19 | const res = await fetch(`/api/voice/${engine}`, {
20 | body: JSON.stringify({ ssml }),
21 | headers: {
22 | 'Content-Type': 'application/json',
23 | },
24 | method: 'POST',
25 | });
26 | if (res.status !== 200) {
27 | const data = await res.json();
28 | throw new Error(data.errorMessage);
29 | }
30 |
31 | const buffer = await res.arrayBuffer();
32 | return buffer;
33 | };
34 |
35 | const getVoiceKey = (engine: TTS_ENGINE) => {
36 | return `vidol_voice_${engine}`;
37 | };
38 |
39 | export const voiceListApi = async (engine: TTS_ENGINE): Promise<{ data: Voice[] }> => {
40 | const key = getVoiceKey(engine);
41 | if (sessionStorage.getItem(key)) {
42 | return JSON.parse(sessionStorage.getItem(key) || '');
43 | }
44 | const res = await fetch(`/api/voice/${engine}/voices`, {
45 | headers: {
46 | 'Content-Type': 'application/json',
47 | },
48 | method: 'GET',
49 | });
50 | const result = await res.json();
51 | sessionStorage.setItem(key, JSON.stringify(result));
52 |
53 | return result;
54 | };
55 |
--------------------------------------------------------------------------------
/src/store/agent/index.ts:
--------------------------------------------------------------------------------
1 | import { Agent } from '@/types/agent';
2 | import { produce } from 'immer';
3 | import { persist } from 'zustand/middleware';
4 | import { shallow } from 'zustand/shallow';
5 | import { createWithEqualityFn } from 'zustand/traditional';
6 |
7 |
8 | export interface AgentStore {
9 | activateAgent: (identifier: string) => void;
10 | currentIdentifier: string;
11 | deactivateAgent: () => void;
12 | getAgentById: (agentId: string) => Agent | undefined;
13 | subscribe: (agent: Agent) => void;
14 | subscribedList: Agent[];
15 | unsubscribe: (agentId: string) => void;
16 | }
17 |
18 | export const useAgentStore = createWithEqualityFn()(
19 | persist(
20 | (set, get) => ({
21 | activateAgent: (identifier) => {
22 | set({ currentIdentifier: identifier });
23 | },
24 | currentIdentifier: '',
25 | deactivateAgent: () => {
26 | set({ currentIdentifier: undefined });
27 | },
28 | getAgentById: (agentId: string): Agent | undefined => {
29 | const { subscribedList } = get();
30 |
31 | const currentAgent = subscribedList.find((item) => item.agentId === agentId);
32 | if (!currentAgent) return undefined;
33 |
34 | return currentAgent;
35 | },
36 | subscribe: (agent) => {
37 | const { subscribedList } = get();
38 |
39 | const newList = produce(subscribedList, (draft) => {
40 | const index = draft.findIndex((item) => item.agentId === agent.agentId);
41 |
42 | if (index === -1) {
43 | draft.unshift(agent);
44 | }
45 | });
46 | set({ subscribedList: newList });
47 | },
48 | subscribedList: [],
49 | unsubscribe: (agentId) => {
50 | const { subscribedList } = get();
51 | const newList = produce(subscribedList, (draft) => {
52 | const index = draft.findIndex((item) => item.agentId === agentId);
53 |
54 | if (index !== -1) {
55 | draft.splice(index, 1);
56 | }
57 | });
58 | set({ currentIdentifier: newList[0]?.agentId, subscribedList: newList });
59 | },
60 | }),
61 | {
62 | name: 'vidol-chat-agent-storage',
63 | },
64 | ),
65 | shallow,
66 | );
67 |
68 |
69 |
70 | export {agentListSelectors} from './selectors/agent';
--------------------------------------------------------------------------------
/src/store/agent/selectors/agent.ts:
--------------------------------------------------------------------------------
1 | import { Agent } from '@/types/agent';
2 | import { AgentStore } from '../index';
3 |
4 | const showSideBar = (s: AgentStore) => !!s.currentIdentifier;
5 |
6 | const currentAgentItem = (s: AgentStore): Agent | undefined => {
7 | const { currentIdentifier, subscribedList } = s;
8 | const currentAgent = subscribedList.find((item) => item.agentId === currentIdentifier);
9 | if (!currentAgent) return undefined;
10 |
11 | return currentAgent;
12 | };
13 |
14 | const subscribed = (s: AgentStore) => (agentId: string) => {
15 | const { subscribedList } = s;
16 | const index = subscribedList.findIndex((item) => item.agentId === agentId);
17 |
18 | return index !== -1;
19 | };
20 |
21 | export const agentListSelectors = {
22 | currentAgentItem,
23 | showSideBar,
24 | subscribed,
25 | };
26 |
--------------------------------------------------------------------------------
/src/store/config/initialState.ts:
--------------------------------------------------------------------------------
1 | import { INITIAL_COORDINATES } from '@/constants/common';
2 | import { Config, PanelConfig, PanelKey } from '@/types/config';
3 |
4 | export interface ConfigState {
5 | config: Config;
6 | focusList: PanelKey[];
7 | panel: PanelConfig;
8 | }
9 |
10 | const initialState: ConfigState = {
11 | config: {
12 | backgroundEffect: 'glow',
13 | languageModel: {
14 | openAI: {
15 | apikey: '',
16 | endpoint: '',
17 | model: 'gpt-3.5-turbo',
18 | },
19 | },
20 | primaryColor: 'blue',
21 | },
22 | focusList: [],
23 |
24 | panel: {
25 | agent: {
26 | coordinates: INITIAL_COORDINATES,
27 | open: false,
28 | },
29 | chat: {
30 | coordinates: INITIAL_COORDINATES,
31 | open: false,
32 | },
33 | config: {
34 | coordinates: INITIAL_COORDINATES,
35 | open: false,
36 | },
37 | dance: {
38 | coordinates: INITIAL_COORDINATES,
39 | open: false,
40 | },
41 | live: {
42 | coordinates: INITIAL_COORDINATES,
43 | open: false,
44 | },
45 | market: {
46 | coordinates: INITIAL_COORDINATES,
47 | open: false,
48 | },
49 | role: {
50 | coordinates: INITIAL_COORDINATES,
51 | open: false,
52 | },
53 | },
54 | };
55 |
56 | export { initialState };
57 |
--------------------------------------------------------------------------------
/src/store/config/selectors/config.ts:
--------------------------------------------------------------------------------
1 | import { INITIAL_Z_INDEX } from '@/constants/common';
2 | import { ConfigStore } from '@/store/config';
3 | import { OpenAIConfig, PanelKey } from '@/types/config';
4 |
5 | const currentOpenAIConfig = (s: ConfigStore): OpenAIConfig | undefined => {
6 | return s.config.languageModel.openAI;
7 | };
8 |
9 | const getPanelZIndex = (s: ConfigStore, panelKey: PanelKey) => {
10 | const focusList = s.focusList;
11 | const index = focusList.indexOf(panelKey);
12 | return index === -1 ? INITIAL_Z_INDEX : INITIAL_Z_INDEX + index;
13 | };
14 |
15 | export const configSelectors = {
16 | currentOpenAIConfig,
17 | getPanelZIndex,
18 | };
19 |
--------------------------------------------------------------------------------
/src/store/dance/index.ts:
--------------------------------------------------------------------------------
1 | import { StateCreator } from 'zustand';
2 | import { devtools, persist } from 'zustand/middleware';
3 | import { shallow } from 'zustand/shallow';
4 | import { createWithEqualityFn } from 'zustand/traditional';
5 |
6 | import { DanceListStore, createDanceStore } from './slices/dancelist';
7 | import { PlayListStore, createPlayListStore } from './slices/playlist';
8 |
9 | export type DanceStore = DanceListStore & PlayListStore;
10 |
11 | const createStore: StateCreator = (...parameters) => ({
12 | ...createDanceStore(...parameters),
13 | ...createPlayListStore(...parameters),
14 | });
15 |
16 | export const useDanceStore = createWithEqualityFn()(
17 | persist(
18 | devtools(createStore, {
19 | name: 'VIDOL_DANCE_STORE',
20 | }),
21 | {
22 | name: 'vidol-chat-dance-storage', // name of the item in the storage (must be unique)
23 | },
24 | ),
25 | shallow,
26 | );
27 |
28 |
29 |
30 | export {danceListSelectors} from './selectors/dance';
--------------------------------------------------------------------------------
/src/store/dance/selectors/dance.ts:
--------------------------------------------------------------------------------
1 | import { Dance } from '@/types/dance';
2 | import { DanceStore } from '../index';
3 |
4 | const showSideBar = (s: DanceStore) => !!s.currentIdentifier;
5 |
6 | const currentDanceItem = (s: DanceStore): Dance | undefined => {
7 | const { currentIdentifier, danceList } = s;
8 | const currentDance = danceList.find((item) => item.danceId === currentIdentifier);
9 | if (!currentDance) return undefined;
10 |
11 | return currentDance;
12 | };
13 |
14 | const subscribed = (s: DanceStore) => (danceId: string) => {
15 | const { danceList } = s;
16 | const index = danceList.findIndex((item) => item.danceId === danceId);
17 |
18 | return index !== -1;
19 | };
20 |
21 | export const danceListSelectors = {
22 | currentDanceItem,
23 | showSideBar,
24 | subscribed,
25 | };
26 |
--------------------------------------------------------------------------------
/src/store/market/index.ts:
--------------------------------------------------------------------------------
1 | import { devtools, persist } from 'zustand/middleware';
2 | import { shallow } from 'zustand/shallow';
3 | import { createWithEqualityFn } from 'zustand/traditional';
4 | import { StateCreator } from 'zustand/vanilla';
5 | import { agentSelectors } from './selectors/agent';
6 | import { danceSelectors } from './selectors/dance';
7 | import { AgentStore, createAgentStore } from './slices/agent';
8 | import { DanceStore, createDanceStore } from './slices/dance';
9 | import { PanelStore, createPanelStore } from './slices/panel';
10 |
11 | export type MarketStore = PanelStore & AgentStore & DanceStore;
12 |
13 | const createStore: StateCreator = (...parameters) => ({
14 | ...createAgentStore(...parameters),
15 | ...createDanceStore(...parameters),
16 | ...createPanelStore(...parameters),
17 | });
18 |
19 | export const useMarketStore = createWithEqualityFn()(
20 | persist(
21 | devtools(createStore, {
22 | name: 'VIDOL_MARKET_STORE',
23 | }),
24 | {
25 | name: 'vidol-chat-market-storage', // name of the item in the storage (must be unique)
26 | },
27 | ),
28 | shallow,
29 | );
30 |
31 | export const marketStoreSelectors = {
32 | ...agentSelectors,
33 | ...danceSelectors,
34 | };
35 |
--------------------------------------------------------------------------------
/src/store/market/selectors/agent.ts:
--------------------------------------------------------------------------------
1 | import { MarketStore } from '@/store/market';
2 | import { Agent } from '@/types/agent';
3 |
4 | const showAgentSideBar = (s: MarketStore) => !!s.currentAgentId;
5 |
6 | const currentAgentItem = (s: MarketStore): Agent | undefined => {
7 | const { currentAgentId, agentList } = s;
8 | const currentAgent = agentList.find((item) => item.agentId === currentAgentId);
9 | if (!currentAgent) return undefined;
10 |
11 | return currentAgent;
12 | };
13 |
14 | export const agentSelectors = {
15 | currentAgentItem,
16 | showAgentSideBar,
17 | };
18 |
--------------------------------------------------------------------------------
/src/store/market/selectors/dance.ts:
--------------------------------------------------------------------------------
1 | import { MarketStore } from '@/store/market';
2 | import { Dance } from '@/types/dance';
3 |
4 | const showDanceSideBar = (s: MarketStore) => !!s.currentDanceId;
5 |
6 | const currentDanceItem = (s: MarketStore): Dance | undefined => {
7 | const { currentDanceId, danceList } = s;
8 | const currentDance = danceList.find((item) => item.danceId === currentDanceId);
9 | if (!currentDance) return undefined;
10 |
11 | return currentDance;
12 | };
13 |
14 | export const danceSelectors = {
15 | currentDanceItem,
16 | showDanceSideBar,
17 | };
18 |
--------------------------------------------------------------------------------
/src/store/market/slices/agent.ts:
--------------------------------------------------------------------------------
1 | import { getAgentIndex } from '@/services/agent';
2 | import { MarketStore } from '@/store/market';
3 | import { Agent } from '@/types/agent';
4 | import { isEqual } from 'lodash-es';
5 | import { StateCreator } from 'zustand/vanilla';
6 |
7 | export interface AgentStore {
8 | activateAgent: (identifier: string) => void;
9 | agentList: Agent[];
10 | agentLoading: boolean;
11 | currentAgentId: string;
12 | deactivateAgent: () => void;
13 | fetchAgentIndex: () => void;
14 | }
15 |
16 | export const createAgentStore: StateCreator<
17 | MarketStore,
18 | [['zustand/devtools', never]],
19 | [],
20 | AgentStore
21 | > = (set, get) => {
22 | return {
23 | activateAgent: (identifier) => {
24 | set({ currentAgentId: identifier });
25 | },
26 | agentList: [],
27 | agentLoading: false,
28 | currentAgentId: '',
29 | deactivateAgent: () => {
30 | set({ currentAgentId: undefined });
31 | },
32 | fetchAgentIndex: async () => {
33 | set({ agentLoading: true });
34 | try {
35 | const { agents = [] } = await getAgentIndex();
36 | const { agentList } = get();
37 | if (!isEqual(agentList, agents)) set({ agentList: agents });
38 | } catch {
39 | set({ agentList: [] });
40 | } finally {
41 | set({ agentLoading: false });
42 | }
43 | },
44 | };
45 | };
46 |
--------------------------------------------------------------------------------
/src/store/market/slices/dance.ts:
--------------------------------------------------------------------------------
1 | import { getDanceIndex } from '@/services/dance';
2 | import { MarketStore } from '@/store/market';
3 | import { Dance } from '@/types/dance'; // 更改这里
4 | import { isEqual } from 'lodash-es';
5 | import { StateCreator } from 'zustand/vanilla';
6 |
7 | export interface DanceStore {
8 | // 更改这里
9 | activateDance: (identifier: string) => void;
10 | // 更改这里
11 | currentDanceId: string;
12 | // 更改这里
13 | danceList: Dance[];
14 | // 更改这里
15 | danceLoading: boolean; // 更改这里
16 | deactivateDance: () => void; // 更改这里
17 | fetchDanceIndex: () => void; // 更改这里
18 | }
19 |
20 | export const createDanceStore: StateCreator<
21 | // 更改这里
22 | MarketStore,
23 | [['zustand/devtools', never]],
24 | [],
25 | DanceStore // 更改这里
26 | > = (set, get) => {
27 | return {
28 | // 更改这里
29 | activateDance: (identifier) => {
30 | // 更改这里
31 | set({ currentDanceId: identifier }); // 更改这里
32 | },
33 |
34 | currentDanceId: '',
35 |
36 | // 更改这里
37 | danceList: [],
38 | // 更改这里
39 | danceLoading: false,
40 | deactivateDance: () => {
41 | // 更改这里
42 | set({ currentDanceId: undefined }); // 更改这里
43 | },
44 | fetchDanceIndex: async () => {
45 | // 更改这里
46 | set({ danceLoading: true }); // 更改这里
47 | try {
48 | const { dances = [] } = await getDanceIndex();
49 | const { danceList } = get(); // 更改这里
50 | if (!isEqual(danceList, dances)) set({ danceList: dances }); // 更改这里
51 | } catch {
52 | set({ danceList: [] }); // 更改这里
53 | } finally {
54 | set({ danceLoading: false }); // 更改这里
55 | }
56 | },
57 | };
58 | };
59 |
--------------------------------------------------------------------------------
/src/store/market/slices/panel.ts:
--------------------------------------------------------------------------------
1 | import { MarketStore } from '@/store/market';
2 | import { StateCreator } from 'zustand/vanilla';
3 |
4 | export type tabType = 'agent' | 'dance';
5 |
6 | export interface PanelStore {
7 | setTab: (tab: tabType) => void;
8 | tab: tabType;
9 | }
10 |
11 | export const createPanelStore: StateCreator<
12 | MarketStore,
13 | [['zustand/devtools', never]],
14 | [],
15 | PanelStore
16 | > = (set) => {
17 | return {
18 | setTab: (tab) => set({ tab }),
19 | tab: 'agent',
20 | };
21 | };
22 |
--------------------------------------------------------------------------------
/src/store/session/initialState.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_AGENT, V_CHAT_DEFAULT_AGENT_ID } from '@/constants/agent';
2 | import { Session } from '@/types/session';
3 |
4 | const defaultSession: Session = {
5 | agentId: V_CHAT_DEFAULT_AGENT_ID,
6 | messages: [],
7 | };
8 |
9 | const initialState = {
10 | activeId: defaultSession.agentId,
11 | chatLoadingId: undefined,
12 | localAgentList: [DEFAULT_AGENT],
13 | messageInput: '',
14 | sessionList: [defaultSession],
15 | viewerMode: true,
16 | voiceOn: true,
17 | };
18 |
19 | export { initialState };
20 |
--------------------------------------------------------------------------------
/src/store/session/reducers/message.ts:
--------------------------------------------------------------------------------
1 | import { ChatMessage } from '@/types/chat';
2 | import { LLMRoleType } from '@/types/llm';
3 | import { produce } from 'immer';
4 |
5 | export interface AddMessageAction {
6 | payload: {
7 | content: string;
8 | id: string;
9 | role: LLMRoleType;
10 | };
11 | type: 'ADD_MESSAGE';
12 | }
13 |
14 | export interface UpdateMessageAction {
15 | payload: {
16 | id: string;
17 | key: keyof ChatMessage;
18 | value: ChatMessage[keyof ChatMessage];
19 | };
20 | type: 'UPDATE_MESSAGE';
21 | }
22 |
23 | export interface DeleteMessageAction {
24 | payload: {
25 | id: string;
26 | };
27 | type: 'DELETE_MESSAGE';
28 | }
29 |
30 | export type MessageActionType = AddMessageAction | UpdateMessageAction | DeleteMessageAction;
31 |
32 | export const messageReducer = (state: ChatMessage[], action: MessageActionType): ChatMessage[] => {
33 | switch (action.type) {
34 | case 'ADD_MESSAGE': {
35 | return produce(state, (draft) => {
36 | const { role, content, id } = action.payload;
37 | draft.push({
38 | content,
39 | createdAt: Date.now(),
40 | id,
41 | meta: {},
42 | role,
43 | updatedAt: Date.now(),
44 | });
45 | });
46 | }
47 | case 'UPDATE_MESSAGE': {
48 | return produce(state, (draft) => {
49 | const { key, id, value } = action.payload;
50 | const message = draft.find((item) => item.id === id);
51 | if (!message) return;
52 |
53 | // @ts-ignore
54 | message[key] = value;
55 | message.updatedAt = Date.now();
56 | });
57 | }
58 | case 'DELETE_MESSAGE': {
59 | return produce(state, (draft) => {
60 | const { id } = action.payload;
61 | const index = draft.findIndex((item) => item.id === id);
62 | if (index === -1) return;
63 | draft.splice(index, 1);
64 | });
65 | }
66 | default: {
67 | return produce(state, () => []);
68 | }
69 | }
70 | };
71 |
--------------------------------------------------------------------------------
/src/store/theme.ts:
--------------------------------------------------------------------------------
1 | import type { ThemeMode } from 'antd-style';
2 | import { shallow } from 'zustand/shallow';
3 | import { createWithEqualityFn } from 'zustand/traditional';
4 |
5 | interface ThemeStore {
6 | setThemeMode: (themeMode: ThemeMode) => void;
7 | themeMode: ThemeMode;
8 | }
9 |
10 | export const useThemeStore = createWithEqualityFn()(
11 | (set) => ({
12 | setThemeMode: (themeMode: ThemeMode) => set({ themeMode }),
13 | themeMode: 'auto' as ThemeMode,
14 | }),
15 | shallow,
16 | );
17 |
--------------------------------------------------------------------------------
/src/store/viewer.ts:
--------------------------------------------------------------------------------
1 | import { Viewer } from '@/features/vrmViewer/viewer';
2 | import { shallow } from 'zustand/shallow';
3 | import { createWithEqualityFn } from 'zustand/traditional';
4 |
5 | interface ViewerStore {
6 | viewer: Viewer;
7 | }
8 |
9 | const viewer = new Viewer();
10 |
11 | export const useViewerStore = createWithEqualityFn()(
12 | () => ({
13 | viewer,
14 | }),
15 | shallow,
16 | );
17 |
--------------------------------------------------------------------------------
/src/styles/global.ts:
--------------------------------------------------------------------------------
1 | import { css } from 'antd-style';
2 |
3 | export default ({ prefixCls }: { prefixCls: string }) => css`
4 | html,
5 | body,
6 | #__next,
7 | .${prefixCls}-app {
8 | position: relative;
9 | overscroll-behavior: none;
10 | height: 100% !important;
11 | min-height: 100% !important;
12 |
13 | ::-webkit-scrollbar {
14 | display: none;
15 | width: 0;
16 | height: 0;
17 | }
18 | }
19 |
20 | p {
21 | margin-bottom: 0;
22 | }
23 |
24 | @media (max-width: 575px) {
25 | * {
26 | ::-webkit-scrollbar {
27 | display: none;
28 | width: 0;
29 | height: 0;
30 | }
31 | }
32 | }
33 | `;
34 |
--------------------------------------------------------------------------------
/src/styles/index.tsx:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'antd-style';
2 | import global from './global';
3 |
4 | const prefixCls = 'ant';
5 |
6 | export const GlobalStyle = createGlobalStyle(() => [global({ prefixCls })]);
7 |
--------------------------------------------------------------------------------
/src/types/agent.ts:
--------------------------------------------------------------------------------
1 | import { TouchActionConfig } from './touch';
2 | import { TTS } from './tts';
3 |
4 | export interface AgentMeta {
5 | /**
6 | * 头像图片路径
7 | */
8 | avatar: string;
9 | /**
10 | * 封面图片路径
11 | */
12 | cover: string;
13 | /**
14 | * 角色描述
15 | */
16 | description: string;
17 | /**
18 | * 主页地址,一般为 Vroid Hub 的地址
19 | */
20 | homepage: string;
21 | /**
22 | * 模型文件路径
23 | */
24 | model: string;
25 | /**
26 | * 角色名
27 | */
28 | name: string;
29 | /**
30 | * 说明文件
31 | */
32 | readme: string;
33 | }
34 |
35 | export interface Agent {
36 | /**
37 | * 角色 ID,为本地文件目录
38 | */
39 | agentId: string;
40 | /**
41 | * 角色元数据
42 | */
43 | meta: AgentMeta;
44 |
45 | /**
46 | * 角色设定
47 | */
48 | systemRole: string;
49 | /**
50 | * 触摸配置
51 | */
52 | touch: TouchActionConfig;
53 | /**
54 | * 角色 tts 配置文件
55 | */
56 | tts: TTS;
57 | }
58 |
--------------------------------------------------------------------------------
/src/types/api.ts:
--------------------------------------------------------------------------------
1 | export enum ErrorTypeEnum {
2 | API_KEY_MISSING = 'API_KEY_MISSING',
3 | INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
4 | OPENAI_API_ERROR = 'OPENAI_API_ERROR'
5 | }
6 |
7 | export interface APIErrorResponse {
8 | body: any;
9 | errorType: ErrorTypeEnum;
10 | success: boolean;
11 | }
12 |
--------------------------------------------------------------------------------
/src/types/chat.ts:
--------------------------------------------------------------------------------
1 | import { ErrorTypeEnum } from '@/types/api';
2 | import { LLMRoleType } from './llm';
3 |
4 | /**
5 | * 聊天消息错误对象
6 | */
7 | export interface ChatMessageError {
8 | body?: any;
9 | message: string;
10 | type: ErrorTypeEnum;
11 | }
12 |
13 | /**
14 | * 消息元数据,包括头像,背景色,描述,名称等
15 | */
16 | export interface MetaData {
17 | /**
18 | * 角色头像
19 | */
20 | avatar?: string;
21 | /**
22 | * 背景色
23 | */
24 | backgroundColor?: string;
25 | /**
26 | * 描述
27 | */
28 | description?: string;
29 | /**
30 | * 名称
31 | */
32 | title?: string;
33 | }
34 |
35 | /**
36 | * 消息体定义,与 LobeUI ChatList 组件一致
37 | */
38 | export interface ChatMessage {
39 | /**
40 | * 消息内容
41 | */
42 | content: string;
43 | /**
44 | * 创建时间
45 | */
46 | createdAt: number;
47 | /**
48 | * 错误
49 | */
50 | error?: ChatMessageError;
51 | /**
52 | * 额外信息
53 | */
54 | extra?: any;
55 | /**
56 | * 消息id
57 | */
58 | id: string;
59 | /**
60 | * 元数据
61 | */
62 | meta: MetaData;
63 | /**
64 | * 角色
65 | */
66 | role: LLMRoleType;
67 | /**
68 | * 更新时间
69 | */
70 | updatedAt: number;
71 | }
72 |
--------------------------------------------------------------------------------
/src/types/config.ts:
--------------------------------------------------------------------------------
1 | import { Coordinates } from '@dnd-kit/utilities';
2 | import { PrimaryColors } from '@lobehub/ui';
3 |
4 | export type BackgroundEffect = 'glow' | 'none';
5 |
6 | export interface Panel {
7 | /**
8 | * 坐标
9 | */
10 | coordinates: Coordinates;
11 | /**
12 | * 是否打开
13 | */
14 | open: boolean;
15 | }
16 |
17 | export interface PanelConfig {
18 | agent: Panel;
19 | chat: Panel;
20 | config: Panel;
21 | dance: Panel;
22 | live: Panel;
23 | market: Panel;
24 | role: Panel;
25 | }
26 |
27 | export type PanelKey = keyof PanelConfig;
28 |
29 | export interface CommonConfig {
30 | /**
31 | * 背景类型
32 | */
33 | backgroundEffect: BackgroundEffect;
34 | /**
35 | * 主题色
36 | */
37 | primaryColor: PrimaryColors;
38 | }
39 |
40 | export interface OpenAIConfig {
41 | apikey?: string;
42 | endpoint?: string;
43 | model?: string;
44 | }
45 |
46 | export interface LanguageModelConfig {
47 | openAI: OpenAIConfig;
48 | }
49 |
50 | export interface Config extends CommonConfig {
51 | languageModel: LanguageModelConfig;
52 | }
53 |
--------------------------------------------------------------------------------
/src/types/dance.ts:
--------------------------------------------------------------------------------
1 | export interface Dance {
2 | /**
3 | * 音频文件
4 | */
5 | audio: string;
6 | /**
7 | * 封面图片
8 | */
9 | cover: string;
10 | /**
11 | * 舞蹈 ID
12 | */
13 | danceId: string;
14 | /**
15 | * 舞蹈名
16 | */
17 | name: string;
18 | /**
19 | * 说明文件
20 | */
21 | readme: string;
22 | /**
23 | * 舞蹈文件
24 | */
25 | src: string;
26 | /**
27 | * 缩略图
28 | */
29 | thumb: string;
30 | }
31 |
--------------------------------------------------------------------------------
/src/types/llm.ts:
--------------------------------------------------------------------------------
1 | export type LLMRoleType = 'user' | 'assistant';
2 |
3 | export interface LLMMessage {
4 | content: string;
5 | role: LLMRoleType;
6 | }
7 |
--------------------------------------------------------------------------------
/src/types/session.ts:
--------------------------------------------------------------------------------
1 | import { ChatMessage } from './chat';
2 |
3 | export interface Session {
4 | /**
5 | * 会话对应的 Agent ID
6 | */
7 | agentId: string;
8 | /**
9 | * 会话消息列表
10 | */
11 | messages: ChatMessage[];
12 | }
13 |
--------------------------------------------------------------------------------
/src/types/touch.ts:
--------------------------------------------------------------------------------
1 | import { VRMExpressionPresetName } from '@pixiv/three-vrm';
2 | import { TTS } from './tts';
3 |
4 | export const emotions = ['neutral', 'happy', 'angry', 'sad', 'relaxed'] as const;
5 | export type EmotionType = (typeof emotions)[number] | VRMExpressionPresetName;
6 |
7 | export enum TouchAreaEnum {
8 | Arm = 'arm',
9 | Belly = 'belly',
10 | Chest = 'chest',
11 | Head = 'head',
12 | Leg = 'leg'
13 | }
14 |
15 | export interface TouchAction {
16 | emotion: EmotionType;
17 | enabled: boolean;
18 | motion?: string;
19 | text: string;
20 | }
21 |
22 | export interface TouchActionConfig {
23 | [TouchAreaEnum.Head]: TouchAction[];
24 | [TouchAreaEnum.Arm]: TouchAction[];
25 | [TouchAreaEnum.Leg]: TouchAction[];
26 | [TouchAreaEnum.Chest]: TouchAction[];
27 | [TouchAreaEnum.Belly]: TouchAction[];
28 | enabled: boolean;
29 | }
30 |
31 | export type Screenplay = {
32 | emotion: EmotionType;
33 | tts: TTS;
34 | };
35 |
--------------------------------------------------------------------------------
/src/types/tts.ts:
--------------------------------------------------------------------------------
1 | export type TTS_ENGINE = 'microsoft' | 'edge';
2 |
3 | // TODO: 需要根据不同 API 进行适配
4 | export const talkStyles = ['talk', 'happy', 'sad', 'angry', 'fear', 'surprised'] as const;
5 |
6 | export type TalkStyle = (typeof talkStyles)[number];
7 |
8 | export type TTS = {
9 | /**
10 | * TTS 引擎
11 | */
12 | engine?: TTS_ENGINE;
13 | /**
14 | * 多语音标识
15 | */
16 | locale?: string;
17 | /**
18 | * 消息
19 | */
20 | message?: string;
21 | /**
22 | * 音调
23 | */
24 | pitch?: number;
25 | /**
26 | * 速度
27 | */
28 | speed?: number;
29 | /**
30 | * 风格
31 | */
32 | style?: TalkStyle;
33 | /**
34 | * 语音模型
35 | */
36 | voice?: string;
37 | };
38 |
39 | export interface Voice {
40 | DisplayName: string;
41 | DisplayVoiceName: string;
42 | LocalName: string;
43 | PreviewSentence: string;
44 | ShortName: string;
45 | locale: string;
46 | localeZH: string;
47 | }
48 |
--------------------------------------------------------------------------------
/src/utils/cookie.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 |
3 | import { COOKIE_CACHE_DAYS } from '@/constants/common';
4 |
5 | export const setCookie = (key: string, value: string | undefined) => {
6 | const expires = dayjs().add(COOKIE_CACHE_DAYS, 'day').toISOString();
7 |
8 | // eslint-disable-next-line unicorn/no-document-cookie
9 | document.cookie = `${key}=${value};expires=${expires};path=/;`;
10 | };
11 |
--------------------------------------------------------------------------------
/src/utils/fetch.ts:
--------------------------------------------------------------------------------
1 | import { APIErrorResponse, ErrorTypeEnum } from '@/types/api';
2 | import { ChatMessageError } from '@/types/chat';
3 | import { message } from 'antd';
4 |
5 | const getMessageByErrorType = (errorType: ErrorTypeEnum) => {
6 | const errorMap = {
7 | API_KEY_MISSING: 'OpenAI API Key 为空,请添加自定义 OpenAI API Key',
8 | INTERNAL_SERVER_ERROR: '服务器错误,请联系管理员',
9 | OPENAI_API_ERROR: 'OpenAI API 错误,请检查 OpenAI API Key 和 Endpoint 是否正确',
10 | };
11 | return errorMap[errorType] || 'unknown error';
12 | };
13 | /**
14 | * @description: 封装fetch请求,使用流式方法获取数据
15 | */
16 | export const fetchSEE = async (
17 | fetcher: () => Promise,
18 | handler: {
19 | onMessageError?: (error: ChatMessageError) => void;
20 | onMessageUpdate?: (text: string) => void;
21 | },
22 | ) => {
23 | const res = await fetcher();
24 |
25 | if (!res.ok) {
26 | const data = (await res.json()) as APIErrorResponse;
27 |
28 | handler.onMessageError?.({
29 | body: data.body,
30 | message: getMessageByErrorType(data.errorType),
31 | type: data.errorType,
32 | });
33 | message.error(getMessageByErrorType(data.errorType));
34 | return;
35 | }
36 |
37 | const returnRes = res.clone();
38 |
39 | const data = res.body;
40 |
41 | if (!data) return;
42 |
43 | const reader = data.getReader();
44 | const decoder = new TextDecoder('utf8');
45 |
46 | let done = false;
47 |
48 | while (!done) {
49 | const { value, done: doneReading } = await reader.read();
50 | done = doneReading;
51 | const chunkValue = decoder.decode(value, { stream: true });
52 | handler.onMessageUpdate?.(chunkValue);
53 | }
54 |
55 | return returnRes;
56 | };
57 |
--------------------------------------------------------------------------------
/src/utils/keyboard.ts:
--------------------------------------------------------------------------------
1 | import { KeyboardEvent } from 'react';
2 |
3 | import { isMacOS } from './platform';
4 |
5 | export const isCommandPressed = (event: KeyboardEvent) => {
6 | const isMac = isMacOS();
7 |
8 | if (isMac) {
9 | return event.metaKey; // Use metaKey (Command key) on macOS
10 | } else {
11 | return event.ctrlKey; // Use ctrlKey on Windows/Linux
12 | }
13 | };
14 |
--------------------------------------------------------------------------------
/src/utils/platform.ts:
--------------------------------------------------------------------------------
1 | import UAParser from 'ua-parser-js';
2 |
3 | const getPaser = () => {
4 | if (typeof window === 'undefined') {
5 | // @ts-ignore
6 | return new UAParser('Node');
7 | }
8 |
9 | const ua = navigator.userAgent;
10 | // @ts-ignore
11 | return new UAParser(ua);
12 | };
13 |
14 | export const getPlatform = () => {
15 | return getPaser().getOS().name;
16 | };
17 |
18 | export const isMacOS = () => getPlatform() === 'Mac OS';
19 |
--------------------------------------------------------------------------------
/src/utils/three-helpers.ts:
--------------------------------------------------------------------------------
1 | import { Euler, MathUtils, Object3D, Vector3 } from 'three';
2 |
3 | const PI2 = Math.PI * 2;
4 |
5 | export function* transverse(self?: Object3D | null): IterableIterator {
6 | if (!self) return;
7 | const stack: Object3D[] = [self];
8 | const stackIndex = [0];
9 | yield self;
10 | while (stack.length) {
11 | const current = stack.pop()!;
12 | const currentIndex = stackIndex.pop()!;
13 | if (current.children.length <= currentIndex) continue;
14 | stack.push(current, current.children[currentIndex]);
15 | stackIndex.push(currentIndex + 1, 0);
16 | yield current.children[currentIndex];
17 | }
18 | }
19 |
20 | export function clampByRadian(
21 | v: number,
22 | min = Number.NEGATIVE_INFINITY,
23 | max = Number.POSITIVE_INFINITY,
24 | ) {
25 | const hasMin = Number.isFinite(min);
26 | const hasMax = Number.isFinite(max);
27 | if (hasMin && hasMax && min === max) return min;
28 |
29 | const newMin = hasMin ? MathUtils.euclideanModulo(min, PI2) : min;
30 | let newMax = hasMax ? MathUtils.euclideanModulo(max, PI2) : max;
31 | let newV = MathUtils.euclideanModulo(v, PI2);
32 |
33 | if (hasMin && hasMax && newMin >= newMax) {
34 | newMax += PI2;
35 | if (newV < Math.PI) newV += PI2;
36 | }
37 | if (hasMax && newV > newMax) newV = newMax;
38 | else if (hasMin && newV < newMin) newV = newMin;
39 | return MathUtils.euclideanModulo(newV, PI2);
40 | }
41 |
42 | export function centerOfDescendant(self: Object3D) {
43 | const sum = new Vector3();
44 | const temp = new Vector3();
45 | let i = 0;
46 | for (const current of transverse(self)) {
47 | temp.copy(current.position);
48 | let { parent } = current.parent!;
49 | while (parent) {
50 | temp.applyQuaternion(parent.quaternion).add(parent.position);
51 | if (parent === self) break;
52 | parent = parent.parent;
53 | }
54 | sum.add(temp);
55 | i++;
56 | }
57 | return sum.divideScalar(i);
58 | }
59 |
60 | export function clampVector3ByRadian(v: Vector3 | Euler, min?: Vector3, max?: Vector3) {
61 | return v.set(
62 | clampByRadian(v.x, min?.x, max?.x),
63 | clampByRadian(v.y, min?.y, max?.y),
64 | clampByRadian(v.z, min?.z, max?.z),
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/utils/wait.ts:
--------------------------------------------------------------------------------
1 | export const wait = async (ms: number) =>
2 | new Promise((resolve) => {
3 | setTimeout(resolve, ms);
4 | return;
5 | });
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------