├── src
├── renderer
│ ├── src
│ │ ├── components
│ │ │ ├── canvas
│ │ │ │ ├── canvas.tsx
│ │ │ │ ├── subtitle.tsx
│ │ │ │ ├── ws-status.tsx
│ │ │ │ ├── background.tsx
│ │ │ │ └── canvas-styles.tsx
│ │ │ ├── sidebar
│ │ │ │ ├── invite-dialog.tsx
│ │ │ │ ├── setting
│ │ │ │ │ ├── tts.tsx
│ │ │ │ │ ├── agent.tsx
│ │ │ │ │ ├── live2d.tsx
│ │ │ │ │ ├── about.tsx
│ │ │ │ │ └── asr.tsx
│ │ │ │ ├── bottom-tab.tsx
│ │ │ │ ├── chat-bubble.tsx
│ │ │ │ ├── browser-panel.tsx
│ │ │ │ ├── screen-panel.tsx
│ │ │ │ └── camera-panel.tsx
│ │ │ ├── ui
│ │ │ │ ├── provider.tsx
│ │ │ │ ├── close-button.tsx
│ │ │ │ ├── number-input.tsx
│ │ │ │ ├── radio.tsx
│ │ │ │ ├── checkbox.tsx
│ │ │ │ ├── field.tsx
│ │ │ │ ├── tag.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── toaster.tsx
│ │ │ │ ├── tooltip.tsx
│ │ │ │ ├── switch.tsx
│ │ │ │ ├── alert.tsx
│ │ │ │ ├── input-group.tsx
│ │ │ │ ├── popover.tsx
│ │ │ │ ├── drawer.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── color-mode.tsx
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── slider.tsx
│ │ │ │ └── clipboard.tsx
│ │ │ ├── footer
│ │ │ │ ├── ai-state-indicator.tsx
│ │ │ │ └── footer-styles.tsx
│ │ │ ├── electron
│ │ │ │ ├── electron-style.tsx
│ │ │ │ └── title-bar.tsx
│ │ │ └── phone-call
│ │ │ │ └── phone-call-styles.ts
│ │ ├── hooks
│ │ │ ├── sidebar
│ │ │ │ ├── use-invite-dialog.ts
│ │ │ │ ├── use-chat-history-panel.ts
│ │ │ │ ├── use-capture-screen.ts
│ │ │ │ ├── use-camera-panel.ts
│ │ │ │ ├── use-sidebar.ts
│ │ │ │ ├── setting
│ │ │ │ │ ├── use-live2d-settings.ts
│ │ │ │ │ ├── use-agent-settings.ts
│ │ │ │ │ └── use-asr-settings.ts
│ │ │ │ ├── use-group-drawer.tsx
│ │ │ │ └── use-history-drawer.ts
│ │ │ ├── canvas
│ │ │ │ ├── use-background.ts
│ │ │ │ ├── use-subtitle-display.ts
│ │ │ │ ├── use-ws-status.ts
│ │ │ │ └── use-live2d-expression.ts
│ │ │ ├── utils
│ │ │ │ ├── use-mic-toggle.ts
│ │ │ │ ├── use-trigger-speak.ts
│ │ │ │ ├── use-force-ignore-mouse.ts
│ │ │ │ ├── use-local-storage.ts
│ │ │ │ ├── use-send-audio.tsx
│ │ │ │ ├── use-interrupt.ts
│ │ │ │ └── use-switch-character.tsx
│ │ │ ├── footer
│ │ │ │ ├── use-footer.ts
│ │ │ │ └── use-text-input.tsx
│ │ │ └── electron
│ │ │ │ ├── use-input-subtitle.ts
│ │ │ │ └── use-draggable.ts
│ │ ├── vite-env.d.ts
│ │ ├── env.d.ts
│ │ ├── electron.d.ts
│ │ ├── index.css
│ │ ├── context
│ │ │ ├── group-context.tsx
│ │ │ ├── browser-context.tsx
│ │ │ ├── subtitle-context.tsx
│ │ │ ├── character-config-context.tsx
│ │ │ ├── websocket-context.tsx
│ │ │ ├── proactive-speak-context.tsx
│ │ │ ├── screen-capture-context.tsx
│ │ │ └── mode-context.tsx
│ │ ├── i18n.ts
│ │ ├── utils
│ │ │ ├── task-queue.ts
│ │ │ └── audio-manager.ts
│ │ └── main.tsx
│ ├── WebSDK
│ │ ├── Framework
│ │ │ ├── .gitattributes
│ │ │ ├── .vscode
│ │ │ │ ├── settings.json
│ │ │ │ ├── extensions.json
│ │ │ │ └── tasks.json
│ │ │ ├── .editorconfig
│ │ │ ├── .gitignore
│ │ │ ├── package.json
│ │ │ ├── tsconfig.json
│ │ │ ├── src
│ │ │ │ ├── cubismframeworkconfig.ts
│ │ │ │ ├── icubismallcator.ts
│ │ │ │ ├── type
│ │ │ │ │ ├── csmrectf.ts
│ │ │ │ │ └── csmstring.ts
│ │ │ │ ├── id
│ │ │ │ │ ├── cubismid.ts
│ │ │ │ │ └── cubismidmanager.ts
│ │ │ │ ├── model
│ │ │ │ │ └── cubismmodeluserdatajson.ts
│ │ │ │ ├── utils
│ │ │ │ │ ├── cubismjsonextension.ts
│ │ │ │ │ └── cubismstring.ts
│ │ │ │ └── motion
│ │ │ │ │ └── cubismmotionmanager.ts
│ │ │ ├── LICENSE.md
│ │ │ └── .eslintrc.yml
│ │ ├── Core
│ │ │ ├── RedistributableFiles.txt
│ │ │ ├── LICENSE.md
│ │ │ ├── README.ja.md
│ │ │ └── README.md
│ │ └── src
│ │ │ ├── lappglmanager.ts
│ │ │ ├── lapppal.ts
│ │ │ └── lappdefine.ts
│ ├── public
│ │ └── favicon.ico
│ └── index.html
└── preload
│ └── index.d.ts
├── .gitattributes
├── resources
├── icon.icns
├── icon.ico
└── icon.png
├── .eslintignore
├── tsconfig.json
├── README.md
├── tsconfig.web.json
├── tsconfig.app.json
├── tsconfig.node.json
├── .gitignore
├── .eslintrc.js
├── i18next-scanner.config.js
├── docs
└── i18n-usage.md
├── electron-builder.yml
├── LICENSE
├── electron.vite.config.ts
├── vite.config.ts
├── package.json
└── CLAUDE.md
/src/renderer/src/components/canvas/canvas.tsx:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/invite-dialog.tsx:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/sidebar/use-invite-dialog.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/src/renderer/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/resources/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/K-Jadeja/Open-LLM-VTuber-Web/main/resources/icon.icns
--------------------------------------------------------------------------------
/resources/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/K-Jadeja/Open-LLM-VTuber-Web/main/resources/icon.ico
--------------------------------------------------------------------------------
/resources/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/K-Jadeja/Open-LLM-VTuber-Web/main/resources/icon.png
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | src/renderer/WebSDK/**/*.ts
2 | src/renderer/MotionSync/**/*.js
3 | src/renderer/MotionSync/**/*.ts
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "./node_modules/typescript/lib"
3 | }
4 |
--------------------------------------------------------------------------------
/src/renderer/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/K-Jadeja/Open-LLM-VTuber-Web/main/src/renderer/public/favicon.ico
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "editorconfig.editorconfig"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/setting/tts.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@chakra-ui/react';
2 |
3 | function TTS(): JSX.Element {
4 | return ;
5 | }
6 |
7 | export default TTS;
8 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_size = 2
7 | indent_style = space
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/src/renderer/src/env.d.ts:
--------------------------------------------------------------------------------
1 | interface Window {
2 | api?: {
3 | setIgnoreMouseEvents: (ignore: boolean) => void
4 | showContextMenu?: () => void
5 | onModeChanged: (callback: (mode: string) => void) => void
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Core/RedistributableFiles.txt:
--------------------------------------------------------------------------------
1 | The following is a list of files available for redistribution
2 | under the terms of the Live2D Proprietary Software License Agreement:
3 |
4 | - live2dcubismcore.d.ts
5 | - live2dcubismcore.js
6 | - live2dcubismcore.min.js
7 |
--------------------------------------------------------------------------------
/src/renderer/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Open-LLM-Vtuber
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/.gitignore:
--------------------------------------------------------------------------------
1 | # Package files
2 | node_modules/
3 | # Build files
4 | dist/
5 | # Other files
6 | .vs/
7 | .idea/
8 | *.iml
9 | .DS_Store
10 | # Exclude some VSCode setting files.
11 | .vscode/*
12 | !/.vscode/extensions.json
13 | !/.vscode/settings.json
14 | !/.vscode/tasks.json
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }],
4 | "compilerOptions": {
5 | "noImplicitAny": false,
6 | "strictPropertyInitialization": false,
7 | "strict": false
8 | },
9 | "exclude": [
10 |
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ChakraProvider, defaultSystem } from '@chakra-ui/react';
4 | import {
5 | ColorModeProvider,
6 | type ColorModeProviderProps,
7 | } from './color-mode';
8 |
9 | export function Provider(props: ColorModeProviderProps) {
10 | return (
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/canvas/use-background.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useBgUrl } from '@/context/bgurl-context';
3 |
4 | export const useBackground = () => {
5 | const context = useBgUrl();
6 |
7 | const backgroundUrl = useMemo(() => {
8 | if (!context) return null;
9 | return context.backgroundUrl;
10 | }, [context?.backgroundUrl]);
11 |
12 | return {
13 | backgroundUrl,
14 | isLoaded: !!context,
15 | };
16 | };
17 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/canvas/use-subtitle-display.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useSubtitle } from '@/context/subtitle-context';
3 |
4 | export const useSubtitleDisplay = () => {
5 | const context = useSubtitle();
6 |
7 | const subtitleText = useMemo(() => {
8 | if (!context) return null;
9 | return context.subtitleText;
10 | }, [context?.subtitleText]);
11 |
12 | return {
13 | subtitleText,
14 | isLoaded: !!context,
15 | };
16 | };
17 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Core/LICENSE.md:
--------------------------------------------------------------------------------
1 | ## Live2D Proprietary Software License
2 |
3 | Live2D Cubism Core is available under Live2D Proprietary Software License.
4 |
5 | * [Live2D Proprietary Software License Agreement](https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_en.html)
6 | * [Live2D Proprietary Software 使用許諾契約書](https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_jp.html)
7 | * [Live2D Proprietary Software 使用授权协议](https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_cn.html)
8 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/close-button.tsx:
--------------------------------------------------------------------------------
1 | import type { ButtonProps } from '@chakra-ui/react';
2 | import { IconButton as ChakraIconButton } from '@chakra-ui/react';
3 | import * as React from 'react';
4 | import { LuX } from 'react-icons/lu';
5 |
6 | export type CloseButtonProps = ButtonProps
7 |
8 | export const CloseButton = React.forwardRef<
9 | HTMLButtonElement,
10 | CloseButtonProps
11 | >((props, ref) => (
12 |
13 | {props.children ?? }
14 |
15 | ));
16 |
--------------------------------------------------------------------------------
/src/renderer/src/electron.d.ts:
--------------------------------------------------------------------------------
1 | import { IpcRenderer } from 'electron';
2 |
3 | declare global {
4 | interface Window {
5 | // Define the structure of the API exposed by your preload script
6 | electron?: {
7 | ipcRenderer: IpcRenderer;
8 | process: {
9 | platform: string;
10 | };
11 | // Add other methods or properties exposed by preload script if any
12 | };
13 | // Add other custom window properties if needed
14 | api?: unknown; // Keep existing check if needed
15 | }
16 | }
17 |
18 | // Export {} is needed to make this a module
19 | export {};
20 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "build": "tsc",
5 | "test": "tsc --noEmit",
6 | "lint": "eslint src --ext .ts",
7 | "lint:fix": "eslint src --ext .ts --fix",
8 | "clean": "rimraf dist"
9 | },
10 | "devDependencies": {
11 | "@typescript-eslint/eslint-plugin": "^7.2.0",
12 | "@typescript-eslint/parser": "^7.2.0",
13 | "eslint": "^8.57.0",
14 | "eslint-config-prettier": "^9.1.0",
15 | "eslint-plugin-prettier": "^5.1.3",
16 | "prettier": "^3.2.5",
17 | "rimraf": "^5.0.5",
18 | "typescript": "^5.4.2"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/sidebar/use-chat-history-panel.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useCallback } from 'react';
2 | import { useChatHistory } from '@/context/chat-history-context';
3 |
4 | export function useChatHistoryPanel() {
5 | const { messages } = useChatHistory();
6 | const messageListRef = useRef(null);
7 |
8 | const handleMessageUpdate = useCallback(() => {
9 | if (messageListRef.current) {
10 | messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
11 | }
12 | }, []);
13 |
14 | return {
15 | messages,
16 | messageListRef,
17 | handleMessageUpdate,
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/src/renderer/src/components/footer/ai-state-indicator.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Text } from '@chakra-ui/react';
2 | import { useTranslation } from 'react-i18next';
3 | import { useAiState } from '@/context/ai-state-context';
4 | import { footerStyles } from './footer-styles';
5 |
6 | function AIStateIndicator(): JSX.Element {
7 | const { t } = useTranslation();
8 | const { aiState } = useAiState();
9 | const styles = footerStyles.aiIndicator;
10 |
11 | return (
12 |
13 | {t(`aiState.${aiState}`)}
14 |
15 | );
16 | }
17 |
18 | export default AIStateIndicator;
19 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/utils/use-mic-toggle.ts:
--------------------------------------------------------------------------------
1 | import { useVAD } from '@/context/vad-context';
2 | import { useAiState } from '@/context/ai-state-context';
3 |
4 | export function useMicToggle() {
5 | const { startMic, stopMic, micOn } = useVAD();
6 | const { aiState, setAiState } = useAiState();
7 |
8 | const handleMicToggle = async (): Promise => {
9 | if (micOn) {
10 | stopMic();
11 | if (aiState === 'listening') {
12 | setAiState('idle');
13 | }
14 | } else {
15 | await startMic();
16 | }
17 | };
18 |
19 | return {
20 | handleMicToggle,
21 | micOn,
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "moduleResolution": "node",
5 | "esModuleInterop": true,
6 | "experimentalDecorators": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "outDir": "./dist",
9 | "declaration": true,
10 | "declarationMap": true,
11 | "sourceMap": true,
12 | "emitDecoratorMetadata": true,
13 | "noImplicitAny": true,
14 | "useUnknownInCatchVariables": true
15 | },
16 | "include": [
17 | "src/**/*.ts",
18 | "../Core/*.ts"
19 | ],
20 | "exclude": [
21 | "node_modules",
22 | "dist"
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Open LLM Vtuber
2 |
3 | An Electron application with React and TypeScript
4 |
5 | ## Recommended IDE Setup
6 |
7 | - [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
8 |
9 | ## Project Setup
10 |
11 | ### Install
12 |
13 | ```bash
14 | $ npm install
15 | ```
16 |
17 | ### Development
18 |
19 | ```bash
20 | $ npm run dev
21 | ```
22 |
23 | ### Build
24 |
25 | ```bash
26 | # For windows
27 | $ npm run build:win
28 |
29 | # For macOS
30 | $ npm run build:mac
31 |
32 | # For Linux
33 | $ npm run build:linux
34 | ```
35 |
--------------------------------------------------------------------------------
/tsconfig.web.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.app.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.web.tsbuildinfo",
6 | "baseUrl": ".",
7 | "paths": {
8 | "@/*": ["src/renderer/src/*"],
9 | "@framework/*": ["src/renderer/WebSDK/Framework/src/*"],
10 | "@cubismsdksamples/*": ["src/renderer/WebSDK/src/*"],
11 | "@motionsyncframework/*": ["src/renderer/WebSDK/src/Framework/src/*"],
12 | "@motionsync/*": ["src/renderer/MotionSync/motionsync/*"]
13 | }
14 | },
15 | "include": [
16 | "src/renderer/**/*.ts",
17 | "src/renderer/**/*.tsx",
18 | "src/renderer/**/*.d.ts",
19 | "src/renderer/WebSDK/src/**/*"
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/utils/use-trigger-speak.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { useWebSocket } from '@/context/websocket-context';
3 | import { useMediaCapture } from './use-media-capture';
4 |
5 | export function useTriggerSpeak() {
6 | const { sendMessage } = useWebSocket();
7 | const { captureAllMedia } = useMediaCapture();
8 |
9 | const sendTriggerSignal = useCallback(
10 | async (actualIdleTime: number) => {
11 | const images = await captureAllMedia();
12 | sendMessage({
13 | type: "ai-speak-signal",
14 | idle_time: actualIdleTime,
15 | images,
16 | });
17 | },
18 | [sendMessage, captureAllMedia],
19 | );
20 |
21 | return {
22 | sendTriggerSignal,
23 | };
24 | }
25 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Core/README.ja.md:
--------------------------------------------------------------------------------
1 | [English](README.md) / [日本語](README.ja.md)
2 |
3 | ---
4 |
5 | # Live2D Cubism Core
6 |
7 | このフォルダーには、JavaScriptまたはTypeScriptアプリケーションを開発するためのコアライブラリファイルが含まれています。
8 |
9 |
10 | ## ファイルリスト
11 |
12 | ### live2dcubismcore.d.ts
13 |
14 | このファイルには、`live2dcubismcore.js`に関するTypeScriptの型情報が含まれています。
15 | TypeScriptで開発する場合は、このファイルを`live2dcubismcore.js`とともに使用してください。
16 |
17 | ### live2dcubismcore.js
18 |
19 | このファイルには、CubismCoreの機能といくつかのラッパーが含まれています。
20 | JavaScriptで開発する場合は、このファイルを使用してください。
21 |
22 | ### live2dcubismcore.js.map
23 |
24 | このファイルは、`live2dcubismcore.d.ts`と`live2dcubismcore.js`の間のソースマップです。
25 | デバッグ時にこのファイルを使用します。
26 |
27 | ### live2dcubismcore.min.js
28 |
29 | このファイルは、`live2dcubismcore.js`のminify版です。
30 | このファイルを本番環境で使用します。
31 |
--------------------------------------------------------------------------------
/src/renderer/src/index.css:
--------------------------------------------------------------------------------
1 | body, html {
2 | margin: 0;
3 | padding: 0;
4 | overflow: visible;
5 | background-color: transparent;
6 | }
7 |
8 | /* Phone Call UI Animations */
9 | @keyframes pulse {
10 | 0%, 100% {
11 | opacity: 1;
12 | }
13 | 50% {
14 | opacity: 0.5;
15 | }
16 | }
17 |
18 | /* Mobile viewport adjustments */
19 | @supports(env(safe-area-inset-bottom)) {
20 | .phone-call-controls {
21 | padding-bottom: calc(2rem + env(safe-area-inset-bottom));
22 | }
23 | }
24 |
25 | /* Mobile-first responsive design */
26 | @media (max-width: 768px) {
27 | body {
28 | user-select: none;
29 | -webkit-user-select: none;
30 | -webkit-touch-callout: none;
31 | -webkit-tap-highlight-color: transparent;
32 | }
33 | }
34 |
35 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "npm",
6 | "script": "install",
7 | "problemMatcher": []
8 | },
9 | {
10 | "type": "npm",
11 | "script": "build",
12 | "group": "build",
13 | "problemMatcher": []
14 | },
15 | {
16 | "type": "npm",
17 | "script": "test",
18 | "group": "test",
19 | "problemMatcher": []
20 | },
21 | {
22 | "type": "npm",
23 | "script": "lint",
24 | "group": "test",
25 | "problemMatcher": []
26 | },
27 | {
28 | "type": "npm",
29 | "script": "lint:fix",
30 | "group": "test",
31 | "problemMatcher": []
32 | },
33 | {
34 | "type": "npm",
35 | "script": "clear",
36 | "problemMatcher": []
37 | }
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Core/README.md:
--------------------------------------------------------------------------------
1 | [English](README.md) / [日本語](README.ja.md)
2 |
3 | ---
4 |
5 | # Live2D Cubism Core
6 |
7 | This folder contains core library files for developing JavaScript or TypeScript applications.
8 |
9 |
10 | ## File List
11 |
12 | ### live2dcubismcore.d.ts
13 |
14 | This file contains typescript type information about `live2dcubismcore.js`.
15 | Use this file with `live2dcubismcore.js` when developing with TypeScript.
16 |
17 | ### live2dcubismcore.js
18 |
19 | This file contains Cubism Core features and some wrapper features.
20 | Use this file when developing with JavaScript.
21 |
22 | ### live2dcubismcore.js.map
23 |
24 | This file is the source map between `live2dcubismcore.d.ts` and `live2dcubismcore.js`.
25 | Use this file when debugging.
26 |
27 | ### live2dcubismcore.min.js
28 |
29 | This file is the minified version of `live2dcubismcore.js`.
30 | Use this file in production.
31 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": [
8 | "ES2020",
9 | "DOM",
10 | "DOM.Iterable"
11 | ],
12 | "module": "ESNext",
13 | "skipLibCheck": true,
14 | /* Bundler mode */
15 | "moduleResolution": "bundler",
16 | "allowImportingTsExtensions": true,
17 | "isolatedModules": true,
18 | "moduleDetection": "force",
19 | "noEmit": true,
20 | "jsx": "react-jsx",
21 | /* Linting */
22 | "strict": true,
23 | "noUnusedLocals": true,
24 | "noUnusedParameters": true,
25 | "noFallthroughCasesInSwitch": true,
26 | "baseUrl": ".",
27 | "paths": {
28 | "@/*": [
29 | "./src/*"
30 | ]
31 | }
32 | },
33 | "include": [
34 | "src"
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/number-input.tsx:
--------------------------------------------------------------------------------
1 | import { NumberInput as ChakraNumberInput } from '@chakra-ui/react';
2 | import * as React from 'react';
3 |
4 | export interface NumberInputProps extends ChakraNumberInput.RootProps {}
5 |
6 | export const NumberInputRoot = React.forwardRef<
7 | HTMLDivElement,
8 | NumberInputProps
9 | >((props, ref) => {
10 | const { children, ...rest } = props;
11 | return (
12 |
13 | {children}
14 |
15 |
16 |
17 |
18 |
19 | );
20 | });
21 |
22 | export const NumberInputField = ChakraNumberInput.Input;
23 | export const NumberInputScrubber = ChakraNumberInput.Scrubber;
24 | export const NumberInputLabel = ChakraNumberInput.Label;
25 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/radio.tsx:
--------------------------------------------------------------------------------
1 | import { RadioGroup as ChakraRadioGroup } from '@chakra-ui/react';
2 | import * as React from 'react';
3 |
4 | export interface RadioProps extends ChakraRadioGroup.ItemProps {
5 | rootRef?: React.Ref
6 | inputProps?: React.InputHTMLAttributes
7 | }
8 |
9 | export const Radio = React.forwardRef(
10 | (props, ref) => {
11 | const {
12 | children, inputProps, rootRef, ...rest
13 | } = props;
14 | return (
15 |
16 |
17 |
18 | {children && (
19 | {children}
20 | )}
21 |
22 | );
23 | },
24 | );
25 |
26 | export const RadioGroup = ChakraRadioGroup.Root;
27 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox as ChakraCheckbox } from '@chakra-ui/react';
2 | import * as React from 'react';
3 |
4 | export interface CheckboxProps extends ChakraCheckbox.RootProps {
5 | icon?: React.ReactNode
6 | inputProps?: React.InputHTMLAttributes
7 | rootRef?: React.Ref
8 | }
9 |
10 | export const Checkbox = React.forwardRef(
11 | (props, ref) => {
12 | const {
13 | icon, children, inputProps, rootRef, ...rest
14 | } = props;
15 | return (
16 |
17 |
18 |
19 | {icon || }
20 |
21 | {children != null && (
22 | {children}
23 | )}
24 |
25 | );
26 | },
27 | );
28 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/utils/use-force-ignore-mouse.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | // Define the state store interface
4 | interface ForceIgnoreMouseState {
5 | // Whether mouse events are forcibly ignored
6 | forceIgnoreMouse: boolean;
7 | // Set the force ignore mouse state
8 | setForceIgnoreMouse: (forceIgnore: boolean) => void;
9 | }
10 |
11 | // Create a global store for force ignore mouse state
12 | const useForceIgnoreMouseStore = create((set) => ({
13 | forceIgnoreMouse: false,
14 | setForceIgnoreMouse: (forceIgnore) => set({ forceIgnoreMouse: forceIgnore }),
15 | }));
16 |
17 | /**
18 | * Hook to access and manage force ignore mouse state
19 | * This is used to enable/disable mouse interaction with the model in pet mode
20 | */
21 | export function useForceIgnoreMouse() {
22 | const { forceIgnoreMouse, setForceIgnoreMouse } = useForceIgnoreMouseStore();
23 |
24 | return {
25 | forceIgnoreMouse,
26 | setForceIgnoreMouse,
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/src/cubismframeworkconfig.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | //========================================================
9 | // ログ出力関数の設定
10 | //========================================================
11 |
12 | //---------- ログ出力レベル 選択項目 定義 ----------
13 | // 詳細ログ出力設定
14 | export const CSM_LOG_LEVEL_VERBOSE = 0;
15 | // デバッグログ出力設定
16 | export const CSM_LOG_LEVEL_DEBUG = 1;
17 | // Infoログ出力設定
18 | export const CSM_LOG_LEVEL_INFO = 2;
19 | // 警告ログ出力設定
20 | export const CSM_LOG_LEVEL_WARNING = 3;
21 | // エラーログ出力設定
22 | export const CSM_LOG_LEVEL_ERROR = 4;
23 | // ログ出力オフ設定
24 | export const CSM_LOG_LEVEL_OFF = 5;
25 |
26 | /**
27 | * ログ出力レベル設定。
28 | *
29 | * 強制的にログ出力レベルを変える時に定義を有効にする。
30 | * CSM_LOG_LEVEL_VERBOSE ~ CSM_LOG_LEVEL_OFF を選択する。
31 | */
32 | export const CSM_LOG_LEVEL: number = CSM_LOG_LEVEL_VERBOSE;
33 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/sidebar/use-capture-screen.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useState, useEffect } from 'react';
2 | import { useScreenCaptureContext } from '@/context/screen-capture-context';
3 |
4 | export function useCaptureScreen() {
5 | const videoRef = useRef(null);
6 | const [isHovering, setIsHovering] = useState(false);
7 | const { stream, isStreaming, error, startCapture, stopCapture } = useScreenCaptureContext();
8 |
9 | const toggleCapture = () => {
10 | if (isStreaming) {
11 | stopCapture();
12 | } else {
13 | startCapture();
14 | }
15 | };
16 |
17 | const handleMouseEnter = () => setIsHovering(true);
18 | const handleMouseLeave = () => setIsHovering(false);
19 |
20 | useEffect(() => {
21 | if (videoRef.current) {
22 | videoRef.current.srcObject = stream;
23 | }
24 | }, [stream]);
25 |
26 | return {
27 | videoRef,
28 | error,
29 | isHovering,
30 | isStreaming,
31 | stream,
32 | toggleCapture,
33 | handleMouseEnter,
34 | handleMouseLeave,
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5 | "target": "ES2022",
6 | "lib": [
7 | "ES2023"
8 | ],
9 | "module": "ESNext",
10 | "skipLibCheck": true,
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "isolatedModules": true,
15 | "moduleDetection": "force",
16 | "noEmit": true,
17 | /* Linting */
18 | "strict": false,
19 | // "noUnusedLocals": false,
20 | // "noUnusedParameters": false,
21 | // "noFallthroughCasesInSwitch": false,
22 | // "allowUnusedLabels": true,
23 | // "allowUnreachableCode": true,
24 | // "noImplicitAny": false,
25 | // "noImplicitThis": false,
26 | // "noImplicitReturns": false,
27 | // "noPropertyAccessFromIndexSignature": false,
28 | // "noUncheckedIndexedAccess": false,
29 | // "exactOptionalPropertyTypes": false,
30 | // "noImplicitOverride": false
31 | },
32 | "include": [
33 | "vite.config.ts"
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/src/renderer/src/components/canvas/subtitle.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Text } from '@chakra-ui/react';
2 | import { memo } from 'react';
3 | import { canvasStyles } from './canvas-styles';
4 | import { useSubtitleDisplay } from '@/hooks/canvas/use-subtitle-display';
5 | import { useSubtitle } from '@/context/subtitle-context';
6 |
7 | // Type definitions
8 | interface SubtitleTextProps {
9 | text: string
10 | }
11 |
12 | // Reusable components
13 | const SubtitleText = memo(({ text }: SubtitleTextProps) => (
14 |
15 | {text}
16 |
17 | ));
18 |
19 | SubtitleText.displayName = 'SubtitleText';
20 |
21 | // Main component
22 | const Subtitle = memo((): JSX.Element | null => {
23 | const { subtitleText, isLoaded } = useSubtitleDisplay();
24 | const { showSubtitle } = useSubtitle();
25 |
26 | if (!isLoaded || !subtitleText || !showSubtitle) return null;
27 |
28 | return (
29 |
30 |
31 |
32 | );
33 | });
34 |
35 | Subtitle.displayName = 'Subtitle';
36 |
37 | export default Subtitle;
38 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/field.tsx:
--------------------------------------------------------------------------------
1 | import { Field as ChakraField } from '@chakra-ui/react';
2 | import * as React from 'react';
3 |
4 | export interface FieldProps extends Omit {
5 | label?: React.ReactNode
6 | helperText?: React.ReactNode
7 | errorText?: React.ReactNode
8 | optionalText?: React.ReactNode
9 | }
10 |
11 | export const Field = React.forwardRef(
12 | (props, ref) => {
13 | const {
14 | label, children, helperText, errorText, optionalText, ...rest
15 | } = props;
16 | return (
17 |
18 | {label && (
19 |
20 | {label}
21 |
22 |
23 | )}
24 | {children}
25 | {helperText && (
26 | {helperText}
27 | )}
28 | {errorText && (
29 | {errorText}
30 | )}
31 |
32 | );
33 | },
34 | );
35 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/utils/use-local-storage.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | export function useLocalStorage(
4 | key: string,
5 | initialValue: T,
6 | options?: {
7 | filter?: (value: T) => T
8 | },
9 | ) {
10 | const [storedValue, setStoredValue] = useState(() => {
11 | try {
12 | const item = window.localStorage.getItem(key);
13 | const parsedValue = item ? JSON.parse(item) : initialValue;
14 | return parsedValue;
15 | } catch (error) {
16 | console.error(`Error reading localStorage key "${key}":`, error);
17 | return initialValue;
18 | }
19 | });
20 |
21 | const setValue = (value: T | ((val: T) => T)) => {
22 | try {
23 | const valueToStore = value instanceof Function ? value(storedValue) : value;
24 | const filteredValue = options?.filter ? options.filter(valueToStore) : valueToStore;
25 | setStoredValue(valueToStore);
26 | window.localStorage.setItem(key, JSON.stringify(filteredValue));
27 | } catch (error) {
28 | console.error(`Error setting localStorage key "${key}":`, error);
29 | }
30 | };
31 |
32 | return [storedValue, setValue] as const;
33 | }
34 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/tag.tsx:
--------------------------------------------------------------------------------
1 | import { Tag as ChakraTag } from '@chakra-ui/react';
2 | import * as React from 'react';
3 |
4 | export interface TagProps extends ChakraTag.RootProps {
5 | startElement?: React.ReactNode
6 | endElement?: React.ReactNode
7 | onClose?: VoidFunction
8 | closable?: boolean
9 | }
10 |
11 | export const Tag = React.forwardRef(
12 | (props, ref) => {
13 | const {
14 | startElement,
15 | endElement,
16 | onClose,
17 | closable = !!onClose,
18 | children,
19 | ...rest
20 | } = props;
21 |
22 | return (
23 |
24 | {startElement && (
25 | {startElement}
26 | )}
27 | {children}
28 | {endElement && (
29 | {endElement}
30 | )}
31 | {closable && (
32 |
33 |
34 |
35 | )}
36 |
37 | );
38 | },
39 | );
40 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/utils/use-send-audio.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { useWebSocket } from "@/context/websocket-context";
3 | import { useMediaCapture } from "@/hooks/utils/use-media-capture";
4 |
5 | export function useSendAudio() {
6 | const { sendMessage } = useWebSocket();
7 | const { captureAllMedia } = useMediaCapture();
8 |
9 | const sendAudioPartition = useCallback(
10 | async (audio: Float32Array) => {
11 | const chunkSize = 4096;
12 |
13 | // Send the audio data in chunks
14 | for (let index = 0; index < audio.length; index += chunkSize) {
15 | const endIndex = Math.min(index + chunkSize, audio.length);
16 | const chunk = audio.slice(index, endIndex);
17 | sendMessage({
18 | type: "mic-audio-data",
19 | audio: Array.from(chunk),
20 | // Only send images with first chunk
21 | });
22 | }
23 |
24 | // Send end signal after all chunks
25 | const images = await captureAllMedia();
26 | sendMessage({ type: "mic-audio-end", images });
27 | },
28 | [sendMessage, captureAllMedia],
29 | );
30 |
31 | return {
32 | sendAudioPartition,
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/src/preload/index.d.ts:
--------------------------------------------------------------------------------
1 | import { ElectronAPI } from '@electron-toolkit/preload';
2 |
3 | declare global {
4 | interface Window {
5 | electron: ElectronAPI
6 | api: {
7 | setIgnoreMouseEvents: (ignore: boolean) => void
8 | toggleForceIgnoreMouse: () => void
9 | onForceIgnoreMouseChanged: (callback: (isForced: boolean) => void) => void
10 | onModeChanged: (callback: (mode: 'pet' | 'window') => void) => void
11 | showContextMenu: (x: number, y: number) => void
12 | onMicToggle: (callback: () => void) => void
13 | onInterrupt: (callback: () => void) => void
14 | updateComponentHover: (componentId: string, isHovering: boolean) => void
15 | onToggleInputSubtitle: (callback: () => void) => void
16 | onToggleScrollToResize: (callback: () => void) => void
17 | onSwitchCharacter: (callback: (filename: string) => void) => void
18 | setMode: (mode: 'window' | 'pet') => void
19 | getConfigFiles: () => Promise
20 | updateConfigFiles: (files: any[]) => void
21 | }
22 | }
23 | }
24 |
25 | interface IpcRenderer {
26 | on(channel: 'mode-changed', func: (_event: any, mode: 'pet' | 'window') => void): void;
27 | send(channel: string, ...args: any[]): void;
28 | }
29 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/sidebar/use-camera-panel.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-shadow */
2 | import { useRef, useState } from 'react';
3 | import { useCamera } from '@/context/camera-context';
4 |
5 | export const useCameraPanel = () => {
6 | const videoRef = useRef(null);
7 | const [error, setError] = useState('');
8 | const [isHovering, setIsHovering] = useState(false);
9 | const {
10 | isStreaming, stream, startCamera, stopCamera,
11 | } = useCamera();
12 |
13 | const toggleCamera = async (): Promise => {
14 | try {
15 | if (isStreaming) {
16 | stopCamera();
17 | } else {
18 | await startCamera();
19 | }
20 | setError('');
21 | } catch (error) {
22 | let errorMessage = 'Unable to access camera';
23 | if (error instanceof Error) {
24 | errorMessage = error.message;
25 | }
26 | setError(errorMessage);
27 | }
28 | };
29 |
30 | const handleMouseEnter = () => setIsHovering(true);
31 | const handleMouseLeave = () => setIsHovering(false);
32 |
33 | return {
34 | videoRef,
35 | error,
36 | isHovering,
37 | isStreaming,
38 | stream,
39 | toggleCamera,
40 | handleMouseEnter,
41 | handleMouseLeave,
42 | };
43 | };
44 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/sidebar/use-sidebar.ts:
--------------------------------------------------------------------------------
1 | import { useDisclosure } from '@chakra-ui/react';
2 | import { useWebSocket } from '@/context/websocket-context';
3 | import { useInterrupt } from '@/components/canvas/live2d';
4 | import { useChatHistory } from '@/context/chat-history-context';
5 | import { useMode, ModeType } from '@/context/mode-context';
6 |
7 | export const useSidebar = () => {
8 | const disclosure = useDisclosure();
9 | const { sendMessage } = useWebSocket();
10 | const { interrupt } = useInterrupt();
11 | const { currentHistoryUid, messages, updateHistoryList } = useChatHistory();
12 | const { setMode, mode, isElectron } = useMode();
13 |
14 | const createNewHistory = (): void => {
15 | if (currentHistoryUid && messages.length > 0) {
16 | const latestMessage = messages[messages.length - 1];
17 | updateHistoryList(currentHistoryUid, latestMessage);
18 | }
19 |
20 | interrupt();
21 | sendMessage({
22 | type: 'create-new-history',
23 | });
24 | };
25 |
26 | return {
27 | settingsOpen: disclosure.open,
28 | onSettingsOpen: disclosure.onOpen,
29 | onSettingsClose: disclosure.onClose,
30 | createNewHistory,
31 | setMode,
32 | currentMode: mode,
33 | isElectron,
34 | };
35 | };
36 |
--------------------------------------------------------------------------------
/src/renderer/src/components/canvas/ws-status.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@chakra-ui/react';
2 | import React, { memo } from 'react';
3 | import { useTranslation } from 'react-i18next';
4 | import { canvasStyles } from './canvas-styles';
5 | import { useWSStatus } from '@/hooks/canvas/use-ws-status';
6 |
7 | // Type definitions
8 | interface StatusContentProps {
9 | textKey: string
10 | }
11 |
12 | // Reusable components
13 | const StatusContent: React.FC = ({ textKey }) => {
14 | const { t } = useTranslation();
15 | return t(textKey);
16 | };
17 | const MemoizedStatusContent = memo(StatusContent);
18 |
19 | // Main component
20 | const WebSocketStatus = memo((): JSX.Element => {
21 | const {
22 | color, textKey, handleClick, isDisconnected,
23 | } = useWSStatus();
24 |
25 | return (
26 |
35 |
36 |
37 | );
38 | });
39 |
40 | WebSocketStatus.displayName = 'WebSocketStatus';
41 |
42 | export default WebSocketStatus;
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | pnpm-lock.yaml
9 | lerna-debug.log*
10 |
11 | # Dependencies
12 | node_modules
13 | .yarn
14 | # package-lock.json
15 |
16 | # Build
17 | release
18 | dist
19 | dist-ssr
20 | build
21 | *.local
22 | .docusaurus
23 | .cache-loader
24 | coverage
25 |
26 | # Editor directories and files
27 | .vscode/*
28 | !.vscode/extensions.json
29 | .idea
30 | .DS_Store
31 | *.suo
32 | *.ntvs*
33 | *.njsproj
34 | *.sln
35 | *.sw?
36 | *.swp
37 | *.swo
38 | *.sublime-project
39 | *.sublime-workspace
40 | *.iml
41 | *.code-workspace
42 |
43 | # React DevTools
44 | packages/react-devtools-*/dist
45 | packages/react-devtools-extensions/chrome/build
46 | packages/react-devtools-extensions/chrome/*.crx
47 | packages/react-devtools-extensions/chrome/*.pem
48 | packages/react-devtools-extensions/firefox/build
49 | packages/react-devtools-extensions/firefox/*.xpi
50 | packages/react-devtools-extensions/firefox/*.pem
51 | packages/react-devtools-extensions/shared/build
52 | packages/react-devtools-extensions/.tempUserDataDir
53 |
54 | # Temp files
55 | *~
56 | *.pyc
57 | .grunt
58 | out
59 |
60 | # Cursor files
61 | .cursorrules
62 |
63 | # Backend directory (contains cloned repository)
64 | backend/
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | extends: [
4 | 'airbnb',
5 | 'airbnb/hooks',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react/recommended',
8 | ],
9 | plugins: ['@typescript-eslint', 'react'],
10 | settings: {
11 | 'import/resolver': {
12 | node: {
13 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
14 | },
15 | },
16 | },
17 | rules: {
18 | 'no-unused-vars': 'off',
19 | 'max-len': 'off',
20 | '@typescript-eslint/no-explicit-any': 'off',
21 | '@typescript-eslint/no-unused-vars': 'off',
22 | 'no-console': 'off',
23 | 'react/jsx-filename-extension': [1, { extensions: ['.tsx', '.jsx'] }],
24 | 'import/extensions': 'off',
25 | 'import/no-extraneous-dependencies': ['error', { devDependencies: true }],
26 | 'react/react-in-jsx-scope': 'off',
27 | 'react/jsx-props-no-spreading': 'off',
28 | 'import/no-unresolved': 'off',
29 | 'import/prefer-default-export': 'off',
30 | quotes: 'off',
31 | 'operator-linebreak': 'off',
32 | 'react/display-name': 'off',
33 | 'react-hooks/exhaustive-deps': 'off',
34 | 'consistent-return': 'off',
35 | 'object-curly-newline': 'off',
36 | 'react/require-default-props': 'off',
37 | },
38 | };
39 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import type { ButtonProps as ChakraButtonProps } from '@chakra-ui/react';
2 | import {
3 | AbsoluteCenter,
4 | Button as ChakraButton,
5 | Span,
6 | Spinner,
7 | } from '@chakra-ui/react';
8 | import * as React from 'react';
9 |
10 | interface ButtonLoadingProps {
11 | loading?: boolean
12 | loadingText?: React.ReactNode
13 | }
14 |
15 | export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
16 |
17 | export const Button = React.forwardRef(
18 | (props, ref) => {
19 | const {
20 | loading, disabled, loadingText, children, ...rest
21 | } = props;
22 | return (
23 |
24 | {loading && !loadingText ? (
25 | <>
26 |
27 |
28 |
29 | {children}
30 | >
31 | ) : loading && loadingText ? (
32 | <>
33 |
34 | {loadingText}
35 | >
36 | ) : (
37 | children
38 | )}
39 |
40 | );
41 | },
42 | );
43 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {
4 | Toaster as ChakraToaster,
5 | Portal,
6 | Spinner,
7 | Stack,
8 | Toast,
9 | createToaster,
10 | } from '@chakra-ui/react';
11 |
12 | export const toaster = createToaster({
13 | placement: 'top-end',
14 | pauseOnPageIdle: true,
15 | max: 5,
16 | });
17 |
18 | export function Toaster() {
19 | return (
20 |
21 |
22 | {(toast) => (
23 |
24 | {toast.type === 'loading' ? (
25 |
26 | ) : (
27 |
28 | )}
29 |
30 | {toast.title && {toast.title}}
31 | {toast.description && (
32 | {toast.description}
33 | )}
34 |
35 | {toast.action && (
36 | {toast.action.label}
37 | )}
38 | {toast.meta?.closable && }
39 |
40 | )}
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/canvas/use-ws-status.ts:
--------------------------------------------------------------------------------
1 | import { useMemo, useCallback } from 'react';
2 | import { useWebSocket } from '@/context/websocket-context';
3 |
4 | interface WSStatusInfo {
5 | color: string
6 | textKey: string
7 | isDisconnected: boolean
8 | handleClick: () => void
9 | }
10 |
11 | export const useWSStatus = () => {
12 | const { wsState, reconnect } = useWebSocket();
13 |
14 | const handleClick = useCallback(() => {
15 | if (wsState !== 'OPEN' && wsState !== 'CONNECTING') {
16 | reconnect();
17 | }
18 | }, [wsState, reconnect]);
19 |
20 | const statusInfo = useMemo((): WSStatusInfo => {
21 | switch (wsState) {
22 | case 'OPEN':
23 | return {
24 | color: 'green.500',
25 | textKey: 'wsStatus.connected',
26 | isDisconnected: false,
27 | handleClick,
28 | };
29 | case 'CONNECTING':
30 | return {
31 | color: 'yellow.500',
32 | textKey: 'wsStatus.connecting',
33 | isDisconnected: false,
34 | handleClick,
35 | };
36 | default:
37 | return {
38 | color: 'red.500',
39 | textKey: 'wsStatus.clickToReconnect',
40 | isDisconnected: true,
41 | handleClick,
42 | };
43 | }
44 | }, [wsState, handleClick]);
45 |
46 | return statusInfo;
47 | };
48 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/utils/use-interrupt.ts:
--------------------------------------------------------------------------------
1 | import { useAiState } from '@/context/ai-state-context';
2 | import { useWebSocket } from '@/context/websocket-context';
3 | import { useChatHistory } from '@/context/chat-history-context';
4 | import { audioTaskQueue } from '@/utils/task-queue';
5 | import { useSubtitle } from '@/context/subtitle-context';
6 | import { useAudioTask } from './use-audio-task';
7 |
8 | export const useInterrupt = () => {
9 | const { aiState, setAiState } = useAiState();
10 | const { sendMessage } = useWebSocket();
11 | const { fullResponse, clearResponse } = useChatHistory();
12 | // const { currentModel } = useLive2DModel();
13 | const { subtitleText, setSubtitleText } = useSubtitle();
14 | const { stopCurrentAudioAndLipSync } = useAudioTask();
15 |
16 | const interrupt = (sendSignal = true) => {
17 | if (aiState !== 'thinking-speaking') return;
18 | console.log('Interrupting conversation chain');
19 |
20 | stopCurrentAudioAndLipSync();
21 |
22 | audioTaskQueue.clearQueue();
23 |
24 | setAiState('interrupted');
25 |
26 | if (sendSignal) {
27 | sendMessage({
28 | type: 'interrupt-signal',
29 | text: fullResponse,
30 | });
31 | }
32 |
33 | clearResponse();
34 |
35 | if (subtitleText === 'Thinking...') {
36 | setSubtitleText('');
37 | }
38 | console.log('Interrupted!');
39 | };
40 |
41 | return { interrupt };
42 | };
43 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip as ChakraTooltip, Portal } from '@chakra-ui/react';
2 | import * as React from 'react';
3 |
4 | export interface TooltipProps extends ChakraTooltip.RootProps {
5 | showArrow?: boolean
6 | portalled?: boolean
7 | portalRef?: React.RefObject
8 | content: React.ReactNode
9 | contentProps?: ChakraTooltip.ContentProps
10 | disabled?: boolean
11 | }
12 |
13 | export const Tooltip = React.forwardRef(
14 | (props, ref) => {
15 | const {
16 | showArrow,
17 | children,
18 | disabled,
19 | portalled,
20 | content,
21 | contentProps,
22 | portalRef,
23 | ...rest
24 | } = props;
25 |
26 | if (disabled) return children;
27 |
28 | return (
29 |
30 | {children}
31 |
32 |
33 |
34 | {showArrow && (
35 |
36 |
37 |
38 | )}
39 | {content}
40 |
41 |
42 |
43 |
44 | );
45 | },
46 | );
47 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import { Switch as ChakraSwitch } from '@chakra-ui/react';
2 | import * as React from 'react';
3 |
4 | export interface SwitchProps extends ChakraSwitch.RootProps {
5 | inputProps?: React.InputHTMLAttributes
6 | rootRef?: React.Ref
7 | trackLabel?: { on: React.ReactNode; off: React.ReactNode }
8 | thumbLabel?: { on: React.ReactNode; off: React.ReactNode }
9 | }
10 |
11 | export const Switch = React.forwardRef(
12 | (props, ref) => {
13 | const {
14 | inputProps, children, rootRef, trackLabel, thumbLabel, ...rest
15 | } = props;
16 |
17 | return (
18 |
19 |
20 |
21 |
22 | {thumbLabel && (
23 |
24 | {thumbLabel?.on}
25 |
26 | )}
27 |
28 | {trackLabel && (
29 |
30 | {trackLabel.on}
31 |
32 | )}
33 |
34 | {children != null && (
35 | {children}
36 | )}
37 |
38 | );
39 | },
40 | );
41 |
--------------------------------------------------------------------------------
/i18next-scanner.config.js:
--------------------------------------------------------------------------------
1 | // i18next-scanner configuration
2 | module.exports = {
3 | input: [
4 | 'src/renderer/src/**/*.{js,jsx,ts,tsx}',
5 | // Use ! to filter out files or directories
6 | '!src/renderer/src/**/*.spec.{js,jsx,ts,tsx}',
7 | '!src/renderer/src/i18n/**',
8 | '!**/node_modules/**',
9 | ],
10 | output: './src/renderer/src',
11 | options: {
12 | debug: true,
13 | func: {
14 | list: ['t', 'i18next.t', 'i18n.t'],
15 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
16 | },
17 | trans: {
18 | component: 'Trans',
19 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
20 | fallbackKey(ns, value) {
21 | return value;
22 | },
23 | },
24 | lngs: ['en', 'zh'],
25 | ns: ['translation'],
26 | defaultLng: 'en',
27 | defaultNs: 'translation',
28 | defaultValue(lng, ns, key) {
29 | if (lng === 'en') {
30 | // Return key as the default value for English
31 | return key;
32 | }
33 | // Return empty string for other languages
34 | return '';
35 | },
36 | resource: {
37 | loadPath: 'src/renderer/src/locales/{{lng}}/{{ns}}.json',
38 | savePath: 'locales/{{lng}}/{{ns}}.json',
39 | jsonIndent: 2,
40 | lineEnding: '\n',
41 | },
42 | sort: true,
43 | removeUnusedKeys: true,
44 | nsSeparator: ':',
45 | keySeparator: '.',
46 | pluralSeparator: '_',
47 | contextSeparator: '_',
48 | contextDefaultValues: [],
49 | interpolation: {
50 | prefix: '{{',
51 | suffix: '}}',
52 | },
53 | },
54 | };
55 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import { Alert as ChakraAlert } from '@chakra-ui/react';
2 | import * as React from 'react';
3 | import { CloseButton } from './close-button';
4 |
5 | export interface AlertProps extends Omit {
6 | startElement?: React.ReactNode
7 | endElement?: React.ReactNode
8 | title?: React.ReactNode
9 | icon?: React.ReactElement
10 | closable?: boolean
11 | onClose?: () => void
12 | }
13 |
14 | export const Alert = React.forwardRef(
15 | (props, ref) => {
16 | const {
17 | title,
18 | children,
19 | icon,
20 | closable,
21 | onClose,
22 | startElement,
23 | endElement,
24 | ...rest
25 | } = props;
26 | return (
27 |
28 | {startElement || {icon}}
29 | {children ? (
30 |
31 | {title}
32 | {children}
33 |
34 | ) : (
35 | {title}
36 | )}
37 | {endElement}
38 | {closable && (
39 |
47 | )}
48 |
49 | );
50 | },
51 | );
52 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/src/icubismallcator.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | /**
9 | * メモリアロケーションを抽象化したクラス
10 | *
11 | * メモリ確保・解放処理をプラットフォーム側で実装して
12 | * フレームワークから呼び出すためのインターフェース
13 | */
14 | export abstract class ICubismAllocator {
15 | /**
16 | * アラインメント制約なしのヒープ・メモリーを確保します
17 | *
18 | * @param size 確保するバイト数
19 | * @return 成功すると割り当てられたメモリのアドレス。そうでなければ'0'を返す
20 | */
21 | public abstract allocate(size: number): any;
22 |
23 | /**
24 | * アラインメント制約なしのヒープ・メモリーを解放します。
25 | *
26 | * @param memory 解放するメモリのアドレス
27 | */
28 | public abstract deallocate(memory: any): void;
29 |
30 | /**
31 | * アラインメント制約有のヒープ・メモリーを確保します。
32 | * @param size 確保するバイト数
33 | * @param alignment メモリーブロックのアラインメント幅
34 | * @return 成功すると割り当てられたメモリのアドレス。そうでなければ'0'を返す
35 | */
36 | public abstract allocateAligned(size: number, alignment: number): any;
37 |
38 | /**
39 | * アラインメント制約ありのヒープ・メモリーを解放します。
40 | * @param alignedMemory 解放するメモリのアドレス
41 | */
42 | public abstract deallocateAligned(alignedMemory: any): void;
43 | }
44 |
45 | // Namespace definition for compatibility.
46 | import * as $ from './icubismallcator';
47 | // eslint-disable-next-line @typescript-eslint/no-namespace
48 | export namespace Live2DCubismFramework {
49 | export const ICubismAllocator = $.ICubismAllocator;
50 | export type ICubismAllocator = $.ICubismAllocator;
51 | }
52 |
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/bottom-tab.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import { Tabs } from '@chakra-ui/react'
3 | import { FiCamera, FiMonitor, FiGlobe } from 'react-icons/fi'
4 | import { useTranslation } from 'react-i18next'
5 | import { sidebarStyles } from './sidebar-styles'
6 | import CameraPanel from './camera-panel'
7 | import ScreenPanel from './screen-panel'
8 | import BrowserPanel from './browser-panel'
9 |
10 | function BottomTab(): JSX.Element {
11 | const { t } = useTranslation();
12 |
13 | return (
14 |
19 |
20 |
21 |
22 | {t('sidebar.camera')}
23 |
24 |
25 |
26 | {t('sidebar.screen')}
27 |
28 |
29 |
30 | {t('sidebar.browser')}
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
49 | export default BottomTab
50 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/utils/use-switch-character.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { useWebSocket } from '@/context/websocket-context';
3 | import { useConfig } from '@/context/character-config-context';
4 | import { useInterrupt } from '@/components/canvas/live2d';
5 | import { useVAD } from '@/context/vad-context';
6 | import { useSubtitle } from '@/context/subtitle-context';
7 | import { useAiState } from '@/context/ai-state-context';
8 | import { useLive2DConfig } from '@/context/live2d-config-context';
9 |
10 | export function useSwitchCharacter() {
11 | const { sendMessage } = useWebSocket();
12 | const { confName, getFilenameByName } = useConfig();
13 | const { interrupt } = useInterrupt();
14 | const { stopMic } = useVAD();
15 | const { setSubtitleText } = useSubtitle();
16 | const { setAiState } = useAiState();
17 | const { setModelInfo } = useLive2DConfig();
18 | const switchCharacter = useCallback((fileName: string) => {
19 | const currentFilename = getFilenameByName(confName);
20 |
21 | if (currentFilename === fileName) {
22 | console.log('Skipping character switch - same configuration file');
23 | return;
24 | }
25 |
26 | setSubtitleText('New Character Loading...');
27 | interrupt();
28 | stopMic();
29 | setAiState('loading');
30 | setModelInfo(undefined);
31 | sendMessage({
32 | type: 'switch-config',
33 | file: fileName,
34 | });
35 | console.log('Switch Character fileName: ', fileName);
36 | }, [confName, getFilenameByName, sendMessage, interrupt, stopMic, setSubtitleText, setAiState]);
37 |
38 | return { switchCharacter };
39 | }
40 |
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/chat-bubble.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Text, Flex } from '@chakra-ui/react';
2 | import { Avatar, AvatarGroup } from '@/components/ui/avatar';
3 | import { Message } from '@/services/websocket-service';
4 |
5 | // Type definitions
6 | interface ChatBubbleProps {
7 | message: Message;
8 | isSelected?: boolean;
9 | onClick?: () => void;
10 | }
11 |
12 | // Main component
13 | export function ChatBubble({ message, isSelected, onClick }: ChatBubbleProps): JSX.Element {
14 | const isAI = message.role === 'ai';
15 |
16 | return (
17 |
26 |
27 |
28 |
34 |
35 |
36 |
37 | {message.name || (isAI ? 'AI' : 'Me')}
38 |
39 |
44 | {message.content}
45 |
46 |
47 | {new Date(message.timestamp).toLocaleTimeString()}
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/docs/i18n-usage.md:
--------------------------------------------------------------------------------
1 | # i18n (国际化) 使用指南
2 |
3 | 本项目使用 i18next 实现多语言支持,目前支持英语和中文。
4 |
5 | ## 目录结构
6 |
7 | ```
8 | src/renderer/src/
9 | ├── i18n.ts # i18next 配置文件
10 | ├── locales/ # 翻译文件目录
11 | │ ├── en/ # 英语翻译
12 | │ │ └── translation.json # 英语翻译文件
13 | │ └── zh/ # 中文翻译
14 | │ └── translation.json # 中文翻译文件
15 | ```
16 |
17 | ## 使用方法
18 |
19 | ### 在组件中使用
20 |
21 | ```tsx
22 | import { useTranslation } from 'react-i18next';
23 |
24 | function MyComponent() {
25 | // 获取翻译函数和其他国际化工具
26 | const { t, i18n } = useTranslation();
27 |
28 | // 使用 t 函数翻译文本
29 | return (
30 |
31 |
{t('common.settings')}
32 |
{t('settings.general.language')}
33 |
34 | {/* 改变语言 */}
35 |
36 |
37 |
38 | );
39 | }
40 | ```
41 |
42 | ### 添加新的翻译
43 |
44 | 1. 在代码中使用 `t('your.translation.key')` 添加新的翻译键
45 | 2. 运行扫描命令提取翻译键:
46 |
47 | ```bash
48 | npm run extract-translations
49 | ```
50 |
51 | 3. 添加的翻译键会更新到 `src/renderer/src/locales/en/translation.json` 和 `src/renderer/src/locales/zh/translation.json` 文件中
52 | 4. 编辑中文翻译文件,为每个键添加对应的中文翻译
53 |
54 | ## 翻译键命名规则
55 |
56 | 我们使用点号分隔的命名空间来组织翻译键,例如:
57 |
58 | - `common.save` - 通用的"保存"按钮文本
59 | - `settings.general.language` - 设置页面中的语言设置项
60 |
61 | ## 嵌套翻译
62 |
63 | 对于包含变量的文本,可以使用插值:
64 |
65 | ```tsx
66 | // 在翻译文件中
67 | {
68 | "welcome": "欢迎, {{name}}!"
69 | }
70 |
71 | // 在组件中
72 | t('welcome', { name: 'John' }) // "欢迎, John!"
73 | ```
74 |
75 | ## 自动检测语言
76 |
77 | 系统会自动检测用户的浏览器语言设置,并使用最接近的可用语言。用户也可以手动切换语言,选择会被保存在 localStorage 中。
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/setting/agent.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import { Stack } from '@chakra-ui/react';
3 | import { useTranslation } from 'react-i18next';
4 | import { settingStyles } from './setting-styles';
5 | import { useAgentSettings } from '@/hooks/sidebar/setting/use-agent-settings';
6 | import { SwitchField, NumberField } from './common';
7 |
8 | interface AgentProps {
9 | onSave?: (callback: () => void) => () => void
10 | onCancel?: (callback: () => void) => () => void
11 | }
12 |
13 | function Agent({ onSave, onCancel }: AgentProps): JSX.Element {
14 | const { t } = useTranslation();
15 | const {
16 | settings,
17 | handleAllowProactiveSpeakChange,
18 | handleIdleSecondsChange,
19 | handleAllowButtonTriggerChange,
20 | } = useAgentSettings({ onSave, onCancel });
21 |
22 | return (
23 |
24 |
29 |
30 | {settings.allowProactiveSpeak && (
31 | handleIdleSecondsChange(Number(value))}
35 | min={0}
36 | step={0.1}
37 | allowMouseWheel
38 | />
39 | )}
40 |
41 |
46 |
47 | );
48 | }
49 |
50 | export default Agent;
51 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/input-group.tsx:
--------------------------------------------------------------------------------
1 | import type { BoxProps, InputElementProps } from '@chakra-ui/react';
2 | import { Group, InputElement } from '@chakra-ui/react';
3 | import * as React from 'react';
4 |
5 | export interface InputGroupProps extends BoxProps {
6 | startElementProps?: InputElementProps
7 | endElementProps?: InputElementProps
8 | startElement?: React.ReactNode
9 | endElement?: React.ReactNode
10 | children: React.ReactElement
11 | startOffset?: InputElementProps['paddingStart']
12 | endOffset?: InputElementProps['paddingEnd']
13 | }
14 |
15 | export const InputGroup = React.forwardRef(
16 | (props, ref) => {
17 | const {
18 | startElement,
19 | startElementProps,
20 | endElement,
21 | endElementProps,
22 | children,
23 | startOffset = '6px',
24 | endOffset = '6px',
25 | ...rest
26 | } = props;
27 |
28 | const child = React.Children.only>(children);
29 |
30 | return (
31 |
32 | {startElement && (
33 |
34 | {startElement}
35 |
36 | )}
37 | {React.cloneElement(child, {
38 | ...(startElement && {
39 | ps: `calc(var(--input-height) - ${startOffset})`,
40 | }),
41 | ...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
42 | ...children.props,
43 | })}
44 | {endElement && (
45 |
46 | {endElement}
47 |
48 | )}
49 |
50 | );
51 | },
52 | );
53 |
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/setting/live2d.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | /* eslint-disable react-hooks/rules-of-hooks */
3 | import { Stack } from '@chakra-ui/react';
4 | import { useEffect } from 'react';
5 | import { useTranslation } from 'react-i18next';
6 | import { settingStyles } from './setting-styles';
7 | import { useLive2dSettings } from '@/hooks/sidebar/setting/use-live2d-settings';
8 | import { SwitchField } from './common';
9 |
10 | interface live2DProps {
11 | onSave?: (callback: () => void) => () => void
12 | onCancel?: (callback: () => void) => () => void
13 | }
14 |
15 | function live2D({ onSave, onCancel }: live2DProps): JSX.Element {
16 | const { t } = useTranslation();
17 | const {
18 | modelInfo,
19 | handleInputChange,
20 | handleSave,
21 | handleCancel,
22 | } = useLive2dSettings();
23 |
24 | useEffect(() => {
25 | if (!onSave || !onCancel) return;
26 |
27 | const cleanupSave = onSave(handleSave);
28 | const cleanupCancel = onCancel(handleCancel);
29 |
30 | return (): void => {
31 | cleanupSave?.();
32 | cleanupCancel?.();
33 | };
34 | }, [onSave, onCancel]);
35 |
36 | return (
37 |
38 | handleInputChange('pointerInteractive', checked)}
42 | />
43 |
44 | handleInputChange('scrollToResize', checked)}
48 | />
49 |
50 | );
51 | }
52 |
53 | export default live2D;
54 |
--------------------------------------------------------------------------------
/src/renderer/src/context/group-context.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useState, useMemo } from 'react';
2 |
3 | interface GroupContextState {
4 | selfUid: string;
5 | groupMembers: string[];
6 | isOwner: boolean;
7 | setSelfUid: (uid: string) => void;
8 | setGroupMembers: (members: string[]) => void;
9 | setIsOwner: (isOwner: boolean) => void;
10 | sortedGroupMembers: string[];
11 | resetGroupState: () => void;
12 | }
13 |
14 | const GroupContext = createContext(null);
15 |
16 | export function GroupProvider({ children }: { children: React.ReactNode }) {
17 | const [selfUid, setSelfUid] = useState('');
18 | const [groupMembers, setGroupMembers] = useState([]);
19 | const [isOwner, setIsOwner] = useState(false);
20 |
21 | const resetGroupState = () => {
22 | setGroupMembers([]);
23 | setIsOwner(false);
24 | };
25 |
26 | const sortedGroupMembers = useMemo(() => {
27 | if (!groupMembers.includes(selfUid)) return groupMembers;
28 |
29 | return [
30 | selfUid,
31 | ...groupMembers.filter((memberId) => memberId !== selfUid),
32 | ];
33 | }, [groupMembers, selfUid]);
34 |
35 | return (
36 | // eslint-disable-next-line react/jsx-no-constructed-context-values
37 |
48 | {children}
49 |
50 | );
51 | }
52 |
53 | export function useGroup() {
54 | const context = useContext(GroupContext);
55 | if (!context) {
56 | throw new Error('useGroup must be used within a GroupProvider');
57 | }
58 | return context;
59 | }
60 |
--------------------------------------------------------------------------------
/src/renderer/src/components/canvas/background.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Image } from '@chakra-ui/react';
2 | import { memo, useEffect, useRef } from 'react';
3 | import { canvasStyles } from './canvas-styles';
4 | import { useCamera } from '@/context/camera-context';
5 | import { useBgUrl } from '@/context/bgurl-context';
6 |
7 | const Background = memo(({ children }: { children?: React.ReactNode }) => {
8 | const videoRef = useRef(null);
9 | const {
10 | backgroundStream, isBackgroundStreaming, startBackgroundCamera, stopBackgroundCamera,
11 | } = useCamera();
12 | const { useCameraBackground, backgroundUrl } = useBgUrl();
13 |
14 | useEffect(() => {
15 | if (useCameraBackground) {
16 | startBackgroundCamera();
17 | } else {
18 | stopBackgroundCamera();
19 | }
20 | }, [useCameraBackground, startBackgroundCamera, stopBackgroundCamera]);
21 |
22 | useEffect(() => {
23 | if (videoRef.current && backgroundStream) {
24 | videoRef.current.srcObject = backgroundStream;
25 | }
26 | }, [backgroundStream]);
27 |
28 | return (
29 |
30 | {useCameraBackground ? (
31 |
42 | ) : (
43 |
48 | )}
49 | {children}
50 |
51 | );
52 | });
53 |
54 | Background.displayName = 'Background';
55 |
56 | export default Background;
57 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import { Popover as ChakraPopover, Portal } from '@chakra-ui/react';
2 | import * as React from 'react';
3 | import { CloseButton } from './close-button';
4 |
5 | interface PopoverContentProps extends ChakraPopover.ContentProps {
6 | portalled?: boolean
7 | portalRef?: React.RefObject
8 | }
9 |
10 | export const PopoverContent = React.forwardRef<
11 | HTMLDivElement,
12 | PopoverContentProps
13 | >((props, ref) => {
14 | const { portalled = true, portalRef, ...rest } = props;
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | );
22 | });
23 |
24 | export const PopoverArrow = React.forwardRef<
25 | HTMLDivElement,
26 | ChakraPopover.ArrowProps
27 | >((props, ref) => (
28 |
29 |
30 |
31 | ));
32 |
33 | export const PopoverCloseTrigger = React.forwardRef<
34 | HTMLButtonElement,
35 | ChakraPopover.CloseTriggerProps
36 | >((props, ref) => (
37 |
45 |
46 |
47 | ));
48 |
49 | export const PopoverTitle = ChakraPopover.Title;
50 | export const PopoverDescription = ChakraPopover.Description;
51 | export const PopoverFooter = ChakraPopover.Footer;
52 | export const PopoverHeader = ChakraPopover.Header;
53 | export const PopoverRoot = ChakraPopover.Root;
54 | export const PopoverBody = ChakraPopover.Body;
55 | export const PopoverTrigger = ChakraPopover.Trigger;
56 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | import { Drawer as ChakraDrawer, Portal } from '@chakra-ui/react';
2 | import * as React from 'react';
3 | import { CloseButton } from './close-button';
4 |
5 | interface DrawerContentProps extends ChakraDrawer.ContentProps {
6 | portalled?: boolean
7 | portalRef?: React.RefObject
8 | offset?: ChakraDrawer.ContentProps['padding']
9 | }
10 |
11 | export const DrawerContent = React.forwardRef<
12 | HTMLDivElement,
13 | DrawerContentProps
14 | >((props, ref) => {
15 | const {
16 | children, portalled = true, portalRef, offset, ...rest
17 | } = props;
18 | return (
19 |
20 |
21 |
22 | {children}
23 |
24 |
25 |
26 | );
27 | });
28 |
29 | export const DrawerCloseTrigger = React.forwardRef<
30 | HTMLButtonElement,
31 | ChakraDrawer.CloseTriggerProps
32 | >((props, ref) => (
33 |
40 |
41 |
42 | ));
43 |
44 | export const DrawerTrigger = ChakraDrawer.Trigger;
45 | export const DrawerRoot = ChakraDrawer.Root;
46 | export const DrawerFooter = ChakraDrawer.Footer;
47 | export const DrawerHeader = ChakraDrawer.Header;
48 | export const DrawerBody = ChakraDrawer.Body;
49 | export const DrawerBackdrop = ChakraDrawer.Backdrop;
50 | export const DrawerDescription = ChakraDrawer.Description;
51 | export const DrawerTitle = ChakraDrawer.Title;
52 | export const DrawerActionTrigger = ChakraDrawer.ActionTrigger;
53 |
--------------------------------------------------------------------------------
/src/renderer/src/components/canvas/canvas-styles.tsx:
--------------------------------------------------------------------------------
1 | export const canvasStyles = {
2 | background: {
3 | container: {
4 | position: 'relative',
5 | width: '100%',
6 | height: '100%',
7 | overflow: 'hidden',
8 | pointerEvents: 'auto',
9 | },
10 | image: {
11 | position: 'absolute',
12 | top: '0',
13 | left: '0',
14 | width: '100%',
15 | height: '100%',
16 | objectFit: 'cover',
17 | zIndex: 1,
18 | },
19 | video: {
20 | position: 'absolute' as const,
21 | top: '0',
22 | left: '0',
23 | width: '100%',
24 | height: '100%',
25 | objectFit: 'cover' as const,
26 | zIndex: 1,
27 | transform: 'scaleX(-1)' as const,
28 | },
29 | },
30 | canvas: {
31 | container: {
32 | position: 'relative',
33 | width: '100%',
34 | height: '100%',
35 | zIndex: '1',
36 | pointerEvents: 'auto',
37 | },
38 | },
39 | subtitle: {
40 | container: {
41 | backgroundColor: 'rgba(0, 0, 0, 0.7)',
42 | padding: '15px 30px',
43 | borderRadius: '12px',
44 | minWidth: '60%',
45 | maxWidth: '95%',
46 | },
47 | text: {
48 | color: 'white',
49 | fontSize: '1.5rem',
50 | textAlign: 'center',
51 | lineHeight: '1.4',
52 | whiteSpace: 'pre-wrap',
53 | },
54 | },
55 | wsStatus: {
56 | container: {
57 | position: 'relative',
58 | // top: '20px',
59 | // left: '20px',
60 | zIndex: 2,
61 | padding: '8px 16px',
62 | borderRadius: '20px',
63 | fontSize: '14px',
64 | fontWeight: 'medium',
65 | color: 'white',
66 | transition: 'all 0.2s',
67 | cursor: 'pointer',
68 | userSelect: 'none',
69 | _hover: {
70 | opacity: 0.8,
71 | },
72 | },
73 | },
74 | };
75 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/src/lappglmanager.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | export let canvas: HTMLCanvasElement | null = null;
9 | export let gl: WebGLRenderingContext | null = null;
10 | export let s_instance: LAppGlManager | null = null;
11 | /**
12 | * Cubism SDKのサンプルで使用するWebGLを管理するクラス
13 | */
14 | export class LAppGlManager {
15 | /**
16 | * クラスのインスタンス(シングルトン)を返す。
17 | * インスタンスが生成されていない場合は内部でインスタンスを生成する。
18 | *
19 | * @return クラスのインスタンス
20 | */
21 | public static getInstance(): LAppGlManager {
22 | if (s_instance == null) {
23 | s_instance = new LAppGlManager();
24 | }
25 |
26 | return s_instance;
27 | }
28 |
29 | /**
30 | * クラスのインスタンス(シングルトン)を解放する。
31 | */
32 | public static releaseInstance(): void {
33 | if (s_instance != null) {
34 | s_instance.release();
35 | }
36 |
37 | s_instance = null;
38 | }
39 |
40 | constructor() {
41 | // Use existing canvas instead of creating a new one
42 | canvas = document.getElementById('canvas') as HTMLCanvasElement;
43 | // canvas = document.createElement("canvas");
44 |
45 | if (!canvas) {
46 | console.warn("Canvas element not found during LAppGlManager initialization");
47 | return;
48 | }
49 |
50 | gl = canvas.getContext("webgl2");
51 |
52 | if (!gl) {
53 | // gl初期化失敗
54 | alert("Cannot initialize WebGL. This browser does not support.");
55 | gl = null;
56 |
57 | document.body.innerHTML =
58 | "This browser does not support the <canvas> element.";
59 | }
60 | }
61 |
62 | /**
63 | * 解放する。
64 | */
65 | public release(): void {}
66 | }
67 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog as ChakraDialog, Portal } from '@chakra-ui/react';
2 | import * as React from 'react';
3 | import { CloseButton } from './close-button';
4 |
5 | interface DialogContentProps extends ChakraDialog.ContentProps {
6 | portalled?: boolean
7 | portalRef?: React.RefObject
8 | backdrop?: boolean
9 | }
10 |
11 | export const DialogContent = React.forwardRef<
12 | HTMLDivElement,
13 | DialogContentProps
14 | >((props, ref) => {
15 | const {
16 | children,
17 | portalled = true,
18 | portalRef,
19 | backdrop = true,
20 | ...rest
21 | } = props;
22 |
23 | return (
24 |
25 | {backdrop && }
26 |
27 |
28 | {children}
29 |
30 |
31 |
32 | );
33 | });
34 |
35 | export const DialogCloseTrigger = React.forwardRef<
36 | HTMLButtonElement,
37 | ChakraDialog.CloseTriggerProps
38 | >((props, ref) => (
39 |
46 |
47 | {props.children}
48 |
49 |
50 | ));
51 |
52 | export const DialogRoot = ChakraDialog.Root;
53 | export const DialogFooter = ChakraDialog.Footer;
54 | export const DialogHeader = ChakraDialog.Header;
55 | export const DialogBody = ChakraDialog.Body;
56 | export const DialogBackdrop = ChakraDialog.Backdrop;
57 | export const DialogTitle = ChakraDialog.Title;
58 | export const DialogDescription = ChakraDialog.Description;
59 | export const DialogTrigger = ChakraDialog.Trigger;
60 | export const DialogActionTrigger = ChakraDialog.ActionTrigger;
61 |
--------------------------------------------------------------------------------
/electron-builder.yml:
--------------------------------------------------------------------------------
1 | appId: com.electron.app
2 | productName: open-llm-vtuber-electron
3 | directories:
4 | buildResources: resources
5 | output: release/${version}
6 | files:
7 | - 'dist/**/*'
8 | - 'out/**/*'
9 | - 'resources/**/*'
10 | - '!**/.vscode/*'
11 | - '!src/*'
12 | - '!electron.vite.config.{js,ts,mjs,cjs}'
13 | - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
14 | - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
15 | - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
16 | asarUnpack:
17 | - resources/**
18 | win:
19 | executableName: open-llm-vtuber-electron
20 | icon: resources/icon.ico
21 | nsis:
22 | artifactName: ${name}-${version}-setup.${ext}
23 | shortcutName: ${productName}
24 | uninstallDisplayName: ${productName}
25 | createDesktopShortcut: always
26 | differentialPackage: false
27 | mac:
28 | icon: resources/icon.icns
29 | target:
30 | - target: dmg
31 | arch: [x64, arm64]
32 | entitlementsInherit: build/entitlements.mac.plist
33 | extendInfo:
34 | - NSCameraUsageDescription: Application requests access to the device's camera.
35 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone.
36 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
37 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
38 | notarize: false
39 | dmg:
40 | artifactName: ${name}-${version}.${ext}
41 | writeUpdateInfo: false
42 | linux:
43 | target:
44 | - AppImage
45 | - snap
46 | - deb
47 | maintainer: electronjs.org
48 | category: Utility
49 | appImage:
50 | artifactName: ${name}-${version}.${ext}
51 | npmRebuild: false
52 | publish:
53 | provider: github
54 | releaseType: release
55 | publishAutoUpdate: false
56 | electronDownload:
57 | mirror: https://npmmirror.com/mirrors/electron/
--------------------------------------------------------------------------------
/src/renderer/src/i18n.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import i18n from "i18next";
3 | import { initReactI18next } from "react-i18next";
4 | import LanguageDetector from "i18next-browser-languagedetector";
5 |
6 | // Import translation resources
7 | import enTranslation from "./locales/en/translation.json";
8 | import zhTranslation from "./locales/zh/translation.json";
9 |
10 | // Configure i18next instance
11 | i18n
12 | // Detect user language
13 | .use(LanguageDetector)
14 | // Pass the i18n instance to react-i18next
15 | .use(initReactI18next)
16 | // Initialize i18next
17 | .init({
18 | // Default language when detection fails
19 | fallbackLng: "en",
20 | // Debug mode for development
21 | debug: process.env.NODE_ENV === "development",
22 | // Namespaces configuration
23 | defaultNS: "translation",
24 | ns: ["translation"],
25 | // Resources containing translations
26 | resources: {
27 | en: {
28 | translation: enTranslation,
29 | },
30 | zh: {
31 | translation: zhTranslation,
32 | },
33 | },
34 | // Language detection options
35 | detection: {
36 | // Order and from where user language should be detected
37 | order: ["localStorage", "navigator"],
38 | // Cache user language detection
39 | caches: ["localStorage"],
40 | // HTML attribute with which to set language
41 | htmlTag: document.documentElement,
42 | },
43 | // Escaping special characters
44 | interpolation: {
45 | escapeValue: false, // React already safes from XSS
46 | },
47 | // React config
48 | react: {
49 | useSuspense: true,
50 | },
51 | });
52 |
53 | // Save language change to localStorage
54 | i18n.on("languageChanged", (lng) => {
55 | localStorage.setItem("i18nextLng", lng);
56 | // Update HTML document lang attribute
57 | document.documentElement.lang = lng;
58 | });
59 |
60 | export default i18n;
61 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/footer/use-footer.ts:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, KeyboardEvent } from 'react';
2 | import { useVAD } from '@/context/vad-context';
3 | import { useTextInput } from '@/hooks/footer/use-text-input';
4 | import { useInterrupt } from '@/hooks/utils/use-interrupt';
5 | import { useMicToggle } from '@/hooks/utils/use-mic-toggle';
6 | import { useAiState, AiStateEnum } from '@/context/ai-state-context';
7 | import { useTriggerSpeak } from '@/hooks/utils/use-trigger-speak';
8 | import { useProactiveSpeak } from '@/context/proactive-speak-context';
9 |
10 | export const useFooter = () => {
11 | const {
12 | inputText: inputValue,
13 | setInputText: handleChange,
14 | handleKeyPress: handleKey,
15 | handleCompositionStart,
16 | handleCompositionEnd,
17 | } = useTextInput();
18 |
19 | const { interrupt } = useInterrupt();
20 | const { startMic, autoStartMicOn } = useVAD();
21 | const { handleMicToggle, micOn } = useMicToggle();
22 | const { setAiState, aiState } = useAiState();
23 | const { sendTriggerSignal } = useTriggerSpeak();
24 | const { settings } = useProactiveSpeak();
25 |
26 | const handleInputChange = (e: ChangeEvent) => {
27 | handleChange({ target: { value: e.target.value } } as ChangeEvent);
28 | setAiState(AiStateEnum.WAITING);
29 | };
30 |
31 | const handleKeyPress = (e: KeyboardEvent) => {
32 | handleKey(e as any);
33 | };
34 |
35 | const handleInterrupt = () => {
36 | if (aiState === AiStateEnum.THINKING_SPEAKING) {
37 | interrupt();
38 | if (autoStartMicOn) {
39 | startMic();
40 | }
41 | } else if (settings.allowButtonTrigger) {
42 | sendTriggerSignal(-1);
43 | }
44 | };
45 |
46 | return {
47 | inputValue,
48 | handleInputChange,
49 | handleKeyPress,
50 | handleCompositionStart,
51 | handleCompositionEnd,
52 | handleInterrupt,
53 | handleMicToggle,
54 | micOn,
55 | };
56 | };
57 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/src/lapppal.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | /**
9 | * プラットフォーム依存機能を抽象化する Cubism Platform Abstraction Layer.
10 | * 抽象化Cubism平台依赖功能。
11 | *
12 | * ファイル読み込みや時刻取得等のプラットフォームに依存する関数をまとめる。
13 | * 将依赖于平台的功能(如文件读取和时间获取)集中在一起。
14 | */
15 | export class LAppPal {
16 | /**
17 | * ファイルをバイトデータとして読みこむ 读取文件为字节数据
18 | *
19 | * @param filePath 読み込み対象ファイルのパス [Path to the file to be read]
20 | * @return
21 | * {
22 | * buffer, 読み込んだバイトデータ [Read byte data]
23 | * size ファイルサイズ [File size]
24 | * }
25 | */
26 | public static loadFileAsBytes(
27 | filePath: string,
28 | callback: (arrayBuffer: ArrayBuffer, size: number) => void
29 | ): void {
30 | fetch(filePath)
31 | .then(response => response.arrayBuffer())
32 | .then(arrayBuffer => callback(arrayBuffer, arrayBuffer.byteLength));
33 | }
34 |
35 | /**
36 | * デルタ時間(前回フレームとの差分)を取得する
37 | * @return デルタ時間[ms]
38 | *
39 | * 获取增量时间(与上一帧的差异)
40 | */
41 | public static getDeltaTime(): number {
42 | return this.s_deltaTime;
43 | }
44 |
45 | public static updateTime(modifyLastFrameTime: boolean = true): void {
46 | this.s_currentFrame = Date.now();
47 | this.s_deltaTime = (this.s_currentFrame - this.s_lastFrame) / 1000;
48 | // this.s_lastFrame = this.s_currentFrame;
49 | if (modifyLastFrameTime === true) {
50 | this.s_lastFrame = this.s_currentFrame;
51 | }
52 | }
53 |
54 | /**
55 | * メッセージを出力する
56 | * @param message 文字列
57 | *
58 | * 输出消息
59 | */
60 | public static printMessage(message: string): void {
61 | console.log(message);
62 | }
63 |
64 | static lastUpdate = Date.now();
65 |
66 | static s_currentFrame = 0.0;
67 | static s_lastFrame = 0.0;
68 | static s_deltaTime = 0.0;
69 | }
70 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/electron/use-input-subtitle.ts:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, KeyboardEvent } from 'react';
2 | import { useChatHistory } from '@/context/chat-history-context';
3 | import { useVAD } from '@/context/vad-context';
4 | import { useMicToggle } from '@/hooks/utils/use-mic-toggle';
5 | import { useTextInput } from '@/hooks/footer/use-text-input';
6 | import { useAiState, AiStateEnum } from '@/context/ai-state-context';
7 | import { useInterrupt } from '@/hooks/utils/use-interrupt';
8 |
9 | export function useInputSubtitle() {
10 | const {
11 | inputText: inputValue,
12 | setInputText: handleChange,
13 | handleKeyPress: handleKey,
14 | handleCompositionStart,
15 | handleCompositionEnd,
16 | handleSend,
17 |
18 | } = useTextInput();
19 |
20 | const { messages } = useChatHistory();
21 | const { startMic, autoStartMicOn } = useVAD();
22 | const { handleMicToggle, micOn } = useMicToggle();
23 | const { aiState, setAiState } = useAiState();
24 | const { interrupt } = useInterrupt();
25 |
26 | const lastAIMessage = messages
27 | .filter((msg) => msg.role === 'ai')
28 | .slice(-1)
29 | .map((msg) => msg.content)[0];
30 |
31 | const hasAIMessages = messages.some((msg) => msg.role === 'ai');
32 |
33 | const handleInterrupt = () => {
34 | interrupt();
35 | if (autoStartMicOn) {
36 | startMic();
37 | }
38 | };
39 |
40 | const handleInputChange = (e: ChangeEvent) => {
41 | handleChange({ target: { value: e.target.value } } as ChangeEvent);
42 | setAiState(AiStateEnum.WAITING);
43 | };
44 |
45 | const handleKeyPress = (e: KeyboardEvent) => {
46 | handleKey(e as any);
47 | };
48 |
49 | return {
50 | inputValue,
51 | handleInputChange,
52 | handleKeyPress,
53 | handleCompositionStart,
54 | handleCompositionEnd,
55 | handleInterrupt,
56 | handleMicToggle,
57 | lastAIMessage,
58 | hasAIMessages,
59 | aiState,
60 | micOn,
61 | handleSend,
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/sidebar/setting/use-live2d-settings.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { ModelInfo, useLive2DConfig } from '@/context/live2d-config-context';
3 |
4 | export const useLive2dSettings = () => {
5 | const Live2DConfigContext = useLive2DConfig();
6 |
7 | const initialModelInfo: ModelInfo = {
8 | url: '',
9 | kScale: 0.5,
10 | initialXshift: 0,
11 | initialYshift: 0,
12 | emotionMap: {},
13 | scrollToResize: true,
14 | };
15 |
16 | const [modelInfo, setModelInfoState] = useState(
17 | Live2DConfigContext?.modelInfo || initialModelInfo,
18 | );
19 | const [originalModelInfo, setOriginalModelInfo] = useState(
20 | Live2DConfigContext?.modelInfo || initialModelInfo,
21 | );
22 |
23 | useEffect(() => {
24 | if (Live2DConfigContext?.modelInfo) {
25 | if (JSON.stringify(Live2DConfigContext.modelInfo) !== JSON.stringify(originalModelInfo)) {
26 | setOriginalModelInfo(Live2DConfigContext.modelInfo);
27 | setModelInfoState(Live2DConfigContext.modelInfo);
28 | }
29 | }
30 | }, [Live2DConfigContext?.modelInfo]);
31 |
32 | useEffect(() => {
33 | if (Live2DConfigContext && modelInfo) {
34 | Live2DConfigContext.setModelInfo(modelInfo);
35 | }
36 | }, [modelInfo.pointerInteractive, modelInfo.scrollToResize]);
37 |
38 | const handleInputChange = (key: keyof ModelInfo, value: ModelInfo[keyof ModelInfo]): void => {
39 | setModelInfoState((prev) => ({ ...prev, [key]: value }));
40 | };
41 |
42 | const handleSave = (): void => {
43 | if (Live2DConfigContext && modelInfo) {
44 | setOriginalModelInfo(modelInfo);
45 | }
46 | };
47 |
48 | const handleCancel = (): void => {
49 | setModelInfoState(originalModelInfo);
50 | if (Live2DConfigContext && originalModelInfo) {
51 | Live2DConfigContext.setModelInfo(originalModelInfo);
52 | }
53 | };
54 |
55 | return {
56 | modelInfo,
57 | handleInputChange,
58 | handleSave,
59 | handleCancel,
60 | };
61 | };
62 |
--------------------------------------------------------------------------------
/src/renderer/src/context/browser-context.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-no-constructed-context-values */
2 | import {
3 | createContext, useContext, useState, ReactNode, useCallback,
4 | } from 'react';
5 |
6 | // Define browser view data structure
7 | export interface BrowserViewData {
8 | debuggerFullscreenUrl: string;
9 | debuggerUrl: string;
10 | pages: {
11 | id: string;
12 | url: string;
13 | faviconUrl: string;
14 | title: string;
15 | debuggerUrl: string;
16 | debuggerFullscreenUrl: string;
17 | }[];
18 | wsUrl: string;
19 | sessionId?: string; // Optional for backward compatibility
20 | }
21 |
22 | // Define context interface
23 | interface BrowserContextType {
24 | browserViewData: BrowserViewData | null;
25 | setBrowserViewData: (data: BrowserViewData) => void;
26 | clearBrowserViewData: () => void;
27 | }
28 |
29 | // Create context with default values
30 | export const BrowserContext = createContext({
31 | browserViewData: null,
32 | setBrowserViewData: () => {},
33 | clearBrowserViewData: () => {},
34 | });
35 |
36 | // Provider component
37 | export function BrowserProvider({ children }: { children: ReactNode }) {
38 | const [browserViewData, setBrowserViewDataState] = useState(null);
39 |
40 | const setBrowserViewData = useCallback((data: BrowserViewData) => {
41 | setBrowserViewDataState(data);
42 | }, []);
43 |
44 | const clearBrowserViewData = useCallback(() => {
45 | setBrowserViewDataState(null);
46 | }, []);
47 |
48 | return (
49 |
56 | {children}
57 |
58 | );
59 | }
60 |
61 | // Custom hook for using the browser context
62 | export function useBrowser() {
63 | const context = useContext(BrowserContext);
64 | if (!context) {
65 | throw new Error('useBrowser must be used within a BrowserProvider');
66 | }
67 | return context;
68 | }
69 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/footer/use-text-input.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useWebSocket } from '@/context/websocket-context';
3 | import { useAiState } from '@/context/ai-state-context';
4 | import { useInterrupt } from '@/components/canvas/live2d';
5 | import { useChatHistory } from '@/context/chat-history-context';
6 | import { useVAD } from '@/context/vad-context';
7 | import { useMediaCapture } from '@/hooks/utils/use-media-capture';
8 |
9 | export function useTextInput() {
10 | const [inputText, setInputText] = useState('');
11 | const [isComposing, setIsComposing] = useState(false);
12 | const wsContext = useWebSocket();
13 | const { aiState } = useAiState();
14 | const { interrupt } = useInterrupt();
15 | const { appendHumanMessage } = useChatHistory();
16 | const { stopMic, autoStopMic } = useVAD();
17 | const { captureAllMedia } = useMediaCapture();
18 |
19 | const handleInputChange = (e: React.ChangeEvent) => {
20 | setInputText(e.target.value);
21 | };
22 |
23 | const handleSend = async () => {
24 | if (!inputText.trim() || !wsContext) return;
25 | if (aiState === 'thinking-speaking') {
26 | interrupt();
27 | }
28 |
29 | const images = await captureAllMedia();
30 |
31 | appendHumanMessage(inputText.trim());
32 | wsContext.sendMessage({
33 | type: 'text-input',
34 | text: inputText.trim(),
35 | images,
36 | });
37 |
38 | if (autoStopMic) stopMic();
39 | setInputText('');
40 | };
41 |
42 | const handleKeyPress = (e: React.KeyboardEvent) => {
43 | if (isComposing) return;
44 |
45 | if (e.key === 'Enter' && !e.shiftKey) {
46 | e.preventDefault();
47 | handleSend();
48 | }
49 | };
50 |
51 | const handleCompositionStart = () => setIsComposing(true);
52 | const handleCompositionEnd = () => setIsComposing(false);
53 |
54 | return {
55 | inputText,
56 | setInputText: handleInputChange,
57 | handleSend,
58 | handleKeyPress,
59 | handleCompositionStart,
60 | handleCompositionEnd,
61 | };
62 | }
63 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Open-LLM-VTuber License 1.0
2 |
3 | Copyright © 2025 Open LLM Vtuber. All rights reserved.
4 |
5 | This software is licensed under the Apache License Version 2.0 (the "Apache License"), subject to the following Additional Conditions.
6 |
7 | Additional Conditions
8 |
9 | 1. Definitions
10 |
11 | "Software" refers to the Open-LLM-VTuber project and all of its associated components, including but not limited to source code, executable files, web applications, desktop clients, documentation, and accompanying assets.
12 |
13 | 2. Permitted Uses (No Additional License Required)
14 |
15 | You are granted permission to use the software freely, without requiring a commercial license, under the following circumstances:
16 |
17 | Non-commercial Uses: Any non-commercial activity including, but not limited to, personal projects, educational purposes, academic research, and non-profit initiatives.
18 |
19 | VTuber Streaming and Content Creation: Activities involving VTuber streaming, content creation, broadcasting, and monetization on platforms such as YouTube, Twitch, Bilibili, or other streaming services, provided revenue primarily derives from content creation and not from direct sale, distribution, or subscription to the software itself.
20 |
21 | 3. Uses Requiring a Commercial License
22 |
23 | You must obtain a separate commercial license from Open LLM Vtuber for any of the following uses:
24 |
25 | Monetized Access or Hosting Services: Providing paid access to or hosting of the software—whether original, modified, or derivative—including SaaS, subscription models, pay-per-use, or paid downloads and installations.
26 |
27 | Commercial Redistribution or Rebranding: Redistributing, repackaging, renaming, or otherwise commercially providing the software (original or modified versions) under any alternative brand, identity, or project name in exchange for payment or other commercial benefits.
28 |
29 | Commercial Embedding or Integration: Incorporating this software, wholly or partially, into any commercial software, hardware, or physical product that is sold, licensed, or otherwise distributed for a fee.
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/LICENSE.md:
--------------------------------------------------------------------------------
1 | ## Definitions
2 |
3 | ### Live2D Cubism Components
4 |
5 | Cubism Web Framework is included in Live2D Cubism Components.
6 |
7 | Cubism Web Framework は Live2D Cubism Components に含まれます。
8 |
9 | Cubism Web Framework 包括在 Live2D Cubism Components 中。
10 |
11 | ## Cubism SDK Release License
12 |
13 | *All business* users must obtain a Cubism SDK Release License. "Business" means an entity with the annual gross revenue more than ten million (10,000,000) JPY for the most recent fiscal year.
14 |
15 | * [Cubism SDK Release License](https://www.live2d.com/en/download/cubism-sdk/release-license/)
16 |
17 | 直近会計年度の売上高が 1000 万円以上の事業者様がご利用になる場合は、Cubism SDK リリースライセンス(出版許諾契約)に同意していただく必要がございます。
18 |
19 | * [Cubism SDK リリースライセンス](https://www.live2d.com/ja/download/cubism-sdk/release-license/)
20 |
21 | 如果您的企业在最近一个会计年度的销售额达到或超过1000万日元,您必须得到Cubism SDK的出版授权许可(出版许可协议)。
22 |
23 | * [Cubism SDK发行许可证](https://www.live2d.com/zh-CHS/download/cubism-sdk/release-license/)
24 |
25 | ## Live2D Open Software License
26 |
27 | Live2D Cubism Components is available under Live2D Open Software License.
28 |
29 | * [Live2D Open Software License](https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html)
30 | * [Live2D Open Software 使用許諾契約書](https://www.live2d.com/eula/live2d-open-software-license-agreement_jp.html)
31 | * [Live2D Open Software 使用授权协议](https://www.live2d.com/eula/live2d-open-software-license-agreement_cn.html)
32 |
33 |
34 | ## Live2D Proprietary Software License
35 |
36 | Live2D Cubism Core is available under Live2D Proprietary Software License.
37 |
38 | * [Live2D Proprietary Software License Agreement](https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_en.html)
39 | * [Live2D Proprietary Software 使用許諾契約書](https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_jp.html)
40 | * [Live2D Proprietary Software 使用授权协议](https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_cn.html)
41 |
42 |
43 | ---
44 |
45 | Please contact us from [here](https://www.live2d.jp/contact/) for more license information.
46 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/color-mode.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { IconButtonProps } from '@chakra-ui/react';
4 | import { ClientOnly, IconButton, Skeleton } from '@chakra-ui/react';
5 | import { ThemeProvider, useTheme } from 'next-themes';
6 | import type { ThemeProviderProps } from 'next-themes';
7 | import * as React from 'react';
8 | import { LuMoon, LuSun } from 'react-icons/lu';
9 |
10 | export interface ColorModeProviderProps extends ThemeProviderProps {}
11 |
12 | export function ColorModeProvider(props: ColorModeProviderProps) {
13 | return (
14 |
15 | );
16 | }
17 |
18 | export function useColorMode() {
19 | const { resolvedTheme, setTheme } = useTheme();
20 | const toggleColorMode = () => {
21 | setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
22 | };
23 | return {
24 | colorMode: resolvedTheme,
25 | setColorMode: setTheme,
26 | toggleColorMode,
27 | };
28 | }
29 |
30 | export function useColorModeValue(light: T, dark: T) {
31 | const { colorMode } = useColorMode();
32 | return colorMode === 'light' ? light : dark;
33 | }
34 |
35 | export function ColorModeIcon() {
36 | const { colorMode } = useColorMode();
37 | return colorMode === 'light' ? : ;
38 | }
39 |
40 | interface ColorModeButtonProps extends Omit {}
41 |
42 | export const ColorModeButton = React.forwardRef<
43 | HTMLButtonElement,
44 | ColorModeButtonProps
45 | >((props, ref) => {
46 | const { toggleColorMode } = useColorMode();
47 | return (
48 | }>
49 |
63 |
64 |
65 |
66 | );
67 | });
68 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/src/type/csmrectf.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | /**
9 | * 矩形形状(座標・長さはfloat値)を定義するクラス
10 | */
11 | export class csmRect {
12 | /**
13 | * コンストラクタ
14 | * @param x 左端X座標
15 | * @param y 上端Y座標
16 | * @param w 幅
17 | * @param h 高さ
18 | */
19 | public constructor(x?: number, y?: number, w?: number, h?: number) {
20 | this.x = x;
21 | this.y = y;
22 | this.width = w;
23 | this.height = h;
24 | }
25 |
26 | /**
27 | * 矩形中央のX座標を取得する
28 | */
29 | public getCenterX(): number {
30 | return this.x + 0.5 * this.width;
31 | }
32 |
33 | /**
34 | * 矩形中央のY座標を取得する
35 | */
36 | public getCenterY(): number {
37 | return this.y + 0.5 * this.height;
38 | }
39 |
40 | /**
41 | * 右側のX座標を取得する
42 | */
43 | public getRight(): number {
44 | return this.x + this.width;
45 | }
46 |
47 | /**
48 | * 下端のY座標を取得する
49 | */
50 | public getBottom(): number {
51 | return this.y + this.height;
52 | }
53 |
54 | /**
55 | * 矩形に値をセットする
56 | * @param r 矩形のインスタンス
57 | */
58 | public setRect(r: csmRect): void {
59 | this.x = r.x;
60 | this.y = r.y;
61 | this.width = r.width;
62 | this.height = r.height;
63 | }
64 |
65 | /**
66 | * 矩形中央を軸にして縦横を拡縮する
67 | * @param w 幅方向に拡縮する量
68 | * @param h 高さ方向に拡縮する量
69 | */
70 | public expand(w: number, h: number) {
71 | this.x -= w;
72 | this.y -= h;
73 | this.width += w * 2.0;
74 | this.height += h * 2.0;
75 | }
76 |
77 | public x: number; // 左端X座標
78 | public y: number; // 上端Y座標
79 | public width: number; // 幅
80 | public height: number; // 高さ
81 | }
82 |
83 | // Namespace definition for compatibility.
84 | import * as $ from './csmrectf';
85 | // eslint-disable-next-line @typescript-eslint/no-namespace
86 | export namespace Live2DCubismFramework {
87 | export const csmRect = $.csmRect;
88 | export type csmRect = $.csmRect;
89 | }
90 |
--------------------------------------------------------------------------------
/src/renderer/src/utils/task-queue.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-promise-executor-return */
2 | /* eslint-disable arrow-parens */
3 | export class TaskQueue {
4 | private queue: (() => Promise)[] = [];
5 |
6 | private running = false;
7 |
8 | private taskInterval: number;
9 |
10 | private pendingComplete = false;
11 |
12 | private activeTasks = new Set>();
13 |
14 | constructor(taskIntervalMs = 3000) {
15 | this.taskInterval = taskIntervalMs;
16 | }
17 |
18 | addTask(task: () => Promise) {
19 | this.queue.push(task);
20 | this.runNextTask();
21 | }
22 |
23 | clearQueue() {
24 | this.queue = [];
25 | this.activeTasks.clear();
26 | this.running = false;
27 | }
28 |
29 | private async runNextTask() {
30 | if (this.running || this.queue.length === 0) {
31 | if (this.queue.length === 0 && this.activeTasks.size === 0 && this.pendingComplete) {
32 | this.pendingComplete = false;
33 | await new Promise(resolve => setTimeout(resolve, this.taskInterval));
34 | }
35 | return;
36 | }
37 |
38 | this.running = true;
39 | const task = this.queue.shift();
40 | if (task) {
41 | const taskPromise = task();
42 | this.activeTasks.add(taskPromise);
43 |
44 | try {
45 | await taskPromise;
46 | await new Promise(resolve => setTimeout(resolve, this.taskInterval));
47 | } catch (error) {
48 | console.error('Task Queue Error', error);
49 | } finally {
50 | this.activeTasks.delete(taskPromise);
51 | this.running = false;
52 | this.runNextTask();
53 | }
54 | }
55 | }
56 |
57 | public hasTask(): boolean {
58 | return this.queue.length > 0 || this.activeTasks.size > 0 || this.running;
59 | }
60 |
61 | public waitForCompletion(): Promise {
62 | this.pendingComplete = true;
63 | return new Promise((resolve) => {
64 | const check = () => {
65 | if (!this.hasTask()) {
66 | resolve();
67 | } else {
68 | setTimeout(check, 100);
69 | }
70 | };
71 | check();
72 | });
73 | }
74 | }
75 |
76 | export const audioTaskQueue = new TaskQueue(20);
77 |
--------------------------------------------------------------------------------
/src/renderer/src/context/subtitle-context.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext, useState, useMemo, useContext, memo,
3 | } from 'react';
4 |
5 | /**
6 | * Subtitle context state interface
7 | * @interface SubtitleState
8 | */
9 | interface SubtitleState {
10 | /** Current subtitle text */
11 | subtitleText: string
12 |
13 | /** Set subtitle text */
14 | setSubtitleText: (text: string) => void
15 |
16 | /** Whether to show subtitle */
17 | showSubtitle: boolean
18 |
19 | /** Toggle subtitle visibility */
20 | setShowSubtitle: (show: boolean) => void
21 | }
22 |
23 | /**
24 | * Default values and constants
25 | */
26 | const DEFAULT_SUBTITLE = {
27 | text: "Hi, I'm some random AI VTuber. Who the hell are ya? "
28 | + 'Ahh, you must be amazed by my awesomeness, right? right?',
29 | };
30 |
31 | /**
32 | * Create the subtitle context
33 | */
34 | export const SubtitleContext = createContext(null);
35 |
36 | /**
37 | * Subtitle Provider Component
38 | * Manages the subtitle display text state
39 | *
40 | * @param {Object} props - Provider props
41 | * @param {React.ReactNode} props.children - Child components
42 | */
43 | export const SubtitleProvider = memo(({ children }: { children: React.ReactNode }) => {
44 | // State management
45 | const [subtitleText, setSubtitleText] = useState(DEFAULT_SUBTITLE.text);
46 | const [showSubtitle, setShowSubtitle] = useState(true);
47 |
48 | // Memoized context value
49 | const contextValue = useMemo(
50 | () => ({
51 | subtitleText,
52 | setSubtitleText,
53 | showSubtitle,
54 | setShowSubtitle,
55 | }),
56 | [subtitleText, showSubtitle],
57 | );
58 |
59 | return (
60 |
61 | {children}
62 |
63 | );
64 | });
65 |
66 | /**
67 | * Custom hook to use the subtitle context
68 | * @throws {Error} If used outside of SubtitleProvider
69 | */
70 | export function useSubtitle() {
71 | const context = useContext(SubtitleContext);
72 |
73 | if (!context) {
74 | throw new Error('useSubtitle must be used within a SubtitleProvider');
75 | }
76 |
77 | return context;
78 | }
79 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/sidebar/use-group-drawer.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { useWebSocket } from '@/context/websocket-context';
4 | import { toaster } from '@/components/ui/toaster';
5 |
6 | export const useGroupDrawer = () => {
7 | const { t } = useTranslation();
8 | const [isOpen, setIsOpen] = useState(false);
9 | const [inviteUid, setInviteUid] = useState('');
10 | const { sendMessage } = useWebSocket();
11 |
12 | // Request latest group information to update the display
13 | const requestGroupInfo = useCallback(() => {
14 | sendMessage({
15 | type: 'request-group-info',
16 | });
17 | }, [sendMessage]);
18 |
19 | const handleInvite = useCallback(async () => {
20 | if (!inviteUid.trim()) {
21 | toaster.create({
22 | title: t('error.enterValidUuid'),
23 | type: 'error',
24 | duration: 2000,
25 | });
26 | return;
27 | }
28 |
29 | sendMessage({
30 | type: 'add-client-to-group',
31 | invitee_uid: inviteUid.trim(),
32 | });
33 | setInviteUid('');
34 |
35 | // Add a small delay to ensure server has processed the operation
36 | setTimeout(requestGroupInfo, 100);
37 | }, [inviteUid, sendMessage, requestGroupInfo, t]);
38 |
39 | const handleRemove = useCallback((targetUid: string) => {
40 | sendMessage({
41 | type: 'remove-client-from-group',
42 | target_uid: targetUid,
43 | });
44 |
45 | // Add a small delay to ensure server has processed the operation
46 | setTimeout(requestGroupInfo, 100);
47 | }, [sendMessage, requestGroupInfo]);
48 |
49 | const handleLeaveGroup = useCallback((selfUid: string) => {
50 | sendMessage({
51 | type: 'remove-client-from-group',
52 | target_uid: selfUid,
53 | });
54 |
55 | // Add a small delay to ensure server has processed the operation
56 | setTimeout(requestGroupInfo, 100);
57 | }, [sendMessage, requestGroupInfo]);
58 |
59 | return {
60 | isOpen,
61 | setIsOpen,
62 | inviteUid,
63 | setInviteUid,
64 | handleInvite,
65 | handleRemove,
66 | handleLeaveGroup,
67 | requestGroupInfo,
68 | };
69 | };
70 |
--------------------------------------------------------------------------------
/electron.vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path';
2 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
3 | import react from '@vitejs/plugin-react';
4 | import { viteStaticCopy } from 'vite-plugin-static-copy'
5 | import { normalizePath } from 'vite';
6 |
7 | export default defineConfig({
8 | main: {
9 | plugins: [externalizeDepsPlugin()],
10 | },
11 | preload: {
12 | plugins: [externalizeDepsPlugin()],
13 | },
14 | renderer: {
15 | resolve: {
16 | alias: {
17 | '@': resolve('src/renderer/src'),
18 | "@framework": resolve("src/renderer/WebSDK/Framework/src"),
19 | "@cubismsdksamples": resolve("src/renderer/WebSDK/src"),
20 | "@motionsyncframework": resolve(
21 | "src/renderer/MotionSync/Framework/src",
22 | ),
23 | "@motionsync": resolve("src/renderer/MotionSync/src"),
24 | "/src": resolve("src/renderer/src"),
25 | },
26 | },
27 | plugins: [
28 | viteStaticCopy({
29 | targets: [
30 | {
31 | src: normalizePath(resolve(__dirname, 'node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js')),
32 | dest: './libs/',
33 | },
34 | {
35 | src: normalizePath(resolve(__dirname, 'node_modules/@ricky0123/vad-web/dist/silero_vad_v5.onnx')),
36 | dest: './libs/',
37 | },
38 | {
39 | src: normalizePath(resolve(__dirname, 'node_modules/@ricky0123/vad-web/dist/silero_vad_legacy.onnx')),
40 | dest: './libs/',
41 | },
42 | {
43 | src: normalizePath(resolve(__dirname, 'node_modules/onnxruntime-web/dist/*.wasm')),
44 | dest: './libs/',
45 | },
46 | {
47 | src: normalizePath(resolve(__dirname, 'src/renderer/WebSDK/Core/live2dcubismcore.js')),
48 | dest: './libs/'
49 | }
50 | ],
51 | }),
52 | react(),
53 | ],
54 | build: {
55 | rollupOptions: {
56 | onwarn(warning, warn) {
57 | if (warning.message.includes('onnxruntime')) {
58 | return;
59 | }
60 | warn(warning);
61 | },
62 | },
63 | },
64 | },
65 | });
66 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/sidebar/use-history-drawer.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { useChatHistory } from '@/context/chat-history-context';
4 | import { useWebSocket, HistoryInfo } from '@/context/websocket-context';
5 | import { toaster } from '@/components/ui/toaster';
6 |
7 | export const useHistoryDrawer = () => {
8 | const { t } = useTranslation();
9 | const [open, setOpen] = useState(false);
10 | const {
11 | historyList,
12 | currentHistoryUid,
13 | setCurrentHistoryUid,
14 | setHistoryList,
15 | messages,
16 | updateHistoryList,
17 | } = useChatHistory();
18 | const { sendMessage } = useWebSocket();
19 |
20 | const fetchAndSetHistory = (uid: string) => {
21 | if (!uid || uid === currentHistoryUid) return;
22 |
23 | if (currentHistoryUid && messages.length > 0) {
24 | const latestMessage = messages[messages.length - 1];
25 | updateHistoryList(currentHistoryUid, latestMessage);
26 | }
27 |
28 | setCurrentHistoryUid(uid);
29 | sendMessage({
30 | type: 'fetch-and-set-history',
31 | history_uid: uid,
32 | });
33 | };
34 |
35 | const deleteHistory = (uid: string) => {
36 | if (uid === currentHistoryUid) {
37 | toaster.create({
38 | title: t('error.cannotDeleteCurrentHistory'),
39 | type: 'warning',
40 | duration: 2000,
41 | });
42 | return;
43 | }
44 |
45 | sendMessage({
46 | type: 'delete-history',
47 | history_uid: uid,
48 | });
49 | setHistoryList(historyList.filter((history) => history.uid !== uid));
50 | };
51 |
52 | const getLatestMessageContent = (history: HistoryInfo) => {
53 | if (history.uid === currentHistoryUid && messages.length > 0) {
54 | const latestMessage = messages[messages.length - 1];
55 | return {
56 | content: latestMessage.content,
57 | timestamp: latestMessage.timestamp,
58 | };
59 | }
60 | return {
61 | content: history.latest_message?.content || '',
62 | timestamp: history.timestamp,
63 | };
64 | };
65 |
66 | return {
67 | open,
68 | setOpen,
69 | historyList,
70 | currentHistoryUid,
71 | fetchAndSetHistory,
72 | deleteHistory,
73 | getLatestMessageContent,
74 | };
75 | };
76 |
--------------------------------------------------------------------------------
/src/renderer/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import './index.css';
3 | import App from './App';
4 | import { LAppAdapter } from '../WebSDK/src/lappadapter';
5 | import './i18n';
6 |
7 | const originalConsoleWarn = console.warn;
8 | console.warn = (...args) => {
9 | if (typeof args[0] === 'string' && args[0].includes('onnxruntime')) {
10 | return;
11 | }
12 | originalConsoleWarn.apply(console, args);
13 | };
14 |
15 | // Suppress specific console.error messages from @chatscope/chat-ui-kit-react
16 | const originalConsoleError = console.error;
17 | const errorMessagesToIgnore = ["Warning: Failed"];
18 | console.error = (...args: any[]) => {
19 | if (typeof args[0] === 'string') {
20 | const shouldIgnore = errorMessagesToIgnore.some(msg => args[0].startsWith(msg));
21 | if (shouldIgnore) {
22 | return; // Suppress the warning
23 | }
24 | }
25 | // Call the original console.error for other messages
26 | originalConsoleError.apply(console, args);
27 | };
28 |
29 | if (typeof window !== 'undefined') {
30 | (window as any).getLAppAdapter = () => LAppAdapter.getInstance();
31 |
32 | // Dynamically load the Live2D Core script
33 | const loadLive2DCore = () => {
34 | return new Promise((resolve, reject) => {
35 | const script = document.createElement('script');
36 | script.src = './libs/live2dcubismcore.js'; // Path to the copied script
37 | script.onload = () => {
38 | console.log('Live2D Cubism Core loaded successfully.');
39 | resolve();
40 | };
41 | script.onerror = (error) => {
42 | console.error('Failed to load Live2D Cubism Core:', error);
43 | reject(error);
44 | };
45 | document.head.appendChild(script);
46 | });
47 | };
48 |
49 | // Load the script and then render the app
50 | loadLive2DCore()
51 | .then(() => {
52 | createRoot(document.getElementById('root')!).render(
53 | ,
54 | );
55 | })
56 | .catch((error) => {
57 | console.error('Application failed to start due to script loading error:', error);
58 | // Optionally render an error message to the user
59 | const rootElement = document.getElementById('root');
60 | if (rootElement) {
61 | rootElement.innerHTML = 'Error loading required components. Please check the console for details.';
62 | }
63 | });
64 | }
65 |
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/browser-panel.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/iframe-has-title */
2 | import { Box, Text } from "@chakra-ui/react";
3 | import { FiGlobe } from "react-icons/fi";
4 | import { useState } from "react";
5 | import { useTranslation } from "react-i18next";
6 | import { Tooltip } from "@/components/ui/tooltip";
7 | import { sidebarStyles } from "./sidebar-styles";
8 | import { useBrowser } from "@/context/browser-context";
9 |
10 | function BrowserPlaceholder() {
11 | const { t } = useTranslation();
12 |
13 | return (
14 |
21 |
22 |
23 | {t('sidebar.noBrowserSession')}
24 |
25 |
26 | );
27 | }
28 |
29 | function BrowserPanel(): JSX.Element {
30 | const { t } = useTranslation();
31 | const { browserViewData } = useBrowser();
32 | const [isHovering, setIsHovering] = useState(false);
33 |
34 | const handleMouseEnter = () => setIsHovering(true);
35 | const handleMouseLeave = () => setIsHovering(false);
36 |
37 | return (
38 |
39 |
40 | {browserViewData && (
41 | {t('sidebar.browserSession')}
42 | )}
43 |
44 |
45 |
54 |
60 | {browserViewData ? (
61 |
67 | ) : (
68 |
69 | )}
70 |
71 |
72 |
73 | );
74 | }
75 |
76 | export default BrowserPanel;
77 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, normalizePath } from 'vite';
2 | import path from 'path';
3 | import react from '@vitejs/plugin-react-swc';
4 |
5 | const createConfig = async (outDir: string) => ({
6 | plugins: [
7 | (await import('vite-plugin-static-copy')).viteStaticCopy({
8 | targets: [
9 | {
10 | src: normalizePath(path.resolve(__dirname, 'node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js')),
11 | dest: './libs/',
12 | },
13 | {
14 | src: normalizePath(path.resolve(__dirname, 'node_modules/@ricky0123/vad-web/dist/silero_vad_v5.onnx')),
15 | dest: './libs/',
16 | },
17 | {
18 | src: normalizePath(path.resolve(__dirname, 'node_modules/@ricky0123/vad-web/dist/silero_vad_legacy.onnx')),
19 | dest: './libs/',
20 | },
21 | {
22 | src: normalizePath(path.resolve(__dirname, 'node_modules/onnxruntime-web/dist/*.wasm')),
23 | dest: './libs/',
24 | },
25 | {
26 | src: normalizePath(path.resolve(__dirname, 'src/renderer/WebSDK/Core/live2dcubismcore.js')),
27 | dest: './libs/',
28 | },
29 | ],
30 | }),
31 | react(),
32 | ],
33 | resolve: {
34 | alias: {
35 | "@": path.resolve(__dirname, "./src/renderer/src"),
36 | "@framework": path.resolve(__dirname, "./src/renderer/WebSDK/Framework/src"),
37 | "@cubismsdksamples": path.resolve(__dirname, "./src/renderer/WebSDK/src"),
38 | "@motionsyncframework": path.resolve(
39 | __dirname,
40 | "./src/renderer/MotionSync/Framework/src",
41 | ),
42 | "@motionsync": path.resolve(__dirname, "./src/renderer/MotionSync/src"),
43 | "/src": path.resolve(__dirname, "./src/renderer/src"),
44 | },
45 | },
46 | root: path.join(__dirname, "src/renderer"),
47 | publicDir: path.join(__dirname, "src/renderer/public"),
48 | base: "./",
49 | server: {
50 | port: 3000,
51 | },
52 | build: {
53 | outDir: path.join(__dirname, outDir),
54 | emptyOutDir: true,
55 | assetsDir: "assets",
56 | rollupOptions: {
57 | input: {
58 | main: path.join(__dirname, "src/renderer/index.html"),
59 | },
60 | },
61 | },
62 | ssr: {
63 | noExternal: ['vite-plugin-static-copy'],
64 | },
65 | });
66 |
67 | export default defineConfig(async ({ mode }) => {
68 | if (mode === 'web') {
69 | return createConfig('dist/web');
70 | }
71 | return createConfig('dist/renderer');
72 | });
73 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/sidebar/setting/use-agent-settings.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react';
2 | import { useProactiveSpeak } from '@/context/proactive-speak-context';
3 |
4 | interface UseAgentSettingsProps {
5 | onSave?: (callback: () => void) => () => void
6 | onCancel?: (callback: () => void) => () => void
7 | }
8 |
9 | export function useAgentSettings({ onSave, onCancel }: UseAgentSettingsProps = {}) {
10 | const { settings: persistedSettings, updateSettings } = useProactiveSpeak();
11 |
12 | const [tempSettings, setTempSettings] = useState({
13 | allowProactiveSpeak: persistedSettings.allowProactiveSpeak,
14 | idleSecondsToSpeak: persistedSettings.idleSecondsToSpeak,
15 | allowButtonTrigger: persistedSettings.allowButtonTrigger,
16 | });
17 |
18 | const [originalSettings, setOriginalSettings] = useState({
19 | ...persistedSettings,
20 | });
21 |
22 | useEffect(() => {
23 | if (persistedSettings) {
24 | setOriginalSettings(persistedSettings);
25 | setTempSettings(persistedSettings);
26 | }
27 | }, [persistedSettings]);
28 |
29 | const handleAllowProactiveSpeakChange = useCallback((checked: boolean) => {
30 | setTempSettings((prev) => ({
31 | ...prev,
32 | allowProactiveSpeak: checked,
33 | }));
34 | }, []);
35 |
36 | const handleIdleSecondsChange = useCallback((value: number) => {
37 | setTempSettings((prev) => ({
38 | ...prev,
39 | idleSecondsToSpeak: value,
40 | }));
41 | }, []);
42 |
43 | const handleAllowButtonTriggerChange = useCallback((checked: boolean) => {
44 | setTempSettings((prev) => ({
45 | ...prev,
46 | allowButtonTrigger: checked,
47 | }));
48 | }, []);
49 |
50 | const handleSave = useCallback(() => {
51 | updateSettings(tempSettings);
52 | setOriginalSettings(tempSettings);
53 | }, [updateSettings, tempSettings]);
54 |
55 | const handleCancel = useCallback(() => {
56 | setTempSettings(originalSettings);
57 | updateSettings(originalSettings);
58 | }, [originalSettings, updateSettings]);
59 |
60 | useEffect(() => {
61 | if (!onSave || !onCancel) return;
62 |
63 | const cleanupSave = onSave(handleSave);
64 | const cleanupCancel = onCancel(handleCancel);
65 |
66 | return () => {
67 | cleanupSave?.();
68 | cleanupCancel?.();
69 | };
70 | }, [onSave, onCancel, handleSave, handleCancel]);
71 |
72 | return {
73 | settings: tempSettings,
74 | handleAllowProactiveSpeakChange,
75 | handleIdleSecondsChange,
76 | handleAllowButtonTriggerChange,
77 | };
78 | }
79 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { GroupProps, SlotRecipeProps } from '@chakra-ui/react';
4 | import { Avatar as ChakraAvatar, Group } from '@chakra-ui/react';
5 | import * as React from 'react';
6 |
7 | type ImageProps = React.ImgHTMLAttributes
8 |
9 | export interface AvatarProps extends ChakraAvatar.RootProps {
10 | name?: string
11 | src?: string
12 | srcSet?: string
13 | loading?: ImageProps['loading']
14 | icon?: React.ReactElement
15 | fallback?: React.ReactNode
16 | }
17 |
18 | export const Avatar = React.forwardRef(
19 | (props, ref) => {
20 | const {
21 | name, src, srcSet, loading, icon, fallback, children, ...rest
22 | } = props;
23 | return (
24 |
25 |
26 | {fallback}
27 |
28 |
29 | {children}
30 |
31 | );
32 | },
33 | );
34 |
35 | interface AvatarFallbackProps extends ChakraAvatar.FallbackProps {
36 | name?: string
37 | icon?: React.ReactElement
38 | }
39 |
40 | const AvatarFallback = React.forwardRef(
41 | (props, ref) => {
42 | const {
43 | name, icon, children, ...rest
44 | } = props;
45 | return (
46 |
47 | {children}
48 | {name != null && children == null && <>{getInitials(name)}>}
49 | {name == null && children == null && (
50 | {icon}
51 | )}
52 |
53 | );
54 | },
55 | );
56 |
57 | function getInitials(name: string) {
58 | const names = name.trim().split(' ');
59 | const firstName = names[0] != null ? names[0] : '';
60 | const lastName = names.length > 1 ? names[names.length - 1] : '';
61 | return firstName && lastName
62 | ? `${firstName.charAt(0)}${lastName.charAt(0)}`
63 | : firstName.charAt(0);
64 | }
65 |
66 | interface AvatarGroupProps extends GroupProps, SlotRecipeProps<'avatar'> {}
67 |
68 | export const AvatarGroup = React.forwardRef(
69 | (props, ref) => {
70 | const {
71 | size, variant, borderless, ...rest
72 | } = props;
73 | return (
74 |
75 |
76 |
77 | );
78 | },
79 | );
80 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/src/type/csmstring.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | /**
9 | * 文字列クラス。
10 | */
11 | export class csmString {
12 | /**
13 | * 文字列を後方に追加する
14 | *
15 | * @param c 追加する文字列
16 | * @return 更新された文字列
17 | */
18 | public append(c: string, length?: number): csmString {
19 | this.s += length !== undefined ? c.substr(0, length) : c;
20 |
21 | return this;
22 | }
23 |
24 | /**
25 | * 文字サイズを拡張して文字を埋める
26 | * @param length 拡張する文字数
27 | * @param v 埋める文字
28 | * @return 更新された文字列
29 | */
30 | public expansion(length: number, v: string): csmString {
31 | for (let i = 0; i < length; i++) {
32 | this.append(v);
33 | }
34 |
35 | return this;
36 | }
37 |
38 | /**
39 | * 文字列の長さをバイト数で取得する
40 | */
41 | public getBytes(): number {
42 | return encodeURIComponent(this.s).replace(/%../g, 'x').length;
43 | }
44 |
45 | /**
46 | * 文字列の長さを返す
47 | */
48 | public getLength(): number {
49 | return this.s.length;
50 | }
51 |
52 | /**
53 | * 文字列比較 <
54 | * @param s 比較する文字列
55 | * @return true: 比較する文字列より小さい
56 | * @return false: 比較する文字列より大きい
57 | */
58 | public isLess(s: csmString): boolean {
59 | return this.s < s.s;
60 | }
61 |
62 | /**
63 | * 文字列比較 >
64 | * @param s 比較する文字列
65 | * @return true: 比較する文字列より大きい
66 | * @return false: 比較する文字列より小さい
67 | */
68 | public isGreat(s: csmString): boolean {
69 | return this.s > s.s;
70 | }
71 |
72 | /**
73 | * 文字列比較 ==
74 | * @param s 比較する文字列
75 | * @return true: 比較する文字列と等しい
76 | * @return false: 比較する文字列と異なる
77 | */
78 | public isEqual(s: string): boolean {
79 | return this.s == s;
80 | }
81 |
82 | /**
83 | * 文字列が空かどうか
84 | * @return true: 空の文字列
85 | * @return false: 値が設定されている
86 | */
87 | public isEmpty(): boolean {
88 | return this.s.length == 0;
89 | }
90 |
91 | /**
92 | * 引数付きコンストラクタ
93 | */
94 | public constructor(s: string) {
95 | this.s = s;
96 | }
97 |
98 | s: string;
99 | }
100 |
101 | // Namespace definition for compatibility.
102 | import * as $ from './csmstring';
103 | // eslint-disable-next-line @typescript-eslint/no-namespace
104 | export namespace Live2DCubismFramework {
105 | export const csmString = $.csmString;
106 | export type csmString = $.csmString;
107 | }
108 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/src/id/cubismid.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | import { csmString } from '../type/csmstring';
9 |
10 | /**
11 | * パラメータ名・パーツ名・Drawable名を保持
12 | *
13 | * パラメータ名・パーツ名・Drawable名を保持するクラス。
14 | *
15 | * @note 指定したID文字列からCubismIdを取得する際はこのクラスの生成メソッドを呼ばず、
16 | * CubismIdManager().getId(id)を使用してください
17 | */
18 | export class CubismId {
19 | /**
20 | * 内部で使用するCubismIdクラス生成メソッド
21 | *
22 | * @param id ID文字列
23 | * @returns CubismId
24 | * @note 指定したID文字列からCubismIdを取得する際は
25 | * CubismIdManager().getId(id)を使用してください
26 | */
27 | public static createIdInternal(id: string | csmString) {
28 | return new CubismId(id);
29 | }
30 |
31 | /**
32 | * ID名を取得する
33 | */
34 | public getString(): csmString {
35 | return this._id;
36 | }
37 |
38 | /**
39 | * idを比較
40 | * @param c 比較するid
41 | * @return 同じならばtrue,異なっていればfalseを返す
42 | */
43 | public isEqual(c: string | csmString | CubismId): boolean {
44 | if (typeof c === 'string') {
45 | return this._id.isEqual(c);
46 | } else if (c instanceof csmString) {
47 | return this._id.isEqual(c.s);
48 | } else if (c instanceof CubismId) {
49 | return this._id.isEqual(c._id.s);
50 | }
51 | return false;
52 | }
53 |
54 | /**
55 | * idを比較
56 | * @param c 比較するid
57 | * @return 同じならばtrue,異なっていればfalseを返す
58 | */
59 | public isNotEqual(c: string | csmString | CubismId): boolean {
60 | if (typeof c == 'string') {
61 | return !this._id.isEqual(c);
62 | } else if (c instanceof csmString) {
63 | return !this._id.isEqual(c.s);
64 | } else if (c instanceof CubismId) {
65 | return !this._id.isEqual(c._id.s);
66 | }
67 | return false;
68 | }
69 |
70 | /**
71 | * プライベートコンストラクタ
72 | *
73 | * @note ユーザーによる生成は許可しません
74 | */
75 | private constructor(id: string | csmString) {
76 | if (typeof id === 'string') {
77 | this._id = new csmString(id);
78 | return;
79 | }
80 |
81 | this._id = id;
82 | }
83 |
84 | private _id: csmString; // ID名
85 | }
86 |
87 | export declare type CubismIdHandle = CubismId;
88 |
89 | // Namespace definition for compatibility.
90 | import * as $ from './cubismid';
91 | // eslint-disable-next-line @typescript-eslint/no-namespace
92 | export namespace Live2DCubismFramework {
93 | export const CubismId = $.CubismId;
94 | export type CubismId = $.CubismId;
95 | export type CubismIdHandle = $.CubismIdHandle;
96 | }
97 |
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/setting/about.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Stack,
4 | Text,
5 | Heading,
6 | HStack,
7 | Icon,
8 | } from '@chakra-ui/react';
9 | import { useTranslation } from 'react-i18next';
10 | import { FaGithub, FaBook } from 'react-icons/fa';
11 | import { settingStyles } from './setting-styles';
12 | import { Button } from '@/components/ui/button';
13 |
14 | function About(): JSX.Element {
15 | const { t } = useTranslation();
16 |
17 | const openExternalLink = (url: string) => {
18 | // Handle external link opening via electron
19 | window.open(url, '_blank');
20 | };
21 |
22 | const appVersion = '1.2.1';
23 | // const appAuthor = 'Open LLM VTuber Team';
24 |
25 | return (
26 |
27 |
28 | {t("settings.about.title")}
29 |
30 |
31 |
32 | {t("settings.about.version")}
33 |
34 | {appVersion}
35 |
36 | {/*
37 | {t('Author')}
38 | {appAuthor}
39 | */}
40 |
41 |
42 |
43 | {t("settings.about.projectLinks")}
44 |
45 |
46 |
56 |
62 |
63 |
64 |
65 |
66 |
69 |
70 |
71 |
72 | {t("settings.about.copyright")}
73 |
74 | © {new Date().getFullYear()} Open LLM VTuber Team
75 |
76 |
77 | );
78 | }
79 |
80 | export default About;
81 |
--------------------------------------------------------------------------------
/src/renderer/src/utils/audio-manager.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Global audio manager for handling audio playback and interruption
3 | * This ensures all components share the same audio reference
4 | */
5 | class AudioManager {
6 | private currentAudio: HTMLAudioElement | null = null;
7 | private currentModel: any | null = null;
8 |
9 | /**
10 | * Set the current playing audio
11 | */
12 | setCurrentAudio(audio: HTMLAudioElement, model: any) {
13 | this.currentAudio = audio;
14 | this.currentModel = model;
15 | }
16 |
17 | /**
18 | * Stop current audio playback and lip sync
19 | */
20 | stopCurrentAudioAndLipSync() {
21 | if (this.currentAudio) {
22 | console.log('[AudioManager] Stopping current audio and lip sync');
23 | const audio = this.currentAudio;
24 |
25 | // Stop audio playback
26 | audio.pause();
27 | audio.src = '';
28 | audio.load();
29 |
30 | // Stop Live2D lip sync
31 | const model = this.currentModel;
32 | if (model && model._wavFileHandler) {
33 | try {
34 | // Release PCM data to stop lip sync calculation in update()
35 | model._wavFileHandler.releasePcmData();
36 | console.log('[AudioManager] Called _wavFileHandler.releasePcmData()');
37 |
38 | // Additional reset of state variables as fallback
39 | model._wavFileHandler._lastRms = 0.0;
40 | model._wavFileHandler._sampleOffset = 0;
41 | model._wavFileHandler._userTimeSeconds = 0.0;
42 | console.log('[AudioManager] Also reset _lastRms, _sampleOffset, _userTimeSeconds as fallback');
43 | } catch (e) {
44 | console.error('[AudioManager] Error stopping/resetting wavFileHandler:', e);
45 | }
46 | } else if (model) {
47 | console.warn('[AudioManager] Current model does not have _wavFileHandler to stop/reset.');
48 | } else {
49 | console.log('[AudioManager] No associated model found to stop lip sync.');
50 | }
51 |
52 | // Clear references
53 | this.currentAudio = null;
54 | this.currentModel = null;
55 | } else {
56 | console.log('[AudioManager] No current audio playing to stop.');
57 | }
58 | }
59 |
60 | /**
61 | * Clear the current audio reference (called when audio ends naturally)
62 | */
63 | clearCurrentAudio(audio: HTMLAudioElement) {
64 | if (this.currentAudio === audio) {
65 | this.currentAudio = null;
66 | this.currentModel = null;
67 | }
68 | }
69 |
70 | /**
71 | * Check if there's currently playing audio
72 | */
73 | hasCurrentAudio(): boolean {
74 | return this.currentAudio !== null;
75 | }
76 | }
77 |
78 | // Export singleton instance
79 | export const audioManager = new AudioManager();
--------------------------------------------------------------------------------
/src/renderer/src/context/character-config-context.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext, useContext, useState, useMemo, useEffect, useCallback,
3 | } from 'react';
4 |
5 | /**
6 | * Character configuration file interface
7 | * @interface ConfigFile
8 | */
9 | export interface ConfigFile {
10 | filename: string;
11 | name: string;
12 | }
13 |
14 | /**
15 | * Character configuration context state interface
16 | * @interface CharacterConfigState
17 | */
18 | interface CharacterConfigState {
19 | confName: string;
20 | confUid: string;
21 | configFiles: ConfigFile[];
22 | setConfName: (name: string) => void;
23 | setConfUid: (uid: string) => void;
24 | setConfigFiles: (files: ConfigFile[]) => void;
25 | getFilenameByName: (name: string) => string | undefined;
26 | }
27 |
28 | /**
29 | * Default values and constants
30 | */
31 | const DEFAULT_CONFIG = {
32 | confName: '',
33 | confUid: '',
34 | configFiles: [] as ConfigFile[],
35 | };
36 |
37 | /**
38 | * Create the character configuration context
39 | */
40 | export const ConfigContext = createContext(null);
41 |
42 | /**
43 | * Character Configuration Provider Component
44 | * @param {Object} props - Provider props
45 | * @param {React.ReactNode} props.children - Child components
46 | */
47 | export function CharacterConfigProvider({ children }: { children: React.ReactNode }) {
48 | const [confName, setConfName] = useState(DEFAULT_CONFIG.confName);
49 | const [confUid, setConfUid] = useState(DEFAULT_CONFIG.confUid);
50 | const [configFiles, setConfigFiles] = useState(DEFAULT_CONFIG.configFiles);
51 |
52 | const getFilenameByName = useCallback(
53 | (name: string) => configFiles.find((config) => config.name === name)?.filename,
54 | [configFiles],
55 | );
56 |
57 | // Memoized context value
58 | const contextValue = useMemo(
59 | () => ({
60 | confName,
61 | confUid,
62 | configFiles,
63 | setConfName,
64 | setConfUid,
65 | setConfigFiles,
66 | getFilenameByName,
67 | }),
68 | [confName, confUid, configFiles, getFilenameByName],
69 | );
70 |
71 | useEffect(() => {
72 | (window.api as any)?.updateConfigFiles?.(configFiles);
73 | }, [configFiles]);
74 |
75 | return (
76 |
77 | {children}
78 |
79 | );
80 | }
81 |
82 | /**
83 | * Custom hook to use the character configuration context
84 | * @throws {Error} If used outside of CharacterConfigProvider
85 | */
86 | export function useConfig() {
87 | const context = useContext(ConfigContext);
88 |
89 | if (!context) {
90 | throw new Error('useConfig must be used within a CharacterConfigProvider');
91 | }
92 |
93 | return context;
94 | }
95 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | import { Slider as ChakraSlider, For, HStack } from '@chakra-ui/react';
2 | import * as React from 'react';
3 |
4 | export interface SliderProps extends ChakraSlider.RootProps {
5 | marks?: Array
6 | label?: React.ReactNode
7 | showValue?: boolean
8 | }
9 |
10 | export const Slider = React.forwardRef(
11 | (props, ref) => {
12 | const {
13 | marks: marksProp, label, showValue, ...rest
14 | } = props;
15 | const value = props.defaultValue ?? props.value;
16 |
17 | const marks = marksProp?.map((mark) => {
18 | if (typeof mark === 'number') return { value: mark, label: undefined };
19 | return mark;
20 | });
21 |
22 | const hasMarkLabel = !!marks?.some((mark) => mark.label);
23 |
24 | return (
25 |
26 | {label && !showValue && (
27 | {label}
28 | )}
29 | {label && showValue && (
30 |
31 | {label}
32 |
33 |
34 | )}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | },
45 | );
46 |
47 | function SliderThumbs(props: { value?: number[] }) {
48 | const { value } = props;
49 | return (
50 |
51 | {(_, index) => (
52 |
53 |
54 |
55 | )}
56 |
57 | );
58 | }
59 |
60 | interface SliderMarksProps {
61 | marks?: Array
62 | }
63 |
64 | const SliderMarks = React.forwardRef(
65 | (props, ref) => {
66 | const { marks } = props;
67 | if (!marks?.length) return null;
68 |
69 | return (
70 |
71 | {marks.map((mark, index) => {
72 | const value = typeof mark === 'number' ? mark : mark.value;
73 | const label = typeof mark === 'number' ? undefined : mark.label;
74 | return (
75 |
76 |
77 | {label}
78 |
79 | );
80 | })}
81 |
82 | );
83 | },
84 | );
85 |
--------------------------------------------------------------------------------
/src/renderer/src/context/websocket-context.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-no-constructed-context-values */
2 | import React, { useContext, useCallback, useState, useEffect } from 'react';
3 | import { wsService } from '@/services/websocket-service';
4 | import { useLocalStorage } from '@/hooks/utils/use-local-storage';
5 |
6 | const DEFAULT_WS_URL = 'ws://127.0.0.1:12393/client-ws';
7 | const DEFAULT_BASE_URL = 'http://127.0.0.1:12393';
8 |
9 | export interface HistoryInfo {
10 | uid: string;
11 | latest_message: {
12 | role: 'human' | 'ai';
13 | timestamp: string;
14 | content: string;
15 | } | null;
16 | timestamp: string | null;
17 | }
18 |
19 | interface WebSocketContextProps {
20 | sendMessage: (message: object) => void;
21 | wsState: string;
22 | reconnect: () => void;
23 | wsUrl: string;
24 | setWsUrl: (url: string) => void;
25 | baseUrl: string;
26 | setBaseUrl: (url: string) => void;
27 | }
28 |
29 | export const WebSocketContext = React.createContext({
30 | sendMessage: wsService.sendMessage.bind(wsService),
31 | wsState: 'CLOSED',
32 | reconnect: () => wsService.connect(DEFAULT_WS_URL),
33 | wsUrl: DEFAULT_WS_URL,
34 | setWsUrl: () => {},
35 | baseUrl: DEFAULT_BASE_URL,
36 | setBaseUrl: () => {},
37 | });
38 |
39 | export function useWebSocket() {
40 | const context = useContext(WebSocketContext);
41 | if (!context) {
42 | throw new Error('useWebSocket must be used within a WebSocketProvider');
43 | }
44 | return context;
45 | }
46 |
47 | export const defaultWsUrl = DEFAULT_WS_URL;
48 | export const defaultBaseUrl = DEFAULT_BASE_URL;
49 |
50 | export function WebSocketProvider({ children }: { children: React.ReactNode }) {
51 | const [wsUrl, setWsUrl] = useLocalStorage('wsUrl', DEFAULT_WS_URL);
52 | const [baseUrl, setBaseUrl] = useLocalStorage('baseUrl', DEFAULT_BASE_URL);
53 | const [wsState, setWsState] = useState<'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED'>(wsService.getCurrentState());
54 |
55 | // Subscribe to state changes from the websocket service
56 | useEffect(() => {
57 | const subscription = wsService.onStateChange((newState) => {
58 | setWsState(newState);
59 | });
60 |
61 | // Set initial state
62 | setWsState(wsService.getCurrentState());
63 |
64 | return () => {
65 | subscription.unsubscribe();
66 | };
67 | }, []);
68 |
69 | const handleSetWsUrl = useCallback((url: string) => {
70 | setWsUrl(url);
71 | wsService.connect(url);
72 | }, [setWsUrl]);
73 |
74 | const value = {
75 | sendMessage: wsService.sendMessage.bind(wsService),
76 | wsState,
77 | reconnect: () => wsService.connect(wsUrl),
78 | wsUrl,
79 | setWsUrl: handleSetWsUrl,
80 | baseUrl,
81 | setBaseUrl,
82 | };
83 |
84 | return (
85 |
86 | {children}
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/src/renderer/src/components/electron/electron-style.tsx:
--------------------------------------------------------------------------------
1 | import { SystemStyleObject } from '@chakra-ui/react';
2 |
3 | export const inputSubtitleStyles = {
4 | container: {
5 | display: 'flex',
6 | alignItems: 'flex-end',
7 | justifyContent: 'center',
8 | maxW: 'fit-content',
9 | position: 'absolute' as const,
10 | bottom: '120px',
11 | left: '50%',
12 | transform: 'translateX(-50%)',
13 | zIndex: 1000,
14 | userSelect: 'none',
15 | willChange: 'transform',
16 | padding: 0,
17 | },
18 |
19 | box: {
20 | w: '400px',
21 | rounded: 'xl',
22 | overflow: 'hidden',
23 | boxShadow: 'lg',
24 | bg: 'blackAlpha.700',
25 | backdropFilter: 'blur(8px)',
26 | css: { WebkitUserSelect: 'none' },
27 | },
28 |
29 | messageStack: {
30 | p: '3',
31 | gap: 1,
32 | alignItems: 'stretch',
33 | justify: 'flex-end',
34 | },
35 |
36 | messageText: {
37 | color: 'white',
38 | fontSize: 'sm',
39 | lineHeight: '1.5',
40 | transition: 'all 0.3s',
41 | },
42 |
43 | statusBox: {
44 | bg: 'blackAlpha.600',
45 | p: '3',
46 | borderTop: '1px',
47 | borderColor: 'whiteAlpha.200',
48 | },
49 |
50 | statusText: {
51 | fontSize: 'xs',
52 | color: 'whiteAlpha.800',
53 | transition: 'all 0.3s',
54 | },
55 |
56 | iconButton: {
57 | size: 'xs',
58 | variant: 'ghost',
59 | color: 'whiteAlpha.800',
60 | _hover: { bg: 'whiteAlpha.200' },
61 | },
62 |
63 | inputBox: {
64 | bg: 'blackAlpha.600',
65 | borderTop: '1px',
66 | borderColor: 'whiteAlpha.200',
67 | },
68 |
69 | input: {
70 | size: 'sm',
71 | bg: 'blackAlpha.500',
72 | color: 'white',
73 | _placeholder: { color: 'whiteAlpha.500' },
74 | borderColor: 'whiteAlpha.300',
75 | _focus: {
76 | borderColor: 'whiteAlpha.500',
77 | outline: 'none',
78 | },
79 | flex: '1',
80 | },
81 |
82 | sendButton: {
83 | p: '1.5',
84 | bg: 'blackAlpha.500',
85 | rounded: 'lg',
86 | _hover: { bg: 'blackAlpha.600' },
87 | transition: 'colors',
88 | color: 'whiteAlpha.800',
89 | size: 'sm',
90 | },
91 |
92 | draggableContainer: (isDragging: boolean): SystemStyleObject => ({
93 | cursor: isDragging ? 'grabbing' : 'grab',
94 | transition: isDragging ? 'none' : 'transform 0.1s ease',
95 | _active: { cursor: 'grabbing' },
96 | }),
97 |
98 | closeButton: {
99 | position: 'absolute' as const,
100 | top: 0,
101 | right: 0,
102 | size: '2xs',
103 | minW: '6',
104 | height: '6',
105 | padding: 0,
106 | variant: 'ghost',
107 | color: 'whiteAlpha.400',
108 | bg: 'transparent',
109 | _hover: {
110 | bg: 'blackAlpha.300',
111 | color: 'whiteAlpha.800',
112 | },
113 | zIndex: 10,
114 | },
115 | } as const;
116 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/canvas/use-live2d-expression.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { ModelInfo } from '@/context/live2d-config-context';
3 |
4 | /**
5 | * Custom hook for handling Live2D model expressions
6 | */
7 | export const useLive2DExpression = () => {
8 | /**
9 | * Set expression for Live2D model
10 | * @param expressionValue - Expression name (string) or index (number)
11 | * @param lappAdapter - LAppAdapter instance
12 | * @param logMessage - Optional message to log on success
13 | */
14 | const setExpression = useCallback((
15 | expressionValue: string | number,
16 | lappAdapter: any,
17 | logMessage?: string,
18 | ) => {
19 | try {
20 | if (typeof expressionValue === 'string') {
21 | // Set expression by name
22 | lappAdapter.setExpression(expressionValue);
23 | } else if (typeof expressionValue === 'number') {
24 | // Set expression by index
25 | const expressionName = lappAdapter.getExpressionName(expressionValue);
26 | if (expressionName) {
27 | lappAdapter.setExpression(expressionName);
28 | }
29 | }
30 | if (logMessage) {
31 | console.log(logMessage);
32 | }
33 | } catch (error) {
34 | console.error('Failed to set expression:', error);
35 | }
36 | }, []);
37 |
38 | /**
39 | * Reset expression to default
40 | * @param lappAdapter - LAppAdapter instance
41 | * @param modelInfo - Current model information
42 | */
43 | const resetExpression = useCallback((
44 | lappAdapter: any,
45 | modelInfo?: ModelInfo,
46 | ) => {
47 | if (!lappAdapter) return;
48 |
49 | try {
50 | // Check if model is loaded and has expressions
51 | const model = lappAdapter.getModel();
52 | if (!model || !model._modelSetting) {
53 | console.log('Model or model settings not loaded yet, skipping expression reset');
54 | return;
55 | }
56 |
57 | // If model has a default emotion defined, use it
58 | if (modelInfo?.defaultEmotion !== undefined) {
59 | setExpression(
60 | modelInfo.defaultEmotion,
61 | lappAdapter,
62 | `Reset expression to default: ${modelInfo.defaultEmotion}`,
63 | );
64 | } else {
65 | // Check if model has any expressions before trying to get the first one
66 | const expressionCount = lappAdapter.getExpressionCount();
67 | if (expressionCount > 0) {
68 | const defaultExpressionName = lappAdapter.getExpressionName(0);
69 | if (defaultExpressionName) {
70 | setExpression(
71 | defaultExpressionName,
72 | lappAdapter,
73 | );
74 | }
75 | }
76 | }
77 | } catch (error) {
78 | console.log('Failed to reset expression:', error);
79 | }
80 | }, [setExpression]);
81 |
82 | return {
83 | setExpression,
84 | resetExpression,
85 | };
86 | };
87 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/src/id/cubismidmanager.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | import { csmString } from '../type/csmstring';
9 | import { csmVector } from '../type/csmvector';
10 | import { CubismId } from './cubismid';
11 |
12 | /**
13 | * ID名の管理
14 | *
15 | * ID名を管理する。
16 | */
17 | export class CubismIdManager {
18 | /**
19 | * コンストラクタ
20 | */
21 | public constructor() {
22 | this._ids = new csmVector();
23 | }
24 |
25 | /**
26 | * デストラクタ相当の処理
27 | */
28 | public release(): void {
29 | for (let i = 0; i < this._ids.getSize(); ++i) {
30 | this._ids.set(i, void 0);
31 | }
32 | this._ids = null;
33 | }
34 |
35 | /**
36 | * ID名をリストから登録
37 | *
38 | * @param ids ID名リスト
39 | * @param count IDの個数
40 | */
41 | public registerIds(ids: string[] | csmString[]): void {
42 | for (let i = 0; i < ids.length; i++) {
43 | this.registerId(ids[i]);
44 | }
45 | }
46 |
47 | /**
48 | * ID名を登録
49 | *
50 | * @param id ID名
51 | */
52 | public registerId(id: string | csmString): CubismId {
53 | let result: CubismId = null;
54 |
55 | if ('string' == typeof id) {
56 | if ((result = this.findId(id)) != null) {
57 | return result;
58 | }
59 |
60 | result = CubismId.createIdInternal(id);
61 | this._ids.pushBack(result);
62 | } else {
63 | return this.registerId(id.s);
64 | }
65 |
66 | return result;
67 | }
68 |
69 | /**
70 | * ID名からIDを取得する
71 | *
72 | * @param id ID名
73 | */
74 | public getId(id: csmString | string): CubismId {
75 | return this.registerId(id);
76 | }
77 |
78 | /**
79 | * ID名からIDの確認
80 | *
81 | * @return true 存在する
82 | * @return false 存在しない
83 | */
84 | public isExist(id: csmString | string): boolean {
85 | if ('string' == typeof id) {
86 | return this.findId(id) != null;
87 | }
88 | return this.isExist(id.s);
89 | }
90 |
91 | /**
92 | * ID名からIDを検索する。
93 | *
94 | * @param id ID名
95 | * @return 登録されているID。なければNULL。
96 | */
97 | private findId(id: string): CubismId {
98 | for (let i = 0; i < this._ids.getSize(); ++i) {
99 | if (this._ids.at(i).getString().isEqual(id)) {
100 | return this._ids.at(i);
101 | }
102 | }
103 |
104 | return null;
105 | }
106 |
107 | private _ids: csmVector; // 登録されているIDのリスト
108 | }
109 |
110 | // Namespace definition for compatibility.
111 | import * as $ from './cubismidmanager';
112 | // eslint-disable-next-line @typescript-eslint/no-namespace
113 | export namespace Live2DCubismFramework {
114 | export const CubismIdManager = $.CubismIdManager;
115 | export type CubismIdManager = $.CubismIdManager;
116 | }
117 |
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/setting/asr.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | /* eslint-disable react/require-default-props */
3 | import { Stack } from '@chakra-ui/react';
4 | import { useEffect } from 'react';
5 | import { useTranslation } from 'react-i18next';
6 | import { settingStyles } from './setting-styles';
7 | import { useASRSettings } from '@/hooks/sidebar/setting/use-asr-settings';
8 | import { SwitchField, NumberField } from './common';
9 |
10 | interface ASRProps {
11 | onSave?: (callback: () => void) => () => void
12 | onCancel?: (callback: () => void) => () => void
13 | }
14 |
15 | function ASR({ onSave, onCancel }: ASRProps): JSX.Element {
16 | const { t } = useTranslation();
17 | const {
18 | localSettings,
19 | autoStopMic,
20 | autoStartMicOn,
21 | autoStartMicOnConvEnd,
22 | setAutoStopMic,
23 | setAutoStartMicOn,
24 | setAutoStartMicOnConvEnd,
25 | handleInputChange,
26 | handleSave,
27 | handleCancel,
28 | } = useASRSettings();
29 |
30 | useEffect(() => {
31 | if (!onSave || !onCancel) return;
32 |
33 | const cleanupSave = onSave(handleSave);
34 | const cleanupCancel = onCancel(handleCancel);
35 |
36 | return (): void => {
37 | cleanupSave?.();
38 | cleanupCancel?.();
39 | };
40 | }, [onSave, onCancel, handleSave, handleCancel]);
41 |
42 | return (
43 |
44 |
49 |
50 |
55 |
56 |
61 |
62 | handleInputChange('positiveSpeechThreshold', value)}
67 | min={1}
68 | max={100}
69 | />
70 |
71 | handleInputChange('negativeSpeechThreshold', value)}
76 | min={0}
77 | max={100}
78 | />
79 |
80 | handleInputChange('redemptionFrames', value)}
85 | min={1}
86 | max={100}
87 | />
88 |
89 | );
90 | }
91 |
92 | export default ASR;
93 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "open-llm-vtuber",
3 | "version": "1.2.1",
4 | "description": "An Electron application with React and TypeScript",
5 | "main": "./out/main/index.js",
6 | "author": "example.com",
7 | "homepage": "https://electron-vite.org",
8 | "scripts": {
9 | "format": "prettier --write .",
10 | "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
11 | "lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
12 | "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
13 | "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
14 | "typecheck": "npm run typecheck:node && npm run typecheck:web",
15 | "start": "electron-vite preview",
16 | "dev": "electron-vite dev",
17 | "build": "electron-vite build",
18 | "postinstall": "electron-builder install-app-deps",
19 | "build:unpack": "npm run build && electron-builder --dir",
20 | "build:win": "npm run build && electron-builder --win",
21 | "build:mac": "electron-vite build && electron-builder --mac",
22 | "build:linux": "electron-vite build && electron-builder --linux",
23 | "dev:web": "vite serve src/renderer --config vite.config.ts",
24 | "build:web": "vite build --config vite.config.ts --mode web",
25 | "extract-translations": "i18next-scanner --config i18next-scanner.config.js"
26 | },
27 | "dependencies": {
28 | "@chakra-ui/react": "^3.2.3",
29 | "@chatscope/chat-ui-kit-react": "^2.0.3",
30 | "@chatscope/chat-ui-kit-styles": "^1.4.0",
31 | "@electron-toolkit/preload": "^3.0.1",
32 | "@electron-toolkit/utils": "^3.0.0",
33 | "@emotion/react": "^11.14.0",
34 | "@ricky0123/vad-web": "^0.0.24",
35 | "clsx": "^2.1.1",
36 | "cors": "^2.8.5",
37 | "date-fns": "^4.1.0",
38 | "electron-updater": "^6.1.7",
39 | "eslint": "^8.57.1",
40 | "express": "^5.1.0",
41 | "framer-motion": "^11.14.4",
42 | "i18next": "^25.0.1",
43 | "i18next-browser-languagedetector": "^8.0.5",
44 | "i18next-scanner": "^4.6.0",
45 | "next-themes": "^0.4.4",
46 | "onnxruntime-web": "1.14.0",
47 | "react": "^18.3.1",
48 | "react-dom": "^18.3.1",
49 | "react-i18next": "^15.5.1",
50 | "react-icons": "^5.4.0",
51 | "rxjs": "^7.8.1",
52 | "ws": "^8.18.3",
53 | "zustand": "^5.0.3"
54 | },
55 | "devDependencies": {
56 | "@electron-toolkit/eslint-config-prettier": "^2.0.0",
57 | "@electron-toolkit/eslint-config-ts": "^2.0.0",
58 | "@electron-toolkit/tsconfig": "^1.0.1",
59 | "@types/node": "^20.14.8",
60 | "@types/react": "^18.3.3",
61 | "@types/react-dom": "^18.3.0",
62 | "@vitejs/plugin-react": "^4.3.1",
63 | "@vitejs/plugin-react-swc": "^3.7.2",
64 | "electron": "^31.0.2",
65 | "electron-builder": "^24.13.3",
66 | "electron-vite": "^2.3.0",
67 | "eslint-plugin-react": "^7.34.3",
68 | "prettier": "^3.3.2",
69 | "react": "^18.3.1",
70 | "react-dom": "^18.3.1",
71 | "typescript": "~5.5.2",
72 | "vite": "^5.3.1",
73 | "vite-plugin-static-copy": "^2.3.1"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/renderer/src/components/electron/title-bar.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Box, IconButton } from '@chakra-ui/react';
3 | import {
4 | FiMinus, FiMaximize2, FiMinimize2, FiX, FiChevronsDown,
5 | } from 'react-icons/fi';
6 | import { layoutStyles } from '@/layout';
7 |
8 | function TitleBar(): JSX.Element {
9 | const [isMaximized, setIsMaximized] = useState(false);
10 | const [isFullScreen, setIsFullScreen] = useState(false);
11 | const isMac = window.electron?.process.platform === 'darwin';
12 |
13 | useEffect(() => {
14 | const handleMaximizeChange = (_event: any, maximized: boolean) => {
15 | setIsMaximized(maximized);
16 | };
17 |
18 | const handleFullScreenChange = (_event: any, fullScreen: boolean) => {
19 | setIsFullScreen(fullScreen);
20 | };
21 |
22 | window.electron?.ipcRenderer.on('window-maximized-change', handleMaximizeChange);
23 | window.electron?.ipcRenderer.on('window-fullscreen-change', handleFullScreenChange);
24 |
25 | return () => {
26 | window.electron?.ipcRenderer.removeAllListeners('window-maximized-change');
27 | window.electron?.ipcRenderer.removeAllListeners('window-fullscreen-change');
28 | };
29 | }, []);
30 |
31 | const handleMaximizeClick = () => {
32 | if (isFullScreen) {
33 | window.electron?.ipcRenderer.send('window-unfullscreen');
34 | } else {
35 | window.electron?.ipcRenderer.send('window-maximize');
36 | }
37 | };
38 |
39 | const getButtonLabel = () => {
40 | if (isFullScreen) return 'Exit Full Screen';
41 | if (isMaximized) return 'Restore';
42 | return 'Maximize';
43 | };
44 |
45 | const getButtonIcon = () => {
46 | if (isFullScreen) return ;
47 | if (isMaximized) return ;
48 | return ;
49 | };
50 |
51 | if (isMac) {
52 | return (
53 |
54 |
55 | Open LLM VTuber
56 |
57 |
58 | );
59 | }
60 |
61 | return (
62 |
63 |
64 | Open LLM VTuber
65 |
66 |
67 | window.electron?.ipcRenderer.send('window-minimize')}
70 | aria-label="Minimize"
71 | >
72 |
73 |
74 |
79 | {getButtonIcon()}
80 |
81 | window.electron?.ipcRenderer.send('window-close')}
84 | aria-label="Close"
85 | >
86 |
87 |
88 |
89 |
90 | );
91 | }
92 |
93 | export default TitleBar;
94 |
--------------------------------------------------------------------------------
/src/renderer/src/components/footer/footer-styles.tsx:
--------------------------------------------------------------------------------
1 | import { SystemStyleObject } from '@chakra-ui/react';
2 |
3 | interface FooterStyles {
4 | container: (isCollapsed: boolean) => SystemStyleObject
5 | toggleButton: SystemStyleObject
6 | actionButton: SystemStyleObject
7 | input: SystemStyleObject
8 | attachButton: SystemStyleObject
9 | }
10 |
11 | interface AIIndicatorStyles {
12 | container: SystemStyleObject
13 | text: SystemStyleObject
14 | }
15 |
16 | export const footerStyles: {
17 | footer: FooterStyles
18 | aiIndicator: AIIndicatorStyles
19 | } = {
20 | footer: {
21 | container: (isCollapsed) => ({
22 | bg: isCollapsed ? 'transparent' : 'gray.800',
23 | borderTopRadius: isCollapsed ? 'none' : 'lg',
24 | transform: isCollapsed ? 'translateY(calc(100% - 24px))' : 'translateY(0)',
25 | transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
26 | height: '100%',
27 | position: 'relative',
28 | overflow: isCollapsed ? 'visible' : 'hidden',
29 | pb: '4',
30 | }),
31 | toggleButton: {
32 | height: '24px',
33 | display: 'flex',
34 | alignItems: 'center',
35 | justifyContent: 'center',
36 | cursor: 'pointer',
37 | color: 'whiteAlpha.700',
38 | _hover: { color: 'white' },
39 | bg: 'transparent',
40 | transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
41 | },
42 | actionButton: {
43 | borderRadius: '12px',
44 | width: '50px',
45 | height: '50px',
46 | minW: '50px',
47 | },
48 | input: {
49 | bg: 'gray.700',
50 | border: 'none',
51 | height: '80px',
52 | borderRadius: '12px',
53 | fontSize: '18px',
54 | pl: '12',
55 | pr: '4',
56 | color: 'whiteAlpha.900',
57 | _placeholder: {
58 | color: 'whiteAlpha.500',
59 | },
60 | _focus: {
61 | border: 'none',
62 | bg: 'gray.700',
63 | },
64 | resize: 'none',
65 | minHeight: '80px',
66 | maxHeight: '80px',
67 | py: '0',
68 | display: 'flex',
69 | alignItems: 'center',
70 | paddingTop: '28px',
71 | lineHeight: '1.4',
72 | },
73 | attachButton: {
74 | position: 'absolute',
75 | left: '1',
76 | top: '50%',
77 | transform: 'translateY(-50%)',
78 | color: 'whiteAlpha.700',
79 | zIndex: 2,
80 | _hover: {
81 | bg: 'transparent',
82 | color: 'white',
83 | },
84 | },
85 | },
86 | aiIndicator: {
87 | container: {
88 | bg: '#7C5CFF',
89 | color: 'white',
90 | width: '110px',
91 | height: '30px',
92 | borderRadius: '12px',
93 | display: 'flex',
94 | alignItems: 'center',
95 | justifyContent: 'center',
96 | boxShadow: '0 2px 6px rgba(0,0,0,0.1)',
97 | overflow: 'hidden',
98 | },
99 | text: {
100 | fontSize: '12px',
101 | whiteSpace: 'nowrap',
102 | overflow: 'hidden',
103 | textOverflow: 'ellipsis',
104 | },
105 | },
106 | };
107 |
--------------------------------------------------------------------------------
/src/renderer/src/context/proactive-speak-context.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext, useContext, ReactNode, useEffect, useRef, useCallback, useMemo,
3 | } from 'react';
4 | import { useLocalStorage } from '@/hooks/utils/use-local-storage';
5 | import { useTriggerSpeak } from '@/hooks/utils/use-trigger-speak';
6 | import { useAiState, AiStateEnum } from '@/context/ai-state-context';
7 |
8 | interface ProactiveSpeakSettings {
9 | allowButtonTrigger: boolean;
10 | allowProactiveSpeak: boolean
11 | idleSecondsToSpeak: number
12 | }
13 |
14 | interface ProactiveSpeakContextType {
15 | settings: ProactiveSpeakSettings
16 | updateSettings: (newSettings: ProactiveSpeakSettings) => void
17 | }
18 |
19 | const defaultSettings: ProactiveSpeakSettings = {
20 | allowProactiveSpeak: false,
21 | idleSecondsToSpeak: 5,
22 | allowButtonTrigger: false,
23 | };
24 |
25 | export const ProactiveSpeakContext = createContext(null);
26 |
27 | export function ProactiveSpeakProvider({ children }: { children: ReactNode }) {
28 | const [settings, setSettings] = useLocalStorage(
29 | 'proactiveSpeakSettings',
30 | defaultSettings,
31 | );
32 |
33 | const { aiState } = useAiState();
34 | const { sendTriggerSignal } = useTriggerSpeak();
35 |
36 | const idleTimerRef = useRef(null);
37 | const idleStartTimeRef = useRef(null);
38 |
39 | const clearIdleTimer = useCallback(() => {
40 | if (idleTimerRef.current) {
41 | clearTimeout(idleTimerRef.current);
42 | idleTimerRef.current = null;
43 | }
44 | idleStartTimeRef.current = null;
45 | }, []);
46 |
47 | const startIdleTimer = useCallback(() => {
48 | clearIdleTimer();
49 |
50 | if (!settings.allowProactiveSpeak) return;
51 |
52 | idleStartTimeRef.current = Date.now();
53 | idleTimerRef.current = setTimeout(() => {
54 | const actualIdleTime = (Date.now() - idleStartTimeRef.current!) / 1000;
55 | sendTriggerSignal(actualIdleTime);
56 | }, settings.idleSecondsToSpeak * 1000);
57 | }, [settings.allowProactiveSpeak, settings.idleSecondsToSpeak, sendTriggerSignal, clearIdleTimer]);
58 |
59 | useEffect(() => {
60 | if (aiState === AiStateEnum.IDLE) {
61 | startIdleTimer();
62 | } else {
63 | clearIdleTimer();
64 | }
65 | }, [aiState, startIdleTimer, clearIdleTimer]);
66 |
67 | useEffect(() => () => {
68 | clearIdleTimer();
69 | }, [clearIdleTimer]);
70 |
71 | const updateSettings = useCallback((newSettings: ProactiveSpeakSettings) => {
72 | setSettings(newSettings);
73 | }, [setSettings]);
74 |
75 | const contextValue = useMemo(() => ({
76 | settings,
77 | updateSettings,
78 | }), [settings, updateSettings]);
79 |
80 | return (
81 |
82 | {children}
83 |
84 | );
85 | }
86 |
87 | export function useProactiveSpeak() {
88 | const context = useContext(ProactiveSpeakContext);
89 | if (!context) {
90 | throw new Error('useProactiveSpeak must be used within a ProactiveSpeakProvider');
91 | }
92 | return context;
93 | }
94 |
--------------------------------------------------------------------------------
/src/renderer/src/context/screen-capture-context.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useState, ReactNode } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { toaster } from "@/components/ui/toaster";
4 |
5 | interface ScreenCaptureContextType {
6 | stream: MediaStream | null;
7 | isStreaming: boolean;
8 | error: string;
9 | startCapture: () => Promise;
10 | stopCapture: () => void;
11 | }
12 |
13 | const ScreenCaptureContext = createContext(undefined);
14 |
15 | export function ScreenCaptureProvider({ children }: { children: ReactNode }) {
16 | const { t } = useTranslation();
17 | const [stream, setStream] = useState(null);
18 | const [isStreaming, setIsStreaming] = useState(false);
19 | const [error, setError] = useState('');
20 |
21 | const startCapture = async () => {
22 | try {
23 | let mediaStream: MediaStream;
24 |
25 | if (window.electron) {
26 | const sourceId = await window.electron.ipcRenderer.invoke('get-screen-capture');
27 |
28 | const displayMediaOptions: DisplayMediaStreamOptions = {
29 | video: {
30 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
31 | // @ts-expect-error
32 | mandatory: {
33 | chromeMediaSource: "desktop",
34 | chromeMediaSourceId: sourceId,
35 | minWidth: 1280,
36 | maxWidth: 1280,
37 | minHeight: 720,
38 | maxHeight: 720,
39 | },
40 | },
41 | audio: false,
42 | };
43 |
44 | mediaStream = await navigator.mediaDevices.getUserMedia(displayMediaOptions);
45 | } else {
46 | const displayMediaOptions: DisplayMediaStreamOptions = {
47 | video: true,
48 | audio: false,
49 | };
50 | mediaStream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
51 | }
52 |
53 | setStream(mediaStream);
54 | setIsStreaming(true);
55 | setError('');
56 | } catch (err) {
57 | setError(t('error.failedStartScreenCapture'));
58 | toaster.create({
59 | title: `${t('error.failedStartScreenCapture')}: ${err}`,
60 | type: 'error',
61 | duration: 2000,
62 | });
63 | console.error(err);
64 | }
65 | };
66 |
67 | const stopCapture = () => {
68 | if (stream) {
69 | stream.getTracks().forEach((track) => track.stop());
70 | setStream(null);
71 | setIsStreaming(false);
72 | }
73 | };
74 |
75 | return (
76 |
86 | {children}
87 |
88 | );
89 | }
90 |
91 | export const useScreenCaptureContext = () => {
92 | const context = useContext(ScreenCaptureContext);
93 | if (context === undefined) {
94 | throw new Error('useScreenCaptureContext must be used within a ScreenCaptureProvider');
95 | }
96 | return context;
97 | };
98 |
--------------------------------------------------------------------------------
/src/renderer/src/components/ui/clipboard.tsx:
--------------------------------------------------------------------------------
1 | import type { ButtonProps, InputProps } from "@chakra-ui/react"
2 | import {
3 | Button,
4 | Clipboard as ChakraClipboard,
5 | IconButton,
6 | Input,
7 | } from "@chakra-ui/react"
8 | import * as React from "react"
9 | import { LuCheck, LuClipboard, LuLink } from "react-icons/lu"
10 |
11 | const ClipboardIcon = React.forwardRef<
12 | HTMLDivElement,
13 | ChakraClipboard.IndicatorProps
14 | >(function ClipboardIcon(props, ref) {
15 | return (
16 | } {...props} ref={ref}>
17 |
18 |
19 | )
20 | })
21 |
22 | const ClipboardCopyText = React.forwardRef<
23 | HTMLDivElement,
24 | ChakraClipboard.IndicatorProps
25 | >(function ClipboardCopyText(props, ref) {
26 | return (
27 |
28 | Copy
29 |
30 | )
31 | })
32 |
33 | export const ClipboardLabel = React.forwardRef<
34 | HTMLLabelElement,
35 | ChakraClipboard.LabelProps
36 | >(function ClipboardLabel(props, ref) {
37 | return (
38 |
46 | )
47 | })
48 |
49 | export const ClipboardButton = React.forwardRef(
50 | function ClipboardButton(props, ref) {
51 | return (
52 |
53 |
57 |
58 | )
59 | },
60 | )
61 |
62 | export const ClipboardLink = React.forwardRef(
63 | function ClipboardLink(props, ref) {
64 | return (
65 |
66 |
79 |
80 | )
81 | },
82 | )
83 |
84 | export const ClipboardIconButton = React.forwardRef<
85 | HTMLButtonElement,
86 | ButtonProps
87 | >(function ClipboardIconButton(props, ref) {
88 | return (
89 |
90 |
91 |
92 |
93 |
94 |
95 | )
96 | })
97 |
98 | export const ClipboardInput = React.forwardRef(
99 | function ClipboardInputElement(props, ref) {
100 | return (
101 |
102 |
103 |
104 | )
105 | },
106 | )
107 |
108 | export const ClipboardRoot = ChakraClipboard.Root
109 |
--------------------------------------------------------------------------------
/src/renderer/src/context/mode-context.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useState, useEffect } from 'react';
2 | import { toaster } from '../components/ui/toaster';
3 |
4 | export type ModeType = 'window' | 'pet' | 'phone';
5 |
6 | interface ModeContextType {
7 | mode: ModeType;
8 | setMode: (mode: ModeType) => void;
9 | isElectron: boolean;
10 | }
11 |
12 | const ModeContext = createContext(undefined);
13 |
14 | export const ModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
15 | const [mode, setModeState] = useState('window');
16 | const isElectron = window.api !== undefined;
17 |
18 | const setMode = (newMode: ModeType) => {
19 | if (newMode === 'pet' && !isElectron) {
20 | toaster.create({
21 | title: "Pet mode unavailable",
22 | description: "Pet mode is only available in the desktop application",
23 | type: "info",
24 | duration: 2000,
25 | });
26 | return;
27 | }
28 |
29 | // Phone mode can work in both web and desktop
30 | if (newMode === 'phone') {
31 | setModeState(newMode);
32 | return;
33 | }
34 |
35 | // Electron-specific mode change
36 | if (isElectron && window.api) {
37 | (window.api as any).setMode(newMode);
38 | } else {
39 | setModeState(newMode);
40 | }
41 | };
42 |
43 | // Listen for mode changes from main process
44 | useEffect(() => {
45 | if (isElectron && window.electron) {
46 | const handlePreModeChange = (_event: any, newMode: ModeType) => {
47 | // Use double requestAnimationFrame to ensure UI is ready
48 | requestAnimationFrame(() => {
49 | requestAnimationFrame(() => {
50 | // Tell main process we're ready for the actual mode change
51 | window.electron?.ipcRenderer.send('renderer-ready-for-mode-change', newMode);
52 | });
53 | });
54 | };
55 |
56 | const handleModeChanged = (_event: any, newMode: ModeType) => {
57 | setModeState(newMode);
58 | // After mode is set, tell main process the UI has been updated
59 | requestAnimationFrame(() => {
60 | requestAnimationFrame(() => {
61 | window.electron?.ipcRenderer.send('mode-change-rendered');
62 | });
63 | });
64 | };
65 |
66 | // Listen for pre-mode-changed and mode-changed events
67 | window.electron.ipcRenderer.on('pre-mode-changed', handlePreModeChange);
68 | window.electron.ipcRenderer.on('mode-changed', handleModeChanged);
69 |
70 | return () => {
71 | if (window.electron) {
72 | window.electron.ipcRenderer.removeListener('pre-mode-changed', handlePreModeChange);
73 | window.electron.ipcRenderer.removeListener('mode-changed', handleModeChanged);
74 | }
75 | };
76 | }
77 | return undefined;
78 | }, [isElectron]);
79 |
80 | return (
81 |
82 | {children}
83 |
84 | );
85 | };
86 |
87 | export const useMode = (): ModeContextType => {
88 | const context = useContext(ModeContext);
89 | if (context === undefined) {
90 | throw new Error('useMode must be used within a ModeProvider');
91 | }
92 | return context;
93 | };
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/src/model/cubismmodeluserdatajson.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | import { CubismIdHandle } from '../id/cubismid';
9 | import { CubismFramework } from '../live2dcubismframework';
10 | import { CubismJson } from '../utils/cubismjson';
11 |
12 | const Meta = 'Meta';
13 | const UserDataCount = 'UserDataCount';
14 | const TotalUserDataSize = 'TotalUserDataSize';
15 | const UserData = 'UserData';
16 | const Target = 'Target';
17 | const Id = 'Id';
18 | const Value = 'Value';
19 |
20 | export class CubismModelUserDataJson {
21 | /**
22 | * コンストラクタ
23 | * @param buffer userdata3.jsonが読み込まれているバッファ
24 | * @param size バッファのサイズ
25 | */
26 | public constructor(buffer: ArrayBuffer, size: number) {
27 | this._json = CubismJson.create(buffer, size);
28 | }
29 |
30 | /**
31 | * デストラクタ相当の処理
32 | */
33 | public release(): void {
34 | CubismJson.delete(this._json);
35 | }
36 |
37 | /**
38 | * ユーザーデータ個数の取得
39 | * @return ユーザーデータの個数
40 | */
41 | public getUserDataCount(): number {
42 | return this._json
43 | .getRoot()
44 | .getValueByString(Meta)
45 | .getValueByString(UserDataCount)
46 | .toInt();
47 | }
48 |
49 | /**
50 | * ユーザーデータ総文字列数の取得
51 | *
52 | * @return ユーザーデータ総文字列数
53 | */
54 | public getTotalUserDataSize(): number {
55 | return this._json
56 | .getRoot()
57 | .getValueByString(Meta)
58 | .getValueByString(TotalUserDataSize)
59 | .toInt();
60 | }
61 |
62 | /**
63 | * ユーザーデータのタイプの取得
64 | *
65 | * @return ユーザーデータのタイプ
66 | */
67 | public getUserDataTargetType(i: number): string {
68 | return this._json
69 | .getRoot()
70 | .getValueByString(UserData)
71 | .getValueByIndex(i)
72 | .getValueByString(Target)
73 | .getRawString();
74 | }
75 |
76 | /**
77 | * ユーザーデータのターゲットIDの取得
78 | *
79 | * @param i インデックス
80 | * @return ユーザーデータターゲットID
81 | */
82 | public getUserDataId(i: number): CubismIdHandle {
83 | return CubismFramework.getIdManager().getId(
84 | this._json
85 | .getRoot()
86 | .getValueByString(UserData)
87 | .getValueByIndex(i)
88 | .getValueByString(Id)
89 | .getRawString()
90 | );
91 | }
92 |
93 | /**
94 | * ユーザーデータの文字列の取得
95 | *
96 | * @param i インデックス
97 | * @return ユーザーデータ
98 | */
99 | public getUserDataValue(i: number): string {
100 | return this._json
101 | .getRoot()
102 | .getValueByString(UserData)
103 | .getValueByIndex(i)
104 | .getValueByString(Value)
105 | .getRawString();
106 | }
107 |
108 | private _json: CubismJson;
109 | }
110 |
111 | // Namespace definition for compatibility.
112 | import * as $ from './cubismmodeluserdatajson';
113 | // eslint-disable-next-line @typescript-eslint/no-namespace
114 | export namespace Live2DCubismFramework {
115 | export const CubismModelUserDataJson = $.CubismModelUserDataJson;
116 | export type CubismModelUserDataJson = $.CubismModelUserDataJson;
117 | }
118 |
--------------------------------------------------------------------------------
/src/renderer/src/components/phone-call/phone-call-styles.ts:
--------------------------------------------------------------------------------
1 | // Phone call interface styles optimized for mobile devices
2 |
3 | export const phoneCallStyles = {
4 | container: {
5 | width: '100vw',
6 | height: '100vh',
7 | bg: 'linear-gradient(135deg, gray.900 0%, gray.800 50%, gray.900 100%)',
8 | color: 'white',
9 | display: 'flex',
10 | flexDirection: 'column' as const,
11 | overflow: 'hidden',
12 | position: 'relative' as const,
13 | },
14 |
15 | statusBar: {
16 | position: 'absolute' as const,
17 | top: 0,
18 | left: 0,
19 | right: 0,
20 | zIndex: 10,
21 | py: 4,
22 | px: 4,
23 | bg: 'blackAlpha.300',
24 | backdropFilter: 'blur(10px)',
25 | },
26 |
27 | statusIndicator: {
28 | width: '8px',
29 | height: '8px',
30 | borderRadius: 'full',
31 | animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
32 | },
33 |
34 | statusText: {
35 | fontSize: 'sm',
36 | fontWeight: 'medium',
37 | color: 'whiteAlpha.900',
38 | },
39 |
40 | characterContainer: {
41 | flex: 1,
42 | position: 'relative' as const,
43 | width: '100%',
44 | height: '100%',
45 | display: 'flex',
46 | alignItems: 'center',
47 | justifyContent: 'center',
48 | mt: '60px', // Account for status bar
49 | mb: '120px', // Account for controls
50 | },
51 |
52 | controlsContainer: {
53 | position: 'absolute' as const,
54 | bottom: 0,
55 | left: 0,
56 | right: 0,
57 | zIndex: 10,
58 | p: 6,
59 | pb: { base: 8, '@supports(env(safe-area-inset-bottom))': 'calc(2rem + env(safe-area-inset-bottom))' },
60 | bg: 'linear-gradient(to top, blackAlpha.800 0%, blackAlpha.400 70%, transparent 100%)',
61 | backdropFilter: 'blur(10px)',
62 | },
63 |
64 | controlsRow: {
65 | gap: 8,
66 | align: 'center',
67 | justify: 'center',
68 | width: '100%',
69 | maxW: '320px',
70 | mx: 'auto',
71 | },
72 |
73 | controlButton: {
74 | size: 'lg',
75 | borderRadius: 'full',
76 | color: 'white',
77 | transition: 'all 0.2s',
78 | _active: {
79 | transform: 'scale(0.95)',
80 | },
81 | },
82 |
83 | muteButton: (micOn: boolean) => ({
84 | bg: micOn ? 'whiteAlpha.200' : 'red.500',
85 | _hover: {
86 | bg: micOn ? 'whiteAlpha.300' : 'red.600',
87 | transform: 'scale(1.05)',
88 | },
89 | border: micOn ? '2px solid' : 'none',
90 | borderColor: micOn ? 'green.400' : 'transparent',
91 | }),
92 |
93 | hangUpButton: {
94 | bg: 'red.500',
95 | size: 'xl',
96 | width: '80px',
97 | height: '80px',
98 | _hover: {
99 | bg: 'red.600',
100 | transform: 'scale(1.05)',
101 | },
102 | _active: {
103 | transform: 'scale(0.95)',
104 | },
105 | shadow: 'lg',
106 | },
107 |
108 | speakerButton: (speakerOn: boolean) => ({
109 | bg: speakerOn ? 'blue.500' : 'whiteAlpha.200',
110 | _hover: {
111 | bg: speakerOn ? 'blue.600' : 'whiteAlpha.300',
112 | transform: 'scale(1.05)',
113 | },
114 | }),
115 | };
116 |
117 | // CSS keyframes for animations (would be added to global styles)
118 | export const phoneCallKeyframes = `
119 | @keyframes pulse {
120 | 0%, 100% {
121 | opacity: 1;
122 | }
123 | 50% {
124 | opacity: 0.5;
125 | }
126 | }
127 | `;
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/screen-panel.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import { Box, Text } from "@chakra-ui/react";
3 | import { FiMonitor } from "react-icons/fi";
4 | import { useTranslation } from "react-i18next";
5 | import { Tooltip } from "@/components/ui/tooltip";
6 | import { sidebarStyles } from "./sidebar-styles";
7 | import { useCaptureScreen } from "@/hooks/sidebar/use-capture-screen";
8 |
9 | // Reusable components
10 | function ScreenIndicator() {
11 | const { t } = useTranslation();
12 |
13 | return (
14 |
15 |
22 | {t('sidebar.screen')}
23 |
24 | );
25 | }
26 |
27 | function ScreenPlaceholder() {
28 | const { t } = useTranslation();
29 |
30 | return (
31 |
38 |
39 |
40 | {t('footer.screenControl')}
41 |
42 |
43 | );
44 | }
45 |
46 | function VideoStream({
47 | videoRef,
48 | isStreaming,
49 | }: {
50 | videoRef: React.RefObject;
51 | isStreaming: boolean;
52 | }) {
53 | return (
54 |
62 | );
63 | }
64 |
65 | function ScreenPanel(): JSX.Element {
66 | const { t } = useTranslation();
67 | const {
68 | videoRef,
69 | error,
70 | isHovering,
71 | isStreaming,
72 | toggleCapture,
73 | handleMouseEnter,
74 | handleMouseLeave,
75 | } = useCaptureScreen();
76 |
77 | return (
78 |
79 |
80 | {isStreaming && }
81 |
82 |
83 |
92 |
103 | {error ? (
104 |
105 | {error}
106 |
107 | ) : (
108 | <>
109 |
110 | {!isStreaming && }
111 | >
112 | )}
113 |
114 |
115 |
116 | );
117 | }
118 |
119 | export default ScreenPanel;
120 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/src/utils/cubismjsonextension.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | import {
9 | JsonArray,
10 | JsonBoolean,
11 | JsonFloat,
12 | JsonMap,
13 | JsonNullvalue,
14 | JsonString,
15 | Value
16 | } from './cubismjson';
17 |
18 | /**
19 | * CubismJsonで実装されているJsonパーサを使用せず、
20 | * TypeScript標準のJsonパーサなどを使用し出力された結果を
21 | * Cubism SDKで定義されているJSONエレメントの要素に
22 | * 置き換える処理をするクラス。
23 | */
24 | export class CubismJsonExtension {
25 | static parseJsonObject(obj: Value, map: JsonMap) {
26 | Object.keys(obj).forEach((key) => {
27 | if (typeof obj[key] == 'boolean') {
28 | const convValue = Boolean(obj[key]);
29 | map.put(key, new JsonBoolean(convValue));
30 | } else if (typeof obj[key] == 'string') {
31 | const convValue = String(obj[key]);
32 | map.put(key, new JsonString(convValue));
33 | } else if (typeof obj[key] == 'number') {
34 | const convValue = Number(obj[key]);
35 | map.put(key, new JsonFloat(convValue));
36 | } else if (obj[key] instanceof Array) {
37 | map.put(key, CubismJsonExtension.parseJsonArray(obj[key]));
38 | } else if (obj[key] instanceof Object) {
39 | map.put(
40 | key,
41 | CubismJsonExtension.parseJsonObject(obj[key], new JsonMap())
42 | );
43 | } else if (obj[key] == null) {
44 | map.put(key, new JsonNullvalue());
45 | } else {
46 | // どれにも当てはまらない場合でも処理する
47 | map.put(key, obj[key]);
48 | }
49 | });
50 | return map;
51 | }
52 |
53 | protected static parseJsonArray(obj: Value) {
54 | const arr = new JsonArray();
55 | Object.keys(obj).forEach((key) => {
56 | const convKey = Number(key);
57 | if (typeof convKey == 'number') {
58 | if (typeof obj[key] == 'boolean') {
59 | const convValue = Boolean(obj[key]);
60 | arr.add(new JsonBoolean(convValue));
61 | } else if (typeof obj[key] == 'string') {
62 | const convValue = String(obj[key]);
63 | arr.add(new JsonString(convValue));
64 | } else if (typeof obj[key] == 'number') {
65 | const convValue = Number(obj[key]);
66 | arr.add(new JsonFloat(convValue));
67 | } else if (obj[key] instanceof Array) {
68 | arr.add(this.parseJsonArray(obj[key]));
69 | } else if (obj[key] instanceof Object) {
70 | arr.add(this.parseJsonObject(obj[key], new JsonMap()));
71 | } else if (obj[key] == null) {
72 | arr.add(new JsonNullvalue());
73 | } else {
74 | // どれにも当てはまらない場合でも処理する
75 | arr.add(obj[key]);
76 | }
77 | } else if (obj[key] instanceof Array) {
78 | arr.add(this.parseJsonArray(obj[key]));
79 | } else if (obj[key] instanceof Object) {
80 | arr.add(this.parseJsonObject(obj[key], new JsonMap()));
81 | } else if (obj[key] == null) {
82 | arr.add(new JsonNullvalue());
83 | } else {
84 | const convValue = Array(obj[key]);
85 | // 配列ともObjectとも判定できなかった場合でも処理する
86 | for (let i = 0; i < convValue.length; i++) {
87 | arr.add(convValue[i]);
88 | }
89 | }
90 | });
91 | return arr;
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/renderer/src/components/sidebar/camera-panel.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { Box, Text } from '@chakra-ui/react';
3 | import { FiCamera } from 'react-icons/fi';
4 | import { useTranslation } from 'react-i18next';
5 | import { Tooltip } from '@/components/ui/tooltip';
6 | import { sidebarStyles } from './sidebar-styles';
7 | import { useCameraPanel } from '@/hooks/sidebar/use-camera-panel';
8 |
9 | // Reusable components
10 | function LiveIndicator() {
11 | const { t } = useTranslation();
12 |
13 | return (
14 |
15 |
16 | {t('sidebar.live')}
17 |
18 | );
19 | }
20 |
21 | function CameraPlaceholder() {
22 | const { t } = useTranslation();
23 |
24 | return (
25 |
32 |
33 |
34 | {t('footer.cameraControl')}
35 |
36 |
37 | );
38 | }
39 |
40 | function VideoStream({
41 | videoRef,
42 | isStreaming,
43 | }: {
44 | videoRef: React.RefObject
45 | isStreaming: boolean
46 | }) {
47 | return (
48 |
56 | );
57 | }
58 |
59 | // Main component
60 | function CameraPanel(): JSX.Element {
61 | const { t } = useTranslation();
62 | const {
63 | videoRef,
64 | error,
65 | isHovering,
66 | isStreaming,
67 | stream,
68 | toggleCamera,
69 | handleMouseEnter,
70 | handleMouseLeave,
71 | } = useCameraPanel();
72 |
73 | useEffect(() => {
74 | if (videoRef.current) {
75 | videoRef.current.srcObject = stream;
76 | }
77 | }, [stream]);
78 |
79 | return (
80 |
81 |
82 | {isStreaming && }
83 |
84 |
85 |
90 |
101 | {error ? (
102 |
103 | {error}
104 |
105 | ) : (
106 | <>
107 |
108 | {!isStreaming && }
109 | >
110 | )}
111 |
112 |
113 |
114 | );
115 | }
116 |
117 | export default CameraPanel;
118 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/electron/use-draggable.ts:
--------------------------------------------------------------------------------
1 | import { useState, useRef } from 'react';
2 | import { useMode } from '@/context/mode-context';
3 | interface Position {
4 | x: number
5 | y: number
6 | }
7 |
8 | interface UseDraggableProps {
9 | componentId: string
10 | }
11 |
12 | /**
13 | * A custom hook that provides dragging functionality for components
14 | * @param isPet - Whether the current mode is pet mode or not
15 | * @param componentId - Unique identifier for the component
16 | * @returns Object containing refs and handlers for dragging functionality
17 | */
18 | export function useDraggable({ componentId }: UseDraggableProps) {
19 | const { mode } = useMode();
20 | const isPet = mode === 'pet';
21 | // Track if the element is currently being dragged
22 | const [isDragging, setIsDragging] = useState(false);
23 |
24 | // Refs to store position data that persists between renders
25 | const positionRef = useRef({ x: 0, y: 0 });
26 | const dragStartRef = useRef({ x: 0, y: 0 });
27 | const elementRef = useRef(null);
28 |
29 | /**
30 | * Handle mouse enter event for pet mode
31 | * Notifies the electron main process about hover state
32 | */
33 | const handleMouseEnter = () => {
34 | if (isPet) {
35 | (window.api as any)?.updateComponentHover(componentId, true);
36 | }
37 | };
38 |
39 | /**
40 | * Handle mouse leave event for pet mode
41 | * Notifies the electron main process about hover state
42 | */
43 | const handleMouseLeave = () => {
44 | if (isPet && !isDragging) {
45 | (window.api as any)?.updateComponentHover(componentId, false);
46 | }
47 | };
48 |
49 | /**
50 | * Handles the start of dragging operation
51 | * Sets up mouse move and mouse up listeners
52 | */
53 | const handleMouseDown = (e: React.MouseEvent) => {
54 | setIsDragging(true);
55 | // Calculate the initial offset
56 | dragStartRef.current = {
57 | x: e.clientX - positionRef.current.x,
58 | y: e.clientY - positionRef.current.y,
59 | };
60 |
61 | /**
62 | * Updates element position during mouse movement
63 | */
64 | const handleMouseMove = (moveEvent: MouseEvent) => {
65 | if (!elementRef.current) return;
66 |
67 | // Calculate new position
68 | const newPosition = {
69 | x: moveEvent.clientX - dragStartRef.current.x,
70 | y: moveEvent.clientY - dragStartRef.current.y,
71 | };
72 |
73 | // Update position ref for future calculations
74 | positionRef.current = newPosition;
75 |
76 | elementRef.current.style.transform = `translateX(-50%) translate(${positionRef.current.x}px, ${positionRef.current.y}px)`;
77 | };
78 |
79 | /**
80 | * Cleanup function for mouse events
81 | */
82 | const handleMouseUp = () => {
83 | setIsDragging(false);
84 | // Clean up event listeners
85 | document.removeEventListener('mousemove', handleMouseMove, true);
86 | document.removeEventListener('mouseup', handleMouseUp, true);
87 | };
88 |
89 | // Add event listeners with capture phase
90 | document.addEventListener('mousemove', handleMouseMove, true);
91 | document.addEventListener('mouseup', handleMouseUp, true);
92 | };
93 |
94 | return {
95 | elementRef,
96 | isDragging,
97 | handleMouseDown,
98 | handleMouseEnter,
99 | handleMouseLeave,
100 | };
101 | }
102 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/src/utils/cubismstring.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | export class CubismString {
9 | /**
10 | * 標準出力の書式を適用した文字列を取得する。
11 | * @param format 標準出力の書式指定文字列
12 | * @param ...args 書式指定文字列に渡す文字列
13 | * @return 書式を適用した文字列
14 | */
15 | public static getFormatedString(format: string, ...args: any[]): string {
16 | const ret: string = format;
17 | return ret.replace(
18 | /\{(\d+)\}/g,
19 | (
20 | m,
21 | k // m="{0}", k="0"
22 | ) => {
23 | return args[k];
24 | }
25 | );
26 | }
27 |
28 | /**
29 | * textがstartWordで始まっているかどうかを返す
30 | * @param test 検査対象の文字列
31 | * @param startWord 比較対象の文字列
32 | * @return true textがstartWordで始まっている
33 | * @return false textがstartWordで始まっていない
34 | */
35 | public static isStartWith(text: string, startWord: string): boolean {
36 | let textIndex = 0;
37 | let startWordIndex = 0;
38 | while (startWord[startWordIndex] != '\0') {
39 | if (
40 | text[textIndex] == '\0' ||
41 | text[textIndex++] != startWord[startWordIndex++]
42 | ) {
43 | return false;
44 | }
45 | }
46 | return false;
47 | }
48 |
49 | /**
50 | * position位置の文字から数字を解析する。
51 | *
52 | * @param string 文字列
53 | * @param length 文字列の長さ
54 | * @param position 解析したい文字の位置
55 | * @param outEndPos 一文字も読み込まなかった場合はエラー値(-1)が入る
56 | * @return 解析結果の数値
57 | */
58 | public static stringToFloat(
59 | string: string,
60 | length: number,
61 | position: number,
62 | outEndPos: number[]
63 | ): number {
64 | let i: number = position;
65 | let minus = false; // マイナスフラグ
66 | let period = false;
67 | let v1 = 0;
68 |
69 | //負号の確認
70 | let c: number = parseInt(string[i]);
71 | if (c < 0) {
72 | minus = true;
73 | i++;
74 | }
75 |
76 | //整数部の確認
77 | for (; i < length; i++) {
78 | const c = string[i];
79 | if (0 <= parseInt(c) && parseInt(c) <= 9) {
80 | v1 = v1 * 10 + (parseInt(c) - 0);
81 | } else if (c == '.') {
82 | period = true;
83 | i++;
84 | break;
85 | } else {
86 | break;
87 | }
88 | }
89 |
90 | //小数部の確認
91 | if (period) {
92 | let mul = 0.1;
93 | for (; i < length; i++) {
94 | c = parseFloat(string[i]) & 0xff;
95 | if (0 <= c && c <= 9) {
96 | v1 += mul * (c - 0);
97 | } else {
98 | break;
99 | }
100 | mul *= 0.1; //一桁下げる
101 | if (!c) break;
102 | }
103 | }
104 |
105 | if (i == position) {
106 | //一文字も読み込まなかった場合
107 | outEndPos[0] = -1; //エラー値が入るので呼び出し元で適切な処理を行う
108 | return 0;
109 | }
110 |
111 | if (minus) v1 = -v1;
112 |
113 | outEndPos[0] = i;
114 | return v1;
115 | }
116 |
117 | /**
118 | * コンストラクタ呼び出し不可な静的クラスにする。
119 | */
120 | private constructor() {}
121 | }
122 |
123 | // Namespace definition for compatibility.
124 | import * as $ from './cubismstring';
125 | // eslint-disable-next-line @typescript-eslint/no-namespace
126 | export namespace Live2DCubismFramework {
127 | export const CubismString = $.CubismString;
128 | export type CubismString = $.CubismString;
129 | }
130 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | extends:
2 | - eslint:recommended
3 | - plugin:@typescript-eslint/eslint-recommended
4 | - plugin:@typescript-eslint/recommended
5 | - plugin:@typescript-eslint/recommended-requiring-type-checking
6 | - plugin:prettier/recommended
7 | plugins:
8 | - '@typescript-eslint'
9 | parser: '@typescript-eslint/parser'
10 | parserOptions:
11 | sourceType: module
12 | ecmaVersion: 2020
13 | project: ./tsconfig.json
14 | rules:
15 | prettier/prettier:
16 | - error
17 | - singleQuote: true
18 | trailingComma: none
19 | camelcase: "off"
20 | '@typescript-eslint/naming-convention':
21 | - warn
22 | - selector: default
23 | format:
24 | - camelCase
25 | - selector: variable
26 | format:
27 | custom: {
28 | # 指定の文字列で始まるものと特定の文字を含むものは許容
29 | regex: '^[A-Z]|^csm|^iterator|Shader',
30 | match: true
31 | }
32 | modifiers: ['exported','const']
33 | - selector: variable
34 | format:
35 | - camelCase
36 | - selector: variable
37 | format:
38 | custom: {
39 | # 指定の文字列で始まるものは許容
40 | regex: '^[A-Z]|^s_',
41 | match: true
42 | }
43 | modifiers: ['global']
44 | - selector: enum
45 | format:
46 | - PascalCase
47 | - selector: enumMember
48 | format:
49 | custom: {
50 | # 大文字から始まること
51 | regex: '^[A-Z]',
52 | match: true
53 | }
54 | - selector: classProperty
55 | format:
56 | - PascalCase
57 | modifiers: ['static','readonly']
58 | - selector: classProperty
59 | format:
60 | - camelCase
61 | leadingUnderscore: allow
62 | - selector: class
63 | format:
64 | custom: {
65 | # 指定の文字列で始まるか、指定の文字列で終わること
66 | regex: '^[A-Z]|^csm|^iterator|_WebGL$',
67 | match: true
68 | }
69 | - selector: interface
70 | format:
71 | - camelCase
72 | - PascalCase
73 | - selector: parameter
74 | format:
75 | - camelCase
76 | - selector: classMethod
77 | format:
78 | - camelCase
79 | - selector: objectLiteralProperty
80 | format:
81 | - camelCase
82 | - PascalCase
83 | - selector: typeAlias
84 | format:
85 | custom: {
86 | # 指定の文字列で始まるものは許容
87 | regex: '^[A-Z]|^[a-z]|^CSM_|^csm|^iterator',
88 | match: true
89 | }
90 | modifiers: ['exported']
91 | - selector: typeAlias
92 | format:
93 | - camelCase
94 | - selector: typeParameter
95 | format:
96 | custom: {
97 | # 「大文字+アンダースコア以外の文字」、あるいは「大文字1文字」
98 | # あるいは、「`T`+アンダースコア」で始まる場合
99 | regex: '^[A-Z][^_]|^[A-Z]|^T_$',
100 | match: true
101 | }
102 | leadingUnderscore: allow
103 | '@typescript-eslint/no-use-before-define': off
104 | no-empty-function: off
105 | '@typescript-eslint/no-empty-function':
106 | - error
107 | - allow:
108 | - constructors
109 | 'no-fallthrough': warn
110 | '@typescript-eslint/unbound-method': off
111 | '@typescript-eslint/no-unsafe-assignment': off
112 | '@typescript-eslint/restrict-plus-operands': off
113 | '@typescript-eslint/no-unsafe-return': off
114 | '@typescript-eslint/no-unsafe-member-access': off
115 | '@typescript-eslint/no-unsafe-argument': off
116 | '@typescript-eslint/no-unsafe-call': off
117 | '@typescript-eslint/no-explicit-any': off
118 | '@typescript-eslint/no-unused-vars': off
119 | 'no-inner-declarations': off
120 | 'no-global-assign': off
121 | 'prefer-const': warn
122 |
--------------------------------------------------------------------------------
/src/renderer/WebSDK/src/lappdefine.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | import { LogLevel } from '@framework/live2dcubismframework';
9 |
10 | /**
11 | * Sample Appで使用する定数
12 | */
13 |
14 | // Canvas width and height pixel values, or dynamic screen size ('auto').
15 | export const CanvasSize: { width: number; height: number } | 'auto' = 'auto';
16 |
17 | // 画面
18 | export const ViewScale = 1.0;
19 | export let CurrentKScale = ViewScale;
20 | export const ViewMaxScale = 2.0;
21 | export const ViewMinScale = 0.8;
22 |
23 | export const ViewLogicalLeft = -1.0;
24 | export const ViewLogicalRight = 1.0;
25 | export const ViewLogicalBottom = -1.0;
26 | export const ViewLogicalTop = 1.0;
27 |
28 | export const ViewLogicalMaxLeft = -2.0;
29 | export const ViewLogicalMaxRight = 2.0;
30 | export const ViewLogicalMaxBottom = -2.0;
31 | export const ViewLogicalMaxTop = 2.0;
32 |
33 | // Dynamic resource path that will be set by the model loader
34 | export let ResourcesPath = "";
35 |
36 | // Model directory and filename storage
37 | export let ModelDir: string[] = [];
38 | export let ModelFileNames: string[] = []; // New array to store model file names
39 |
40 | // Function to update model configuration with both directory and file name
41 | export function updateModelConfig(resourcePath: string, modelDirectory: string, modelFileName: string, kScale?: number) {
42 | console.log('Updating model config:', { resourcePath, modelDirectory, modelFileName, kScale });
43 | ResourcesPath = resourcePath;
44 | ModelDir = [modelDirectory];
45 | ModelFileNames = [modelFileName]; // Store the actual model file name
46 | if (kScale !== undefined) {
47 | CurrentKScale = kScale;
48 | }
49 | // Update ModelDirSize when ModelDir changes
50 | ModelDirSize = ModelDir.length;
51 | }
52 |
53 | // Export ModelDirSize as a variable instead of a constant
54 | export let ModelDirSize = ModelDir.length;
55 |
56 | // モデルの後ろにある背景の画像ファイル
57 | export const BackImageName = 'back_class_normal.png';
58 |
59 | // 歯車
60 | export const GearImageName = 'icon_gear.png';
61 |
62 | // 終了ボタン
63 | export const PowerImageName = 'CloseNormal.png';
64 |
65 | // モデル定義---------------------------------------------
66 | // モデルを配置したディレクトリ名の配列
67 | // ディレクトリ名とmodel3.jsonの名前を一致させておくこと
68 |
69 | // 外部定義ファイル(json)と合わせる
70 | // 外部定义文件(json)与之匹配
71 | export const MotionGroupIdle = 'Idle'; // アイドリング // 空闲
72 | export const MotionGroupTapBody = 'TapBody'; // 体をタップしたとき // 点击身体时
73 |
74 | // 外部定義ファイル(json)と合わせる
75 | // 外部定义文件(json)与之匹配
76 | export const HitAreaNameHead = 'Head';
77 | export const HitAreaNameBody = 'Body';
78 |
79 | // モーションの優先度定数
80 | // 动作优先级常数
81 | export const PriorityNone = 0;
82 | export const PriorityIdle = 1;
83 | export const PriorityNormal = 2;
84 | export const PriorityForce = 3;
85 |
86 | // MOC3の一貫性検証オプション
87 | // MOC3一致性验证选项
88 | export const MOCConsistencyValidationEnable = true;
89 |
90 | // デバッグ用ログの表示オプション
91 | // 调试用日志显示选项
92 | export const DebugLogEnable = false;
93 | export const DebugTouchLogEnable = false;
94 |
95 | // Frameworkから出力するログのレベル設定
96 | // 设置Framework输出的日志级别
97 | export const CubismLoggingLevel: LogLevel = LogLevel.LogLevel_Verbose;
98 |
99 | // デフォルトのレンダーターゲットサイズ
100 | // 默认的渲染目标大小
101 | export const RenderTargetWidth = 1900;
102 | export const RenderTargetHeight = 1000;
103 |
104 | export const ENABLE_LIMITED_FRAME_RATE = true;
105 | export const LIMITED_FRAME_RATE = 60;
--------------------------------------------------------------------------------
/src/renderer/WebSDK/Framework/src/motion/cubismmotionmanager.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright(c) Live2D Inc. All rights reserved.
3 | *
4 | * Use of this source code is governed by the Live2D Open Software license
5 | * that can be found at https://www.live2d.com/eula/live2d-open-software-license-agreement_en.html.
6 | */
7 |
8 | import { CubismModel } from '../model/cubismmodel';
9 | import { ACubismMotion } from './acubismmotion';
10 | import {
11 | CubismMotionQueueEntryHandle,
12 | CubismMotionQueueManager
13 | } from './cubismmotionqueuemanager';
14 |
15 | /**
16 | * モーションの管理
17 | *
18 | * モーションの管理を行うクラス
19 | */
20 | export class CubismMotionManager extends CubismMotionQueueManager {
21 | /**
22 | * コンストラクタ
23 | */
24 | public constructor() {
25 | super();
26 | this._currentPriority = 0;
27 | this._reservePriority = 0;
28 | }
29 |
30 | /**
31 | * 再生中のモーションの優先度の取得
32 | * @return モーションの優先度
33 | */
34 | public getCurrentPriority(): number {
35 | return this._currentPriority;
36 | }
37 |
38 | /**
39 | * 予約中のモーションの優先度を取得する。
40 | * @return モーションの優先度
41 | */
42 | public getReservePriority(): number {
43 | return this._reservePriority;
44 | }
45 |
46 | /**
47 | * 予約中のモーションの優先度を設定する。
48 | * @param val 優先度
49 | */
50 | public setReservePriority(val: number): void {
51 | this._reservePriority = val;
52 | }
53 |
54 | /**
55 | * 優先度を設定してモーションを開始する。
56 | *
57 | * @param motion モーション
58 | * @param autoDelete 再生が狩猟したモーションのインスタンスを削除するならtrue
59 | * @param priority 優先度
60 | * @return 開始したモーションの識別番号を返す。個別のモーションが終了したか否かを判定するIsFinished()の引数で使用する。開始できない時は「-1」
61 | */
62 | public startMotionPriority(
63 | motion: ACubismMotion,
64 | autoDelete: boolean,
65 | priority: number
66 | ): CubismMotionQueueEntryHandle {
67 | if (priority == this._reservePriority) {
68 | this._reservePriority = 0; // 予約を解除
69 | }
70 |
71 | this._currentPriority = priority; // 再生中モーションの優先度を設定
72 |
73 | return super.startMotion(motion, autoDelete);
74 | }
75 |
76 | /**
77 | * モーションを更新して、モデルにパラメータ値を反映する。
78 | *
79 | * @param model 対象のモデル
80 | * @param deltaTimeSeconds デルタ時間[秒]
81 | * @return true 更新されている
82 | * @return false 更新されていない
83 | */
84 | public updateMotion(model: CubismModel, deltaTimeSeconds: number): boolean {
85 | this._userTimeSeconds += deltaTimeSeconds;
86 |
87 | const updated: boolean = super.doUpdateMotion(model, this._userTimeSeconds);
88 |
89 | if (this.isFinished()) {
90 | this._currentPriority = 0; // 再生中のモーションの優先度を解除
91 | }
92 |
93 | return updated;
94 | }
95 |
96 | /**
97 | * モーションを予約する。
98 | *
99 | * @param priority 優先度
100 | * @return true 予約できた
101 | * @return false 予約できなかった
102 | */
103 | public reserveMotion(priority: number): boolean {
104 | if (
105 | priority <= this._reservePriority ||
106 | priority <= this._currentPriority
107 | ) {
108 | return false;
109 | }
110 |
111 | this._reservePriority = priority;
112 |
113 | return true;
114 | }
115 |
116 | _currentPriority: number; // 現在再生中のモーションの優先度
117 | _reservePriority: number; // 再生予定のモーションの優先度。再生中は0になる。モーションファイルを別スレッドで読み込むときの機能。
118 | }
119 |
120 | // Namespace definition for compatibility.
121 | import * as $ from './cubismmotionmanager';
122 | // eslint-disable-next-line @typescript-eslint/no-namespace
123 | export namespace Live2DCubismFramework {
124 | export const CubismMotionManager = $.CubismMotionManager;
125 | export type CubismMotionManager = $.CubismMotionManager;
126 | }
127 |
--------------------------------------------------------------------------------
/src/renderer/src/hooks/sidebar/setting/use-asr-settings.ts:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState, useEffect } from 'react';
2 | import { useVAD, VADSettings } from '@/context/vad-context';
3 |
4 | export const useASRSettings = () => {
5 | const {
6 | settings,
7 | updateSettings,
8 | autoStopMic,
9 | setAutoStopMic,
10 | autoStartMicOn,
11 | setAutoStartMicOn,
12 | autoStartMicOnConvEnd,
13 | setAutoStartMicOnConvEnd,
14 | } = useVAD();
15 |
16 | const localSettingsRef = useRef(settings);
17 | const originalSettingsRef = useRef(settings);
18 | const originalAutoStopMicRef = useRef(autoStopMic);
19 | const originalAutoStartMicOnRef = useRef(autoStartMicOn);
20 | const originalAutoStartMicOnConvEndRef = useRef(autoStartMicOnConvEnd);
21 | const [localVoiceInterruption, setLocalVoiceInterruption] = useState(autoStopMic);
22 | const [localAutoStartMic, setLocalAutoStartMic] = useState(autoStartMicOn);
23 | const [localAutoStartMicOnConvEnd, setLocalAutoStartMicOnConvEnd] = useState(autoStartMicOnConvEnd);
24 | const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
25 |
26 | useEffect(() => {
27 | setLocalVoiceInterruption(autoStopMic);
28 | setLocalAutoStartMic(autoStartMicOn);
29 | setLocalAutoStartMicOnConvEnd(autoStartMicOnConvEnd);
30 | }, [autoStopMic, autoStartMicOn, autoStartMicOnConvEnd]);
31 |
32 | const handleInputChange = (key: keyof VADSettings, value: number | string): void => {
33 | if (value === '' || value === '-') {
34 | localSettingsRef.current = { ...localSettingsRef.current, [key]: value };
35 | } else {
36 | const parsed = Number(value);
37 | // eslint-disable-next-line no-restricted-globals
38 | if (!isNaN(parsed)) {
39 | localSettingsRef.current = { ...localSettingsRef.current, [key]: parsed };
40 | }
41 | }
42 | forceUpdate();
43 | };
44 |
45 | const handleVoiceInterruptionChange = (value: boolean) => {
46 | setLocalVoiceInterruption(value);
47 | setAutoStopMic(value);
48 | };
49 |
50 | const handleAutoStartMicChange = (value: boolean) => {
51 | setLocalAutoStartMic(value);
52 | setAutoStartMicOn(value);
53 | };
54 |
55 | const handleAutoStartMicOnConvEndChange = (value: boolean) => {
56 | setLocalAutoStartMicOnConvEnd(value);
57 | setAutoStartMicOnConvEnd(value);
58 | };
59 |
60 | const handleSave = (): void => {
61 | updateSettings(localSettingsRef.current);
62 | originalSettingsRef.current = localSettingsRef.current;
63 | originalAutoStopMicRef.current = localVoiceInterruption;
64 | originalAutoStartMicOnRef.current = localAutoStartMic;
65 | originalAutoStartMicOnConvEndRef.current = localAutoStartMicOnConvEnd;
66 | };
67 |
68 | const handleCancel = (): void => {
69 | localSettingsRef.current = originalSettingsRef.current;
70 | setLocalVoiceInterruption(originalAutoStopMicRef.current);
71 | setLocalAutoStartMic(originalAutoStartMicOnRef.current);
72 | setAutoStopMic(originalAutoStopMicRef.current);
73 | setAutoStartMicOn(originalAutoStartMicOnRef.current);
74 | setLocalAutoStartMicOnConvEnd(originalAutoStartMicOnConvEndRef.current);
75 | setAutoStartMicOnConvEnd(originalAutoStartMicOnConvEndRef.current);
76 | forceUpdate();
77 | };
78 |
79 | return {
80 | localSettings: localSettingsRef.current,
81 | autoStopMic: localVoiceInterruption,
82 | autoStartMicOn: localAutoStartMic,
83 | autoStartMicOnConvEnd: localAutoStartMicOnConvEnd,
84 | setAutoStopMic: handleVoiceInterruptionChange,
85 | setAutoStartMicOn: handleAutoStartMicChange,
86 | setAutoStartMicOnConvEnd: handleAutoStartMicOnConvEndChange,
87 | handleInputChange,
88 | handleSave,
89 | handleCancel,
90 | };
91 | };
92 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Development Commands
6 |
7 | ### Install dependencies
8 | ```bash
9 | npm install
10 | ```
11 |
12 | ### Run development server
13 | ```bash
14 | npm run dev # Run Electron app in dev mode
15 | npm run dev:web # Run web-only version
16 | ```
17 |
18 | ### Build commands
19 | ```bash
20 | npm run build:win # Build for Windows
21 | npm run build:mac # Build for macOS
22 | npm run build:linux # Build for Linux
23 | npm run build:web # Build web version
24 | ```
25 |
26 | ### Code quality
27 | ```bash
28 | npm run lint # Run ESLint
29 | npm run lint:fix # Run ESLint with auto-fix
30 | npm run typecheck # Run TypeScript type checking (both node and web)
31 | npm run format # Format code with Prettier
32 | ```
33 |
34 | ### Translation extraction
35 | ```bash
36 | npm run extract-translations # Extract i18n strings
37 | ```
38 |
39 | ## Architecture Overview
40 |
41 | This is an Electron + React application for an AI VTuber system with Live2D integration. The architecture consists of:
42 |
43 | ### Main Process (`src/main/`)
44 | - **index.ts**: Entry point, sets up IPC handlers for window management, mouse events, and screen capture
45 | - **window-manager.ts**: Manages window state, modes (window/pet), and window properties
46 | - **menu-manager.ts**: Handles system tray and context menus
47 |
48 | ### Renderer Process (`src/renderer/src/`)
49 | - **App.tsx**: Root component that sets up providers and renders the main layout
50 | - **Two display modes**:
51 | - **Window mode**: Full UI with sidebar, footer, and Live2D canvas
52 | - **Pet mode**: Minimal overlay with just Live2D and input subtitle
53 |
54 | ### Core Services
55 | - **WebSocket Handler** (`services/websocket-handler.tsx`): Central communication hub that:
56 | - Manages WebSocket connection to backend server
57 | - Handles incoming messages (audio, control, model updates, chat history)
58 | - Coordinates state updates across multiple contexts
59 | - Manages audio playback queue
60 |
61 | ### Context Providers (State Management)
62 | The app uses React Context for state management with multiple specialized contexts:
63 | - **AiStateContext**: AI conversation state (idle, thinking, speaking, listening)
64 | - **Live2DConfigContext**: Live2D model configuration and loading
65 | - **ChatHistoryContext**: Conversation history and messages
66 | - **VADContext**: Voice Activity Detection for microphone control
67 | - **WebSocketContext**: WebSocket connection state and messaging
68 | - **SubtitleContext**: Subtitle display management
69 | - **GroupContext**: Multi-user group session management
70 |
71 | ### Live2D Integration
72 | - Uses Cubism SDK (WebSDK folder) for Live2D model rendering
73 | - **live2d.tsx**: Main Live2D component handling model loading, animation, and lip sync
74 | - Supports model switching, expressions, and motion playback
75 | - Audio-driven lip sync with volume-based animation
76 |
77 | ### Key Features
78 | - Real-time voice interaction with VAD (Voice Activity Detection)
79 | - WebSocket-based communication with backend AI server
80 | - Live2D character animation with expressions and lip sync
81 | - Multi-language support (i18n)
82 | - Group/collaborative sessions
83 | - Screen capture support
84 | - Customizable backgrounds and UI themes
85 |
86 | ## Important Notes
87 | - The app requires a backend server connection (WebSocket) for AI functionality
88 | - Live2D models are loaded from URLs provided by the backend
89 | - Audio is streamed as base64-encoded data with volume arrays for lip sync
90 | - The app uses Chakra UI v3 for the component library
91 | - ESLint is configured with relaxed rules (many checks disabled in .eslintrc.js)
--------------------------------------------------------------------------------