Atom
電腦桌寵製作_前端篇

電腦桌寵製作_前端篇

回顧一下我們的整體

  • 運行邏輯:
1
2
3
4
5
6
7
使用者輸入 → 
小洛伊接收 →
(呼叫記憶檢索)→
組合「個性 + 記憶 + 主人偏好」→
發給本地 LLM →
解析 →
Live2D 語音 + 動作 → 回覆主人
  • 環境架構:

WebUI這邊我們使用Electron

1
2
3
4
5
[Web 前端+Live2D]  ←→  [後端 Node/Tauri]
↓ ↓
語音合成 (Kokoro) ←→ 本地 LLM (Qwen2.5-7B)
↑ ↑
└────── 記憶庫(SQLite / JSON)

順序

  1. 建立 Electron 專案架構
  2. 加入 Cubism Web SDK for Live2D Model3
  3. 載入模型 /ellot.model3.json
  4. 建立透明漂浮窗:
    transparent: true
    frame: false
    alwaysOnTop: true
  5. (廢棄)點穿模式(可切換)
  6. 跟後端 /api/chat 連動
  7. 聲音播放後 → lip sync 自動套用
  8. 小洛伊講話時 → 半透明漂浮對話框
  9. 模型可拖曳位置
  10. 支援自動 idle 動作

使用模型來源:【Live2dモデル】 エロット/ ellot 【VtubeStudio】

目前我們有:
後端:http://localhost:3000/api/chat (Qwen2.5 + Kokoro TTS )
Live2D 模型:ellot

前端架構:
\mini-loy-desktop
├─ assets
│ └─ ellot (live2d模型)
├─ main.js
├─ index.html
└─ renderer.js

初始化 Electron 專案

1
2
npm init -y
npm install electron --save-dev

然後修改我們的package.json

1
2
3
"scripts": {
"start": "electron ."
}

新增我們的main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const { app, BrowserWindow, screen } = require("electron");
const path = require("path");

function createWindow() {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;

const winWidth = 400; // 視窗寬度
const winHeight = 600; // 視窗高度(我桌機螢幕比較大這大小剛好)

const win = new BrowserWindow({
width: winWidth,
height: winHeight,
x: width - winWidth - 20, // 右下角預設位置
y: height - winHeight - 20,
frame: false, // 無邊框
transparent: true, // 背景透明
alwaysOnTop: true, // 永遠置頂
resizable: false,
webPreferences: {
preload: path.join(__dirname, "renderer.js"),
nodeIntegration: false,
contextIsolation: false
}
});

win.loadFile("index.html");
// win.webContents.openDevTools(); // 除錯用
}

app.whenReady().then(() => {
createWindow();

app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});

app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});

再來是index.html(Live2D Canvas + 浮動對話框)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>Mini-Loy Desktop</title>

<!-- 背景透明 & 無邊距 -->
<style>
html, body {
margin: 0;
padding: 0;
overflow: hidden;
background: transparent;
font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}

#live2d-canvas {
display: block;
width: 100%;
height: 100%;
}

/* 漂浮聊天 UI */
#chat-box {
position: absolute;
bottom: 10px;
left: 10px;
right: 10px;
background: rgba(15, 23, 42, 0.7); /* 深色半透明 */
border-radius: 12px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}

#messages {
max-height: 140px;
overflow-y: auto;
font-size: 12px;
color: #e5e7eb;
}

.msg-user {
text-align: right;
color: #bfdbfe;
}

.msg-bot {
text-align: left;
color: #e5e7eb;
}

#input-row {
display: flex;
gap: 6px;
}

#user-input {
flex: 1;
border-radius: 999px;
border: none;
padding: 6px 10px;
font-size: 12px;
outline: none;
}

#send-btn {
border: none;
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
background: #22c55e;
color: #0f172a;
cursor: pointer;
font-weight: 600;
}

#send-btn:disabled {
opacity: 0.6;
cursor: default;
}
</style>
<!-- Cubism 4 Core -->
<script src="https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js"></script>

<!-- Cubism 2 runtime(live2d.min.js),給 pixi-live2d-display-->
<script src="https://cdn.jsdelivr.net/gh/dylanNew/live2d/webgl/Live2D/lib/live2d.min.js"></script>

<!-- PixiJS -->
<script src="https://cdn.jsdelivr.net/npm/pixi.js@6.5.2/dist/browser/pixi.min.js"></script>

<!-- 官方 pixi-live2d-display(支援 model3.json) -->
<script src="https://cdn.jsdelivr.net/npm/pixi-live2d-display/dist/index.min.js"></script>

<!-- Live2D / Pixi:使用 CDN -->
<script src="https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pixi-live2d-display@latest/dist/index.min.js"></script> -->


</head>
<body>
<canvas id="live2d-canvas"></canvas>

<div id="chat-box">
<div id="messages"></div>
<div id="input-row">
<input id="user-input" type="text" placeholder="Type in English to Mini-Loy..." />
<button id="send-btn">Send</button>
</div>
</div>

<script src="./renderer.js"></script>
</body>
</html>

