@pheem49/mint 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +8 -0
  2. package/mint-cli.js +148 -921
  3. package/models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253.exp3.json +31 -1
  4. package/models/Shiroko_Model/Shiroko/Shiroko_Core//347/202/271/344/270/200/344/270/213.exp3.json +6 -1
  5. package/package.json +18 -20
  6. package/src/AI_Brain/proactive_engine.js +12 -2
  7. package/src/Automation_Layer/browser_automation.js +26 -24
  8. package/src/CLI/approval_handler.js +42 -0
  9. package/src/CLI/chat_ui.js +192 -7
  10. package/src/CLI/cli_colors.js +32 -0
  11. package/src/CLI/cli_formatters.js +89 -0
  12. package/src/CLI/code_agent.js +166 -57
  13. package/src/CLI/intent_detectors.js +181 -0
  14. package/src/CLI/interactive_chat.js +479 -0
  15. package/src/CLI/list_features.js +3 -0
  16. package/src/CLI/repo_summarizer.js +282 -0
  17. package/src/CLI/semantic_code_search.js +312 -0
  18. package/src/CLI/skill_manager.js +41 -0
  19. package/src/CLI/slash_command_handler.js +418 -0
  20. package/src/CLI/symbol_indexer.js +231 -0
  21. package/src/Channels/discord_bridge.js +11 -13
  22. package/src/Channels/line_bridge.js +10 -10
  23. package/src/Channels/slack_bridge.js +7 -12
  24. package/src/Channels/telegram_bridge.js +6 -14
  25. package/src/Channels/whatsapp_bridge.js +11 -9
  26. package/src/System/chat_history_manager.js +20 -12
  27. package/src/System/optional_require.js +23 -0
  28. package/src/UI/live2d_manager.js +211 -13
  29. package/src/UI/renderer.js +163 -3
  30. package/src/UI/settings.css +655 -420
  31. package/src/UI/settings.html +478 -432
  32. package/src/UI/settings.js +10 -8
  33. package/src/UI/styles.css +89 -25
@@ -1,9 +1,15 @@
1
- const { Client, GatewayIntentBits, Partials } = require('discord.js');
1
+ 'use strict';
2
+
3
+ const { requireOptional } = require('../System/optional_require');
2
4
  const { handleChat } = require('../AI_Brain/Gemini_API');
3
5
 
