├── .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 | 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 |
14 | 15 |
16 |
17 | 18 | 19 | 20 | 21 |
22 |
23 | 24 |
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 |
29 | {name} 30 | 31 | 32 | 主页 33 | 34 | 35 |
36 |
{description}
37 |
38 | {actions} 39 |
40 |
41 |
42 |
{systemRole}
43 |
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 |
28 |
{readme}
29 |
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 |