Skip to content

Commit e849390

Browse files
Merge pull request #52 from georgeistes/custom-model
Customize Live2D model, Hugging Face support
2 parents 7062b00 + ecb0e43 commit e849390

File tree

15 files changed

+490
-198
lines changed

15 files changed

+490
-198
lines changed

README.md

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,35 @@ Some background features can be configured in the sidebar, refer to the [Setup a
5252

5353
You must provide your own ElevenLabs API key for text-to-speech and speech-to-textn features. It can be obtained for free with quite generous usage limits by signing up for an account at [their website](https://elevenlabs.io/).
5454

55-
> [!CAUTION]
56-
> In the pre-release version, we used to support both ElevenLabs and HuggingFace. However, we have currently discontinued support for HuggingFace since the Kokoro TTS API is not reliable.
55+
You can also choose to use Hugging Face's Kokoro and Whisper models for TTS and STT. You can obtained a free API key by signing up for an account at [their website](https://huggingface.co/). **However, we recommend using ElevenLabs since the free tier is much more generous for API usage.**
5756

5857
> [!NOTE]
5958
> In future releases, we might consider adding local TTS and STT models to avoid the need for API keys. However, this will make the extension less accessible to many users who do not have the required hardware since these models are quite large.
6059
60+
### Electron.js
61+
62+
We require `electron.js` to create an overlay window for the cheerleader (because it is dangerous and impossible to do so within the VSCode workspace editor). We recommend installing it globally so you don't need to install it individually for every workspace.
63+
64+
```sh
65+
npm install -g electron
66+
```
67+
68+
> [!NOTE]
69+
> If you do not have electron installed, a terminal will pop up when you launch the cheerleader to prompt you to install it (type Enter or "y" to install).
70+
71+
### Customizing Cheerleader
72+
73+
Other than the existing anime characters, you can use your own Live2D model, either found online or created by yourself with Live2D Cubism. To do this, paste in either the **URL** or **absolute local path** to the `model.json` file in the sidebar settings.
74+
75+
Here are few free collections of Live2D models on GitHub (we do not own nor are affiliated with any of these repositories):
76+
77+
- [iCharlesZ/vscode-live2d-models](https://github.com/iCharlesZ/vscode-live2d-models/tree/master)
78+
79+
- [imuncle/live2d](https://github.com/imuncle/live2d)
80+
81+
> [!CAUTION]
82+
> Advanced animations and interactions are only supported on the default models we provided, because custom models might not have the same motions defined.
83+
6184
### Settings
6285

6386
You can configure the following settings in the sidebar (with cheerleader icon) OR the original settings panel (Ctrl+Shift+P -> Preferences: Open Settings (UI)):
@@ -80,9 +103,6 @@ Available setitngs include:
80103

81104
- **Feature Settings**: Configure settings like break reminder interval, time to quit session, etc from both the sidebar and the original settings panel
82105

83-
> [!NOTE]
84-
> In a future release soon you will be able to bring any Live2D model you want by providing a URL. We will also expand the default catalog to include characters other than anime.
85-
86106
## Installation
87107

88108
### VSCode Extensions Marketplace
@@ -151,11 +171,11 @@ In the future, we plan to extend more unique functionalities to the cheerleader
151171

152172
**We believe coding should feel less like solitary, mundane work and more like a creative jam session.** That’s why we built the cheerleader — not just as an assistant, but as a vibrant, voice-driven companion that brings energy, motivation, and a bit of fun to your coding flow.
153173

154-
Most coding agents today (like Copilot, Cursor, or Roo) are powerful but sterile — all utility, no personality. They’re optimized for efficiency, but forget that creativity thrives in an environment that’s playful, human, and a little unexpected. We’re flipping the script by making human-computer interaction not only smart, but emotionally engaging.
174+
Most coding agents today (like Copilot, Cursor, or Roo) are powerful but sterile — all utility, no personality. They’re optimized for efficiency, but forget that creativity thrives in an environment that’s playful, human, and a little unexpected.
155175

156-
We also see coding as inherently social — even when you’re solo. Whether you’re rubber-ducking a bug or celebrating a passing test, the cheerleader is there to respond, react, and cheer you on in real time.
176+
We also see coding as inherently social — even when you’re solo. That's why rubber-duck debugging and pair programming have been so popular and effective.
157177

158-
Importantly, we’re not trying to replace mature agentic tools which are optimized and excellent. Cheerleader is here to complement and emphasize **reflection and growth**. It’s ideal for moments when you want to think through a problem, like grinding LeetCode or learning a new language — not just vibe code your way through it. Our goal isn’t to automate away the effort, but to make the effort more meaningful. That’s how human skill is perfected.
178+
Cheerleader is here to complement these tools and emphasize **reflection and growth**. It’s ideal for moments when you want to think through a problem, like grinding LeetCode or learning a new language — not just vibe code your way through it. Our goal isn’t to automate away the effort, but to make the effort more meaningful. That’s how human skill is perfected.
159179

160180
**We're building a collaborator with charm.** One that talks, listens, celebrates small wins, and nudges you forward when you hit a wall. Programming should be powerful _and_ delightful. That’s our philosophy.
161181

live2d-container/index.js

Lines changed: 75 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,21 @@ function showSpeechBubble(text, duration = 3000) {
7474
}, duration);
7575
}
7676

77-
const loadModel = async (url) => {
78-
model = await live2d.Live2DModel.from(
79-
url,
80-
);
77+
const loadModel = async (modelUrl, isCustom = false) => {
78+
if (!modelUrl) {
79+
console.error('Model URL is not provided');
80+
return;
81+
}
82+
try {
83+
console.log('Loading model from URL:', modelUrl);
84+
model = await live2d.Live2DModel.from(modelUrl);
85+
if (!model) {
86+
throw new Error('Failed to create Live2DModel');
87+
}
88+
} catch (error) {
89+
console.error('Error loading model:', error);
90+
throw error;
91+
}
8192

8293
app.stage.addChild(model);
8394

@@ -159,22 +170,26 @@ const loadModel = async (url) => {
159170
icon: createSVGElement(Icons.CLOSE),
160171
tooltip: 'Quit Cheerleader'
161172
});
162-
163-
buttonManager.addButton({
164-
onClick: () => {
165-
model.handleExecuteMotion('vodka', MotionPriority.FORCE);
166-
},
167-
icon: createSVGElement(Icons.VODKA),
168-
tooltip: 'Пить водку'
169-
});
170173

171-
buttonManager.addButton({
172-
onClick: () => {
173-
model.handleExecuteMotion('CENTER_HoldPhone', MotionPriority.FORCE);
174-
},
175-
icon: createSVGElement(Icons.PHONE),
176-
tooltip: 'Consume brainrot'
177-
});
174+
// these custom animatinos are only available on the base models
175+
// because not all motions are defined for your custom ones
176+
if (!isCustom) {
177+
buttonManager.addButton({
178+
onClick: () => {
179+
model.handleExecuteMotion('vodka', MotionPriority.FORCE);
180+
},
181+
icon: createSVGElement(Icons.VODKA),
182+
tooltip: 'Пить водку'
183+
});
184+
185+
buttonManager.addButton({
186+
onClick: () => {
187+
model.handleExecuteMotion('CENTER_HoldPhone', MotionPriority.FORCE);
188+
},
189+
icon: createSVGElement(Icons.PHONE),
190+
tooltip: 'Consume brainrot'
191+
});
192+
}
178193

179194
// buttonManager.addButton({
180195
// onClick: () => {
@@ -320,17 +335,43 @@ function makeDraggable(model) {
320335
});
321336
}
322337

323-
async function changeModel(index) {
324-
if (model) {
325-
await model.motion("JUMP_BACK", undefined, MotionPriority.FORCE);
326-
await new Promise(resolve => {
327-
model.internalModel.motionManager.on('motionFinish', resolve);
328-
});
329-
app.stage.removeChild(model);
330-
model = null;
338+
async function changeModel(data) {
339+
console.log('Changing model with data:', data);
340+
341+
if (!data) {
342+
console.error('No data provided for model change');
343+
return;
344+
}
345+
346+
try {
347+
// Clean up existing model
348+
if (model) {
349+
console.log('Cleaning up existing model');
350+
await model.motion("JUMP_BACK", undefined, MotionPriority.FORCE);
351+
await new Promise(resolve => {
352+
model.internalModel.motionManager.on('motionFinish', resolve);
353+
});
354+
app.stage.removeChild(model);
355+
model = null;
356+
}
357+
358+
console.log("Received model data:", data);
359+
360+
// Determine which model to load
361+
if (data.customModelURL) {
362+
console.log('Using custom model URL:', data.customModelURL);
363+
await loadModel(data.customModelURL, true);
364+
} else if (typeof data.modelIndex === 'number' && data.modelIndex >= 0 && data.modelIndex < modelUrls.length) {
365+
console.log('Using built-in model index:', data.modelIndex);
366+
await loadModel(modelUrls[data.modelIndex]);
367+
} else {
368+
console.warn('Invalid model data, falling back to first model');
369+
await loadModel(modelUrls[0]);
370+
}
371+
console.log('Model loaded successfully');
372+
} catch (error) {
373+
console.error('Failed to change model:', error);
331374
}
332-
modelIndex = index;
333-
await loadModel(modelUrls[modelIndex]);
334375
}
335376

336377
window.electronAPI.onStartSpeak((event, message) => {
@@ -348,7 +389,10 @@ window.electronAPI.onStopSpeak((event, message) => {
348389
});
349390

350391
window.electronAPI.onChangeModel((event, message) => {
351-
changeModel(message.modelIndex);
392+
changeModel({
393+
modelIndex: message.modelIndex,
394+
customModelURL: message.customModelURL
395+
});
352396
});
353397

354398
window.electronAPI.onQuit((event, message) => {
@@ -370,3 +414,4 @@ window.electronAPI.onQuit((event, message) => {
370414
});
371415
await loadModel(modelUrls[modelIndex]);
372416
})();
417+

live2d-container/main.js

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function createWindow() {
5454
transparent: true,
5555
frame: false,
5656
alwaysOnTop: true,
57-
focusable: false,
57+
focusable: true, // Allow window to be focused for DevTools
5858
webPreferences: {
5959
nodeIntegration: true,
6060
contextIsolation: true,
@@ -68,8 +68,6 @@ function createWindow() {
6868

6969
// Welcome message in terminal (because the terminal cannot be closed, show something interesting)
7070
showWelcomeMessage();
71-
// open devtools for debugging
72-
// mainWindow.webContents.openDevTools();
7371

7472
if (USE_WEBSOCKET) {
7573
// Connect to VSCode extension's WebSocket server
@@ -87,7 +85,7 @@ function createWindow() {
8785
stopSpeak();
8886
}
8987
if (data.type === 'changeModel') {
90-
changeModel(data.modelIndex);
88+
changeModel(data);
9189
}
9290
// if (data.type === 'window-info') {
9391
// const { x, y, width, height } = data.data;
@@ -101,12 +99,29 @@ function createWindow() {
10199
}
102100
}
103101

104-
app.whenReady().then(() => {
105-
createWindow();
106-
});
102+
// Check for single instance lock
103+
const gotTheLock = app.requestSingleInstanceLock();
104+
105+
if (!gotTheLock) {
106+
app.quit();
107+
} else {
108+
app.on('second-instance', (event, commandLine, workingDirectory) => {
109+
// A second instance tried to run - focus our window
110+
if (mainWindow) {
111+
if (mainWindow.isMinimized()) mainWindow.restore();
112+
mainWindow.focus();
113+
}
114+
});
115+
116+
app.whenReady().then(() => {
117+
createWindow();
118+
});
119+
}
107120

108121
app.on('window-all-closed', () => {
109-
quitApp();
122+
if (process.platform !== 'darwin') {
123+
quitApp();
124+
}
110125
});
111126

112127
ipcMain.on('onCloseButton', () => {
@@ -148,18 +163,30 @@ function stopSpeak() {
148163
mainWindow.webContents.send('stopSpeak');
149164
}
150165

151-
function changeModel(modelIndex) {
166+
function changeModel(data) {
152167
if (!mainWindow) return;
153-
mainWindow.webContents.send('changeModel', { modelIndex });
168+
mainWindow.webContents.send('changeModel', {
169+
modelIndex: data.modelIndex,
170+
customModelURL: data.customModelURL
171+
});
154172
}
155173

156174
function quitApp(mode = "graceful") {
157175
if (mode === "force") {
158-
app.quit();
176+
cleanupAndQuit();
159177
return;
160178
}
161-
mainWindow.webContents.send('quit', {});
162-
setTimeout(() => {
163-
app.quit();
164-
}, 4000); // wait for 4 seconds to let the animation finish
179+
if (mainWindow) {
180+
mainWindow.webContents.send('quit', {});
181+
setTimeout(cleanupAndQuit, 4000); // wait for 4 seconds to let the animation finish
182+
} else {
183+
cleanupAndQuit();
184+
}
185+
}
186+
187+
function cleanupAndQuit() {
188+
if (ws) {
189+
ws.close();
190+
}
191+
app.quit();
165192
}

package-lock.json

Lines changed: 15 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
"displayName": "Cheerleader",
44
"publisher": "cheerleader",
55
"description": "Supercharge your dev experience with an anime coding companion",
6-
"version": "1.0.2",
6+
"version": "1.1.0",
77
"engines": {
88
"vscode": "^1.99.0"
99
},
1010
"icon": "assets/cheerleader_icon.png",
1111
"author": {
1212
"name": "James Zheng and Jet Chiang",
13-
"email": "endernoke@gmail.com"
13+
"email": "jetjiang.ez@gmail.com"
1414
},
1515
"pricing": "Free",
1616
"categories": [
@@ -27,7 +27,7 @@
2727
"license": "MIT",
2828
"bugs": {
2929
"url": "https://github.com/endernoke/vscode-cheerleader/issues",
30-
"email": "endernoke@gmail.com"
30+
"email": "jetjiang.ez@gmail.com"
3131
},
3232
"homepage": "https://github.com/endernoke/vscode-cheerleader/blob/main/README.md",
3333
"activationEvents": [
@@ -191,6 +191,11 @@
191191
"default": "gpt-4o",
192192
"description": "The Copilot model family to use"
193193
},
194+
"cheerleader.model.customModelURL": {
195+
"type": "string",
196+
"default": "",
197+
"description": "The URL of a custom Live2D model (locally or online) to use"
198+
},
194199
"cheerleader.audio.provider": {
195200
"type": "string",
196201
"enum": [
@@ -260,6 +265,7 @@
260265
"play-sound": "^1.1.6",
261266
"tsx": "^4.19.3",
262267
"uuid": "^11.1.0",
268+
"wavefile": "^11.0.0",
263269
"ws": "^8.18.1"
264270
}
265271
}

src/copilot-wrapper/rotting.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ async function monitorRotting() {
2828
const audioFilePath = vscode.Uri.file(
2929
`${globalContext.extensionUri.fsPath}/assets/rotting/${fileName}`
3030
).fsPath;
31-
await playAudioFromFile(audioFilePath, text, 10);
31+
await playAudioFromFile(audioFilePath, text, 10 * 1000);
3232
}
3333

3434
globalContext.globalState.update("lastProductiveState", currentProductiveState);

0 commit comments

Comments
 (0)