4
6
  class DiscordBridge {
5
7
  constructor(token) {
6
8
  this.token = token;
9
+ const { Client, GatewayIntentBits, Partials } = requireOptional(
10
+ 'discord.js',
11
+ 'npm install discord.js'
12
+ );
7
13
  this.client = new Client({
8
14
  intents: [
9
15
  GatewayIntentBits.Guilds,
@@ -21,30 +27,22 @@ class DiscordBridge {
21
27
  });
22
28
 
23
29
  this.client.on('messageCreate', async (message) => {
24
- // Ignore bot messages
25
30
  if (message.author.bot) return;
26
-
27
- // Handle DMs or Mentions
28
31
  const isDM = !message.guild;
29
32
  const isMentioned = message.mentions.has(this.client.user);
30
33
 
31
34
  if (isDM || isMentioned) {
32
35
  try {
33
- // Clean up the message if it's a mention
34
36
  let cleanContent = message.content;
35
37
  if (isMentioned) {
36
- cleanContent = message.content.replace(`<@!${this.client.user.id}>`, '').replace(`<@${this.client.user.id}>`, '').trim();
38
+ cleanContent = message.content
39
+ .replace(`<@!${this.client.user.id}>`, '')
40
+ .replace(`<@${this.client.user.id}>`, '')
41
+ .trim();
37
42
  }
38
-
39
43
  if (!cleanContent) return;
40
-
41
- // Show typing indicator
42
44
  await message.channel.sendTyping();
43
-
44
- // Send to Mint AI Brain
45
45
  const result = await handleChat(cleanContent);
46
-
47
- // Reply to user
48
46
  if (result && result.response) {
49
47
  await message.reply(result.response);
50
48
  }
@@ -1,22 +1,25 @@
1
- const line = require('@line/bot-sdk');
2
- const express = require('express');
1
+ 'use strict';
2
+
3
+ const { requireOptional } = require('../System/optional_require');
3
4
  const { handleChat } = require('../AI_Brain/Gemini_API');
4
5
 
5
6
  class LineBridge {
6
7
  constructor(credentials) {
8
+ this._line = requireOptional('@line/bot-sdk', 'npm install @line/bot-sdk express');
9
+ this._express = requireOptional('express', 'npm install @line/bot-sdk express');
7
10
  this.config = {
8
11
  channelAccessToken: credentials.accessToken,
9
12
  channelSecret: credentials.secret,
10
13
  };
11
- this.port = credentials.port || 3000;
12
- this.client = new line.messagingApi.MessagingApiClient({
14
+ this.port = credentials.port || 3000;
15
+ this.client = new this._line.messagingApi.MessagingApiClient({
13
16
  channelAccessToken: credentials.accessToken
14
17
  });
15
- this.app = express();
18
+ this.app = this._express();
16
19
  }
17
20
 
18
21
  async connect() {
19
- this.app.post('/callback', line.middleware(this.config), (req, res) => {
22
+ this.app.post('/callback', this._line.middleware(this.config), (req, res) => {
20
23
  Promise
21
24
  .all(req.body.events.map(event => this.handleEvent(event)))
22
25
  .then((result) => res.json(result))
@@ -36,7 +39,6 @@ class LineBridge {
36
39
  if (event.type !== 'message' || event.message.type !== 'text') {
37
40
  return Promise.resolve(null);
38
41
  }
39
-
40
42
  try {
41
43
  const result = await handleChat(event.message.text);
42
44
  if (result && result.response) {
@@ -51,9 +53,7 @@ class LineBridge {
51
53
  }
52
54
 
53
55
  async disconnect() {
54
- if (this.server) {
55
- this.server.close();
56
- }
56
+ if (this.server) this.server.close();
57
57
  }
58
58
  }
59
59
 
@@ -1,8 +1,11 @@
1
- const { App } = require('@slack/bolt');
1
+ 'use strict';
2
+
3
+ const { requireOptional } = require('../System/optional_require');
2
4
  const { handleChat } = require('../AI_Brain/Gemini_API');
3
5
 
4
6
  class SlackBridge {
5
7
  constructor(credentials) {
8
+ const { App } = requireOptional('@slack/bolt', 'npm install @slack/bolt');
6
9
  this.app = new App({
7
10
  token: credentials.botToken,
8
11
  appToken: credentials.appToken,
@@ -15,24 +18,18 @@ class SlackBridge {
15
18
  try {
16
19
  const text = event.text.replace(/<@.*?>/g, '').trim();
17
20
  if (!text) return;
18
-
19
21
  const result = await handleChat(text);
20
- if (result && result.response) {
21
- await say(result.response);
22
- }
22
+ if (result && result.response) await say(result.response);
23
23
  } catch (err) {
24
24
  console.error('[Slack Bridge] Error processing app_mention:', err);
25
25
  }
26
26
  });
27
27
 
28
28
  this.app.event('message', async ({ event, say }) => {
29
- // Only respond in DMs
30
29
  if (event.channel_type === 'im') {
31
30
  try {
32
31
  const result = await handleChat(event.text);
33
- if (result && result.response) {
34
- await say(result.response);
35
- }
32
+ if (result && result.response) await say(result.response);
36
33
  } catch (err) {
37
34
  console.error('[Slack Bridge] Error processing message:', err);
38
35
  }
@@ -44,9 +41,7 @@ class SlackBridge {
44
41
  }
45
42
 
46
43
  async disconnect() {
47
- if (this.app) {
48
- await this.app.stop();
49
- }
44
+ if (this.app) await this.app.stop();
50
45
  }
51
46
  }
52
47
 
@@ -1,9 +1,12 @@
1
- const { Telegraf } = require('telegraf');
1
+ 'use strict';
2
+
3
+ const { requireOptional } = require('../System/optional_require');
2
4
  const { handleChat } = require('../AI_Brain/Gemini_API');
3
5
 
4
6
  class TelegramBridge {
5
7
  constructor(token) {
6
8
  this.token = token;
9
+ const { Telegraf } = requireOptional('telegraf', 'npm install telegraf');
7
10
  this.bot = new Telegraf(token);
8
11
  }
9
12
 
@@ -12,19 +15,11 @@ class TelegramBridge {
12
15
 
13
16
  this.bot.on('text', async (ctx) => {
14
17
  try {
15
- // Show typing status
16
18
  await ctx.sendChatAction('typing');
17
-
18
19
  const message = ctx.message.text;
19
20
  if (!message) return;
20
-
21
- // Send to Mint AI Brain
22
21
  const result = await handleChat(message);
23
-
24
- // Reply to user
25
- if (result && result.response) {
26
- await ctx.reply(result.response);
27
- }
22
+ if (result && result.response) await ctx.reply(result.response);
28
23
  } catch (err) {
29
24
  console.error('[Telegram Bridge] Error processing message:', err);
30
25
  await ctx.reply('ขออภัยค่ะ เกิดข้อผิดพลาดบางอย่างในการประมวลผลข้อความ');
@@ -34,15 +29,12 @@ class TelegramBridge {
34
29
  this.bot.launch();
35
30
  console.log('[Telegram Bridge] Bot started!');
36
31
 
37
- // Enable graceful stop
38
32
  process.once('SIGINT', () => this.bot.stop('SIGINT'));
39
33
  process.once('SIGTERM', () => this.bot.stop('SIGTERM'));
40
34
  }
41
35
 
42
36
  async disconnect() {
43
- if (this.bot) {
44
- await this.bot.stop();
45
- }
37
+ if (this.bot) await this.bot.stop();
46
38
  }
47
39
  }
48
40
 
@@ -1,9 +1,16 @@
1
- const { Client, LocalAuth } = require('whatsapp-web.js');
2
- const qrcode = require('qrcode-terminal');
1
+ 'use strict';
2
+
3
+ const { requireOptional } = require('../System/optional_require');
3
4
  const { handleChat } = require('../AI_Brain/Gemini_API');
4
5
 
5
6
  class WhatsappBridge {
6
7
  constructor() {
8
+ // Dynamic require — only loads if user has installed whatsapp-web.js
9
+ const { Client, LocalAuth } = requireOptional(
10
+ 'whatsapp-web.js',
11
+ 'npm install whatsapp-web.js qrcode-terminal'
12
+ );
13
+ this._qrcode = requireOptional('qrcode-terminal', 'npm install qrcode-terminal');
7
14
  this.client = new Client({
8
15
  authStrategy: new LocalAuth({
9
16
  dataPath: require('path').join(require('os').homedir(), '.config', 'mint', 'whatsapp-session')
@@ -17,7 +24,7 @@ class WhatsappBridge {
17
24
  async connect() {
18
25
  this.client.on('qr', (qr) => {
19
26
  console.log('[WhatsApp Bridge] Scan this QR code to login:');
20
- qrcode.generate(qr, { small: true });
27
+ this._qrcode.generate(qr, { small: true });
21
28
  });
22
29
 
23
30
  this.client.on('ready', () => {
@@ -26,13 +33,8 @@ class WhatsappBridge {
26
33
 
27
34
  this.client.on('message', async (msg) => {
28
35
  try {
29
- // Ignore messages from groups unless mentioned (simple implementation)
30
36
  const chat = await msg.getChat();
31
- if (chat.isGroup) {
32
- // For groups, we could add a mention check here if desired
33
- return;
34
- }
35
-
37
+ if (chat.isGroup) return;
36
38
  const result = await handleChat(msg.body);
37
39
  if (result && result.response) {
38
40
  await msg.reply(result.response);
@@ -19,21 +19,29 @@ if (!fs.existsSync(CONFIG_DIR)) {
19
19
 
20
20
  const CHAT_HISTORY_PATH = path.join(CONFIG_DIR, 'mint-chat-history.json');
21
21
 
22
- // Migration Logic: Consolidate from Electron userData or old ~/.mint to ~/.config/mint
22
+ // Migration Logic: Consolidate from various legacy locations to ~/.config/mint/
23
23
  if (!fs.existsSync(CHAT_HISTORY_PATH)) {
24
24
  const electronUserData = app && app.getPath ? path.join(app.getPath('userData'), 'mint-chat-history.json') : null;
25
- const legacyPath = path.join(MINT_DIR, 'mint-chat-history.json');
25
+ const legacyDotMint = path.join(MINT_DIR, 'mint-chat-history.json');
26
+ // Legacy: file was written to the project root (CWD) before v1.5.2
27
+ const legacyProjectRoot = path.join(process.cwd(), 'mint-chat-history.json');
26
28
 
27
- if (electronUserData && fs.existsSync(electronUserData)) {
28
- try {
29
- fs.copyFileSync(electronUserData, CHAT_HISTORY_PATH);
30
- console.log('[History] Migrated chat history from Electron userData');
31
- } catch (e) { console.error('[History] Migration from Electron failed:', e); }
32
- } else if (fs.existsSync(legacyPath)) {
33
- try {
34
- fs.copyFileSync(legacyPath, CHAT_HISTORY_PATH);
35
- console.log('[History] Migrated chat history from ~/.mint');
36
- } catch (e) { console.error('[History] Migration from ~/.mint failed:', e); }
29
+ const candidates = [
30
+ electronUserData,
31
+ legacyDotMint,
32
+ legacyProjectRoot
33
+ ].filter(Boolean);
34
+
35
+ for (const candidate of candidates) {
36
+ if (candidate !== CHAT_HISTORY_PATH && fs.existsSync(candidate)) {
37
+ try {
38
+ fs.copyFileSync(candidate, CHAT_HISTORY_PATH);
39
+ console.log(`[History] Migrated chat history from ${candidate}`);
40
+ } catch (e) {
41
+ console.error('[History] Migration failed:', e);
42
+ }
43
+ break;
44
+ }
37
45
  }
38
46
  }
39
47
 
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Helper: ลอง require package แบบ dynamic
5
+ * ถ้าหาไม่เจอให้ throw Error พร้อม install guide
6
+ */
7
+ function requireOptional(pkg, installHint) {
8
+ try {
9
+ return require(pkg);
10
+ } catch (e) {
11
+ if (e.code === 'MODULE_NOT_FOUND') {
12
+ const hint = installHint || `npm install ${pkg}`;
13
+ throw new Error(
14
+ `[Mint] Optional package "${pkg}" is not installed.\n` +
15
+ `To use this feature, run: ${hint}\n` +
16
+ `(This package is not bundled by default to keep Mint lightweight.)`
17
+ );
18
+ }
19
+ throw e;
20
+ }
21
+ }
22
+
23
+ module.exports = { requireOptional };
@@ -7,6 +7,33 @@ window.Live2DManager = {
7
7
  resizeObserver: null,
8
8
  lipSyncInterval: null,
9
9
  expIndex: 0,
10
+ interactionEnabled: true,
11
+ interactionStorageKey: 'mint-model-interaction-enabled',
12
+ accessoryStorageKey: 'mint-live2d-accessories',
13
+ activeAccessories: {},
14
+ accessoryOrder: ['glasses', 'pen', 'cat'],
15
+ accessoryParams: {
16
+ glasses: { paramId: 'Param96', label: 'Glasses' },
17
+ pen: { paramId: 'Param68', label: 'Pen' },
18
+ cat: { paramId: 'Param54', label: 'Cat Filter' }
19
+ },
20
+ pointerTrackingEnabled: true,
21
+ pointerTrackingFrame: null,
22
+ pointerTracking: {
23
+ targetX: 0,
24
+ targetY: 0,
25
+ currentX: 0,
26
+ currentY: 0,
27
+ lastMoveAt: 0
28
+ },
29
+ pointerTrackingConfig: {
30
+ focusX: 0.35,
31
+ focusY: 0.35,
32
+ rangeX: 0.35,
33
+ rangeY: 0.35,
34
+ smoothing: 0.18
35
+ },
36
+ baseModelPosition: null,
10
37
  lastInteractionAt: 0,
11
38
  expressionToastTimeout: null,
12
39
  expressionParamIds: [
@@ -39,6 +66,8 @@ window.Live2DManager = {
39
66
 
40
67
  async loadModel(mountEl, statusEl, shellEl) {
41
68
  this.statusEl = statusEl; // Store for later use
69
+ this.interactionEnabled = this.getSavedInteractionEnabled();
70
+ this.activeAccessories = this.getSavedAccessories();
42
71
  if (!mountEl) return;
43
72
  if (statusEl) {
44
73
  statusEl.classList.remove('is-error');
@@ -74,7 +103,7 @@ window.Live2DManager = {
74
103
 
75
104
  const modelUrl = new URL('../../models/Shiroko_Model/Shiroko/Shiroko_Core/%E9%9D%A2%E9%A5%BC0.model3.json', window.location.href).href;
76
105
  this.model = await window.PIXI.live2d.Live2DModel.from(modelUrl, {
77
- autoInteract: true
106
+ autoInteract: false
78
107
  });
79
108
  this.expressionToastEl = document.getElementById('expression-toast');
80
109
 
@@ -82,8 +111,9 @@ window.Live2DManager = {
82
111
  this.app.stage.addChild(this.model);
83
112
 
84
113
  // -- Interaction Setup --
85
- this.model.interactive = true;
86
- this.model.buttonMode = true;
114
+ this.setInteractionEnabled(this.interactionEnabled);
115
+ this.setupPointerTracking(mountEl);
116
+ this.applyAccessories();
87
117
 
88
118
  // Tap Interaction. This model does not define Cubism HitAreas, so use
89
119
  // normalized model coordinates to provide stable region reactions.
@@ -105,11 +135,15 @@ window.Live2DManager = {
105
135
  const heightScale = mountHeight / Math.max(modelHeight, 1);
106
136
 
107
137
  // Reduced zoom to 2.0 as requested
108
- const scale = Math.min(widthScale, heightScale) * 1.8;
138
+ const scale = Math.min(widthScale, heightScale) * 1.85;
109
139
 
110
140
  this.model.scale.set(scale);
111
141
  // Adjusted Y offset to 1.0 as requested
112
- this.model.position.set(mountWidth / 2, mountHeight / 2 + mountHeight * 0.6);
142
+ this.baseModelPosition = {
143
+ x: mountWidth / 2,
144
+ y: mountHeight / 2 + mountHeight * 0.55
145
+ };
146
+ this.applyModelFollowOffset();
113
147
  };
114
148
 
115
149
  requestAnimationFrame(() => {
@@ -147,7 +181,7 @@ window.Live2DManager = {
147
181
  },
148
182
 
149
183
  handleModelTap(event) {
150
- if (!this.model) return;
184
+ if (!this.model || !this.interactionEnabled) return;
151
185
 
152
186
  const now = Date.now();
153
187
  if (now - this.lastInteractionAt < 3000) return;
@@ -182,7 +216,7 @@ window.Live2DManager = {
182
216
  if (!point) return null;
183
217
  const { x, y } = point;
184
218
 
185
- if (this.isPointInZone(x, y, 0.37, 0.395, 0.25, 0.13)) {
219
+ if (this.isPointInZone(x, y, 0.38, 0.40, 0.24, 0.115)) {
186
220
  return {
187
221
  id: 'face',
188
222
  label: 'Cat Ears',
@@ -191,7 +225,7 @@ window.Live2DManager = {
191
225
  };
192
226
  }
193
227
 
194
- if (this.isPointInZone(x, y, 0.35, 0.30, 0.29, 0.09)) {
228
+ if (this.isPointInZone(x, y, 0.34, 0.255, 0.32, 0.15)) {
195
229
  return {
196
230
  id: 'head',
197
231
  label: 'Head Pat',
@@ -200,8 +234,8 @@ window.Live2DManager = {
200
234
  };
201
235
  }
202
236
 
203
- const isLeftHand = this.isPointInZone(x, y, 0.17, 0.65, 0.19, 0.17);
204
- const isRightHand = this.isPointInZone(x, y, 0.70, 0.67, 0.17, 0.17);
237
+ const isLeftHand = this.isPointInZone(x, y, 0.22, 0.68, 0.20, 0.16);
238
+ const isRightHand = this.isPointInZone(x, y, 0.61, 0.68, 0.19, 0.16);
205
239
  if (isLeftHand || isRightHand) {
206
240
  return {
207
241
  id: isLeftHand ? 'left-hand' : 'right-hand',
@@ -211,7 +245,7 @@ window.Live2DManager = {
211
245
  };
212
246
  }
213
247
 
214
- if (this.isPointInZone(x, y, 0.31, 0.76, 0.38, 0.24)) {
248
+ if (this.isPointInZone(x, y, 0.38, 0.77, 0.30, 0.23)) {
215
249
  return {
216
250
  id: 'lower-body',
217
251
  label: 'Careful',
@@ -220,7 +254,7 @@ window.Live2DManager = {
220
254
  };
221
255
  }
222
256
 
223
- if (this.isPointInZone(x, y, 0.36, 0.55, 0.29, 0.15)) {
257
+ if (this.isPointInZone(x, y, 0.37, 0.555, 0.29, 0.14)) {
224
258
  return {
225
259
  id: 'body',
226
260
  label: 'Shoulder Tap',
@@ -271,6 +305,112 @@ window.Live2DManager = {
271
305
  this.showExpressionToast(`Expression: ${nextExp.label}`);
272
306
  },
273
307
 
308
+ setInteractionEnabled(isEnabled, persist = false) {
309
+ this.interactionEnabled = Boolean(isEnabled);
310
+ if (persist) {
311
+ this.saveInteractionEnabled(this.interactionEnabled);
312
+ }
313
+ if (!this.model) return;
314
+
315
+ this.model.interactive = this.interactionEnabled;
316
+ this.model.buttonMode = this.interactionEnabled;
317
+ },
318
+
319
+ getSavedInteractionEnabled() {
320
+ try {
321
+ return localStorage.getItem(this.interactionStorageKey) !== 'false';
322
+ } catch (_) {
323
+ return true;
324
+ }
325
+ },
326
+
327
+ saveInteractionEnabled(isEnabled) {
328
+ try {
329
+ localStorage.setItem(this.interactionStorageKey, String(Boolean(isEnabled)));
330
+ } catch (_) {}
331
+ },
332
+
333
+ setupPointerTracking(mountEl) {
334
+ if (!mountEl) return;
335
+
336
+ window.addEventListener('mousemove', (event) => this.updatePointerTrackingTarget(event, mountEl));
337
+
338
+ if (!this.pointerTrackingFrame) {
339
+ this.pointerTrackingFrame = () => this.updatePointerTracking();
340
+ this.app?.ticker?.add(this.pointerTrackingFrame);
341
+ }
342
+ },
343
+
344
+ updatePointerTrackingTarget(event, mountEl) {
345
+ if (!this.pointerTrackingEnabled || !mountEl) return;
346
+
347
+ const rect = {
348
+ left: 0,
349
+ top: 0,
350
+ width: window.innerWidth || mountEl.getBoundingClientRect().width,
351
+ height: window.innerHeight || mountEl.getBoundingClientRect().height
352
+ };
353
+ const config = this.pointerTrackingConfig;
354
+ const centerX = rect.left + rect.width * config.focusX;
355
+ const centerY = rect.top + rect.height * config.focusY;
356
+ const rangeX = Math.max(rect.width * config.rangeX, 1);
357
+ const rangeY = Math.max(rect.height * config.rangeY, 1);
358
+
359
+ this.pointerTracking.targetX = this.clamp((event.clientX - centerX) / rangeX, -1, 1);
360
+ this.pointerTracking.targetY = this.clamp((event.clientY - centerY) / rangeY, -1, 1);
361
+ this.pointerTracking.lastMoveAt = performance.now();
362
+ },
363
+
364
+ resetPointerTrackingTarget() {
365
+ this.pointerTracking.targetX = 0;
366
+ this.pointerTracking.targetY = 0;
367
+ },
368
+
369
+ updatePointerTracking() {
370
+ if (!this.model || !this.pointerTrackingEnabled) return;
371
+
372
+ const tracking = this.pointerTracking;
373
+ const smoothing = this.pointerTrackingConfig.smoothing;
374
+ tracking.currentX += (tracking.targetX - tracking.currentX) * smoothing;
375
+ tracking.currentY += (tracking.targetY - tracking.currentY) * smoothing;
376
+
377
+ const x = tracking.currentX;
378
+ const y = tracking.currentY;
379
+ const core = this.model?.internalModel?.coreModel;
380
+ if (!core) return;
381
+
382
+ this.setLive2DParam(core, 'ParamAngleX', x * 18);
383
+ this.setLive2DParam(core, 'ParamAngleY', -y * 14);
384
+ this.setLive2DParam(core, 'ParamAngleZ', -x * 5);
385
+ this.setLive2DParam(core, 'ParamEyeBallX', x * 1.45);
386
+ this.setLive2DParam(core, 'ParamEyeBallY', -y * 1.35);
387
+ this.setLive2DParam(core, 'Param49', x * 7);
388
+ this.setLive2DParam(core, 'Param51', -y * 5);
389
+ this.setLive2DParam(core, 'Param50', -x * 3);
390
+ this.applyModelFollowOffset();
391
+ },
392
+
393
+ applyModelFollowOffset() {
394
+ if (!this.model || !this.baseModelPosition) return;
395
+
396
+ const x = this.pointerTracking.currentX || 0;
397
+ const y = this.pointerTracking.currentY || 0;
398
+ this.model.position.set(
399
+ this.baseModelPosition.x + x * 22,
400
+ this.baseModelPosition.y + y * 16
401
+ );
402
+ },
403
+
404
+ setLive2DParam(core, id, value) {
405
+ try {
406
+ core.setParameterValueById(id, value);
407
+ } catch (_) {}
408
+ },
409
+
410
+ clamp(value, min, max) {
411
+ return Math.max(min, Math.min(max, value));
412
+ },
413
+
274
414
  showExpressionToast(text, duration = 1600) {
275
415
  const toast = this.expressionToastEl || document.getElementById('expression-toast');
276
416
  if (!toast) return;
@@ -296,6 +436,7 @@ window.Live2DManager = {
296
436
 
297
437
  try {
298
438
  this.model.expression(expressionId);
439
+ requestAnimationFrame(() => this.applyAccessories());
299
440
  } catch (error) {
300
441
  console.error(`[Live2D] Failed to apply expression: ${expressionId}`, error);
301
442
  }
@@ -328,7 +469,64 @@ window.Live2DManager = {
328
469
  }
329
470
 
330
471
  this.resetExpressionParams();
331
- requestAnimationFrame(() => this.resetExpressionParams());
472
+ requestAnimationFrame(() => {
473
+ this.resetExpressionParams();
474
+ this.applyAccessories();
475
+ });
476
+ },
477
+
478
+ setAccessory(accessoryId, isEnabled, persist = false) {
479
+ if (!this.accessoryParams[accessoryId]) return;
480
+
481
+ this.activeAccessories[accessoryId] = Boolean(isEnabled);
482
+ if (persist) {
483
+ this.saveAccessories();
484
+ }
485
+ this.applyAccessory(accessoryId);
486
+ },
487
+
488
+ setExclusiveAccessory(accessoryId, persist = false) {
489
+ const nextAccessoryId = this.accessoryParams[accessoryId] ? accessoryId : null;
490
+ Object.keys(this.accessoryParams).forEach(id => {
491
+ this.activeAccessories[id] = id === nextAccessoryId;
492
+ });
493
+ if (persist) {
494
+ this.saveAccessories();
495
+ }
496
+ this.applyAccessories();
497
+ return nextAccessoryId;
498
+ },
499
+
500
+ getActiveAccessoryId() {
501
+ return this.accessoryOrder.find(id => this.activeAccessories[id]) || null;
502
+ },
503
+
504
+ applyAccessories() {
505
+ Object.keys(this.accessoryParams).forEach(accessoryId => {
506
+ this.applyAccessory(accessoryId);
507
+ });
508
+ },
509
+
510
+ applyAccessory(accessoryId) {
511
+ const accessory = this.accessoryParams[accessoryId];
512
+ const core = this.model?.internalModel?.coreModel;
513
+ if (!accessory || !core) return;
514
+
515
+ this.setLive2DParam(core, accessory.paramId, this.activeAccessories[accessoryId] ? 1 : 0);
516
+ },
517
+
518
+ getSavedAccessories() {
519
+ try {
520
+ return JSON.parse(localStorage.getItem(this.accessoryStorageKey) || '{}') || {};
521
+ } catch (_) {
522
+ return {};
523
+ }
524
+ },
525
+
526
+ saveAccessories() {
527
+ try {
528
+ localStorage.setItem(this.accessoryStorageKey, JSON.stringify(this.activeAccessories));
529
+ } catch (_) {}
332
530
  },
333
531
 
334
532
  startLipSync() {