renderer.js(載入 Live2D + 對話)
這邊直接放我最終版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
window.addEventListener("DOMContentLoaded", () => {
const canvas = document.getElementById("live2d-canvas");
const messagesEl = document.getElementById("messages");
const inputEl = document.getElementById("user-input");
const sendBtn = document.getElementById("send-btn");

// 全域 addMessage(chat 用)
window.addMessage = (text, type = "bot") => {
const div = document.createElement("div");
div.textContent = text;
div.className = type === "user" ? "msg-user" : "msg-bot";
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
};

window.PIXI = PIXI;

const app = new PIXI.Application({
view: canvas,
transparent: true,
autoStart: true,
resizeTo: window
});

let model = null;
// === Web Audio 分析器 ===
let audioContext = null;
let analyser = null;
let dataArray = null;

function initAudioAnalyzer() {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;

const bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
}
// === 真正的語音嘴型同步 ===
function startLipSync(audio) {
if (!model || !model.internalModel.coreModel) return;
if (!audioContext) initAudioAnalyzer();

const coreModel = model.internalModel.coreModel;

// 將 audio 元件接到分析器
const source = audioContext.createMediaElementSource(audio);
source.connect(analyser);
analyser.connect(audioContext.destination);

function animate() {
// 如果音訊停止就結束同步
if (audio.paused || audio.ended) {
coreModel.setParameterValueById("ParamMouthOpenY", 0);
return;
}

// 取得頻率資料
analyser.getByteFrequencyData(dataArray);

// 計算平均音量
let sum = 0;
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
const volume = sum / dataArray.length;

// 映射到嘴型 (0~1)
const mouthValue = Math.min(1, volume / 120); // 值越低嘴巴越靈敏

// 套用到 Live2D 嘴巴參數
coreModel.setParameterValueById("ParamMouthOpenY", mouthValue);

requestAnimationFrame(animate);
}

animate();
}

(async () => {
try {
console.log("PIXI.live2d 存在?", !!PIXI.live2d.Live2DModel); // 會印 true

const modelUrl = "./assets/ellot/ellot.model3.json";
model = await PIXI.live2d.Live2DModel.from(modelUrl); // 官方載入方式

console.log("Ellot 模型載入成功!", model);

model.scale.set(0.4); // 調小一點,避免太大
model.anchor.set(0.5, 1);
model.position.set(app.renderer.width * 0.5, app.renderer.height);

// 拖拽事件(修復簡化)
model.interactive = true;
model.on("pointerdown", (e) => {
model.dragging = true;
const pos = e.data.getLocalPosition(model.parent);
model._dragOffset = { x: pos.x - model.x, y: pos.y - model.y };
});
model.on("pointermove", (e) => {
if (model.dragging) {
const pos = e.data.getLocalPosition(model.parent);
model.position.set(pos.x - model._dragOffset.x, pos.y - model._dragOffset.y);
}
});
model.on("pointerup", () => model.dragging = false);
model.on("pointerupoutside", () => model.dragging = false);

app.stage.addChild(model);

model.internalModel.motionManager.startRandomMotion("Idle");

} catch (err) {
console.error("載入細節錯誤:", err);
addMessage("模型載入失敗:" + err.message, "bot");
}
})();

window.addEventListener("resize", () => {
if (model) model.position.set(app.screen.width / 2, app.screen.height);
});

function startFakeLipSync(audio) {
if (!model || !model.internalModel.coreModel) return;
const coreModel = model.internalModel.coreModel;
let active = true;
const timer = setInterval(() => {
if (!active) return;
const v = Math.random();
coreModel.setParameterValueById("ParamMouthOpenY", v);
}, 80);
audio.addEventListener("ended", () => {
active = false;
clearInterval(timer);
coreModel.setParameterValueById("ParamMouthOpenY", 0);
});
}

// 聊天功能
const sendToMiniLoy = async (text) => {
window.addMessage(text, "user");
sendBtn.disabled = true;
inputEl.value = "";

try {
const resp = await fetch("http://localhost:4001/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text })
});
const data = await resp.json();
window.addMessage(data.reply || "(no reply)", "bot");
if (data.audio) {
const audio = new Audio("http://localhost:4001" + data.audio);
audio.play();
startLipSync(audio);
// window.startFakeLipSync(audio);
}
} catch (err) {
window.addMessage("後端連線失敗", "bot");
} finally {
sendBtn.disabled = false;
}
};

sendBtn.onclick = () => {
const text = inputEl.value.trim();
if (text) sendToMiniLoy(text);
};
inputEl.onkeydown = e => {
if (e.key === "Enter") {
const text = inputEl.value.trim();
if (text) sendToMiniLoy(text);
}
};
});

啟動:npm start

備註:錄這個影片是後續在筆電上跑的,所以等待回應的地方我按暫停(筆電只有cpu會跑比較慢),再來是開啟東西的功能會寫在下一篇。
實際展示

本文作者:Atom
本文鏈接:https://d0ngd.github.io/2026/04/03/洛伊桌寵後端紀錄/
版權聲明:本文採用 CC BY-NC-SA 3.0 CN 協議進行許可