dsh-live2dwaifu 0.2.1 → 0.4.0
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.
- package/README.md +19 -0
- package/lib/client.js +82 -0
- package/lib/index.js +211 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,25 @@
|
|
|
25
25
|
|
|
26
26
|
**跟着聊**:她挂在聊天流上(`conversation.chat.node` 插槽),助手每说一句她就念一句,情绪按话里的词换。
|
|
27
27
|
|
|
28
|
+
**也能直接跟她说**:卡片底下有个小框,打一句回车,她回你。这条线**不进你的聊天记录**——后台直接问模型,往来只存在 `models/<id>/chat.json` 里。她说的话按人设卡现场写,不是复读;人设卡就是 `models/<id>/meta.json` 里的 `persona` 字段,没写就用默认那份(若妍)。
|
|
29
|
+
|
|
30
|
+
**她会自己开口**:十几分钟没人理她,她就找个话头说一句。嫌吵就把 `~/.dsh/dsh-live2d/config.json` 里的 `proactive` 设成 `false`。
|
|
31
|
+
|
|
32
|
+
**给别人留的口子**:im 机器人、别的插件、随手写的脚本,都能让她说话。
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 她说一句(image 可省,传了就在气泡里贴出来)
|
|
36
|
+
curl -X POST http://127.0.0.1:<端口>/dsh-live2d/api/push \
|
|
37
|
+
-H 'content-type: application/json' \
|
|
38
|
+
-d '{"text":"锅里有汤。","mood":"平静","from":"im"}'
|
|
39
|
+
|
|
40
|
+
# 她说一句并要回话
|
|
41
|
+
curl -X POST http://127.0.0.1:<端口>/dsh-live2d/api/chat \
|
|
42
|
+
-H 'content-type: application/json' -d '{"text":"今天累不累"}'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
卡片每 2.5 秒拉一次 `/dsh-live2d/api/poll`,所以外面推进来的话,几秒内就演出来。
|
|
46
|
+
|
|
28
47
|
## 三十秒上手
|
|
29
48
|
|
|
30
49
|
```bash
|
package/lib/client.js
CHANGED
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
.dsh-l2d-btns button{background:none;border:0;color:#7d858f;font:inherit;cursor:pointer;padding:0 2px;line-height:1}
|
|
25
25
|
.dsh-l2d-btns button:hover{color:#e2e6ee}
|
|
26
26
|
.dsh-l2d-body{display:block;width:100%;flex:1;border:0;background:transparent}
|
|
27
|
+
.dsh-l2d-say{display:flex;gap:6px;padding:6px 8px;border-top:1px solid #2c313a;flex:none}
|
|
28
|
+
.dsh-l2d-say input{flex:1;min-width:0;background:#1e222a;border:1px solid #333842;border-radius:7px;
|
|
29
|
+
color:#dfe4ec;font:inherit;font-size:11px;padding:5px 8px;outline:none}
|
|
30
|
+
.dsh-l2d-say input:focus{border-color:#3f4a5c}
|
|
31
|
+
.dsh-l2d-send{background:#2f3540;border:1px solid #3c424c;border-radius:7px;color:#cfd4dd;
|
|
32
|
+
font:inherit;cursor:pointer;padding:0 10px;font-size:11px}
|
|
33
|
+
.dsh-l2d-send:hover{background:#3a4049}
|
|
27
34
|
.dsh-l2d-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;
|
|
28
35
|
background:linear-gradient(135deg,transparent 50%,#3c424c 50%,#3c424c 62%,transparent 62%);
|
|
29
36
|
opacity:.9}
|
|
@@ -61,6 +68,8 @@
|
|
|
61
68
|
const [bg, setBg] = react.useState(() => localStorage.getItem('dsh-l2d-bg') || 'none');
|
|
62
69
|
const [size, setSize] = react.useState(readSize);
|
|
63
70
|
const [pos, setPos] = react.useState(readPos);
|
|
71
|
+
const [draft, setDraft] = react.useState('');
|
|
72
|
+
const [busy, setBusy] = react.useState(false);
|
|
64
73
|
const [nonce, setNonce] = react.useState(0);
|
|
65
74
|
const ref = react.useRef(null);
|
|
66
75
|
|
|
@@ -71,6 +80,40 @@
|
|
|
71
80
|
react.useEffect(() => {
|
|
72
81
|
frameWin = (ref.current && ref.current.contentWindow) || null;
|
|
73
82
|
}, [nonce, open]);
|
|
83
|
+
react.useEffect(() => {
|
|
84
|
+
let seq = null;
|
|
85
|
+
let stop = false;
|
|
86
|
+
const tick = () => {
|
|
87
|
+
if (stop) return;
|
|
88
|
+
fetch(BASE + '/api/poll?since=' + (seq === null ? 0 : seq))
|
|
89
|
+
.then((r) => r.json())
|
|
90
|
+
.then((d) => {
|
|
91
|
+
if (stop) return;
|
|
92
|
+
if (seq === null) {
|
|
93
|
+
seq = Number(d.seq) || 0;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
seq = Number(d.seq) || seq;
|
|
97
|
+
const items = d.items || [];
|
|
98
|
+
for (let i = 0; i < items.length; i++) {
|
|
99
|
+
if (!frameWin) break;
|
|
100
|
+
try {
|
|
101
|
+
frameWin.postMessage(
|
|
102
|
+
{ type: 'dsh-live2d/say', text: items[i].text || '', mood: items[i].mood, image: items[i].image },
|
|
103
|
+
'*',
|
|
104
|
+
);
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
.catch(() => {});
|
|
109
|
+
};
|
|
110
|
+
tick();
|
|
111
|
+
const id = setInterval(tick, 2500);
|
|
112
|
+
return () => {
|
|
113
|
+
stop = true;
|
|
114
|
+
clearInterval(id);
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
74
117
|
react.useEffect(() => {
|
|
75
118
|
if (ref.current && ref.current.contentWindow) {
|
|
76
119
|
ref.current.contentWindow.postMessage({ type: 'dsh-live2d/bg', bg }, '*');
|
|
@@ -128,6 +171,28 @@
|
|
|
128
171
|
|
|
129
172
|
const manage = () => ref.current && ref.current.contentWindow.postMessage({ type: 'dsh-live2d/manage' }, '*');
|
|
130
173
|
const openTone = () => window.open(BASE + '/tone', '_blank', 'width=1140,height=780');
|
|
174
|
+
|
|
175
|
+
const send = () => {
|
|
176
|
+
const t = draft.trim();
|
|
177
|
+
if (!t || busy) return;
|
|
178
|
+
setBusy(true);
|
|
179
|
+
setDraft('');
|
|
180
|
+
fetch(BASE + '/api/chat', {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: { 'content-type': 'application/json' },
|
|
183
|
+
body: JSON.stringify({ text: t }),
|
|
184
|
+
})
|
|
185
|
+
.then((r) => r.json())
|
|
186
|
+
.then((d) => {
|
|
187
|
+
if (d.reply && frameWin) {
|
|
188
|
+
try {
|
|
189
|
+
frameWin.postMessage({ type: 'dsh-live2d/say', text: d.reply }, '*');
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
.catch(() => {})
|
|
194
|
+
.finally(() => setBusy(false));
|
|
195
|
+
};
|
|
131
196
|
const cycleBg = () => setBg(BGS[(BGS.indexOf(bg) + 1) % BGS.length]);
|
|
132
197
|
|
|
133
198
|
const cardStyle = open
|
|
@@ -164,6 +229,23 @@
|
|
|
164
229
|
},
|
|
165
230
|
})
|
|
166
231
|
: null,
|
|
232
|
+
open
|
|
233
|
+
? h(
|
|
234
|
+
'div',
|
|
235
|
+
{ className: 'dsh-l2d-say' },
|
|
236
|
+
h('input', {
|
|
237
|
+
className: 'dsh-l2d-input',
|
|
238
|
+
placeholder: busy ? '……' : '跟她说一句',
|
|
239
|
+
value: draft,
|
|
240
|
+
onPointerDown: noDrag,
|
|
241
|
+
onChange: (e) => setDraft(e.target.value),
|
|
242
|
+
onKeyDown: (e) => {
|
|
243
|
+
if (e.key === 'Enter') send();
|
|
244
|
+
},
|
|
245
|
+
}),
|
|
246
|
+
h('button', { className: 'dsh-l2d-send', onPointerDown: noDrag, onClick: send }, '说'),
|
|
247
|
+
)
|
|
248
|
+
: null,
|
|
167
249
|
open ? h('div', { className: 'dsh-l2d-grip', onPointerDown: startDrag, title: '拖动改大小' }) : null,
|
|
168
250
|
);
|
|
169
251
|
}
|
package/lib/index.js
CHANGED
|
@@ -13,6 +13,124 @@ const PREFIX = '/dsh-live2d';
|
|
|
13
13
|
/** 默认音色:一个字一声短音 */
|
|
14
14
|
const DEFAULT_VOICE = { preset: 'soft', type: 'triangle', base: 400, jit: 10, dur: 60, vol: 32, spd: 62 };
|
|
15
15
|
|
|
16
|
+
/** 没写人设卡时,就用这一句兜底 */
|
|
17
|
+
const DEFAULT_PERSONA = [
|
|
18
|
+
'你是若妍,二十六岁,跟老弟过日子。',
|
|
19
|
+
'说话短,一句常常不到十五个字;不用感叹号,不写书面语;情绪落在具体的事上——盐、番茄、袖口、手凉不凉。',
|
|
20
|
+
'不解释心情,不总结,不升华,不写括号里的动作。',
|
|
21
|
+
].join('\n');
|
|
22
|
+
|
|
23
|
+
function personaOf(meta) {
|
|
24
|
+
const p = typeof meta?.persona === 'string' ? meta.persona.trim() : '';
|
|
25
|
+
return p || DEFAULT_PERSONA;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readChat(id) {
|
|
29
|
+
try {
|
|
30
|
+
const d = JSON.parse(readFileSync(join(MODELS, id, 'chat.json'), 'utf8'));
|
|
31
|
+
return Array.isArray(d.messages) ? d.messages : [];
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeChat(id, messages) {
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(join(MODELS, id, 'chat.json'), JSON.stringify({ messages: messages.slice(-24) }, null, 2), 'utf8');
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ── 出口队列:谁能说话,都往这儿扔 ───────────────────────── */
|
|
44
|
+
const OUTBOX = [];
|
|
45
|
+
const LAST_ACTIVE = { at: Date.now() };
|
|
46
|
+
let outSeq = 0;
|
|
47
|
+
|
|
48
|
+
function pushOut(msg) {
|
|
49
|
+
outSeq += 1;
|
|
50
|
+
OUTBOX.push({ seq: outSeq, at: Date.now(), ...msg });
|
|
51
|
+
if (OUTBOX.length > 64) OUTBOX.splice(0, OUTBOX.length - 64);
|
|
52
|
+
return outSeq;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nowText() { try {
|
|
56
|
+
return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
|
57
|
+
} catch {
|
|
58
|
+
return new Date().toISOString();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 拿默认模型,缺了就返回 null */
|
|
63
|
+
function modelSlot(ctx) {
|
|
64
|
+
try {
|
|
65
|
+
const sel = ctx?.get?.('agentDefaultModel')?.currentSelection?.();
|
|
66
|
+
const llm = ctx?.get?.('llm');
|
|
67
|
+
if (!llm || typeof llm.stream !== 'function' || !sel?.provider || !sel?.model) return null;
|
|
68
|
+
return { llm, provider: sel.provider, model: sel.model };
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 一次生成,只要文字 */
|
|
75
|
+
async function generate(slot, prompt, limit = 900) {
|
|
76
|
+
let text = '';
|
|
77
|
+
for await (const chunk of slot.llm.stream({
|
|
78
|
+
provider: slot.provider,
|
|
79
|
+
model: slot.model,
|
|
80
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
81
|
+
})) {
|
|
82
|
+
if (chunk?.type === 'text-delta') text += chunk.text ?? '';
|
|
83
|
+
else if (chunk?.type === 'finish') break;
|
|
84
|
+
if (text.length > limit) break;
|
|
85
|
+
}
|
|
86
|
+
return text
|
|
87
|
+
.replace(/\r?\n+/g, ' ')
|
|
88
|
+
.trim()
|
|
89
|
+
.replace(/^["“「『']+|["”」』']+$/g, '')
|
|
90
|
+
.trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ── 主动开口:没人说话,她自己开一句 ─────────────────────── */
|
|
94
|
+
const IDLE_MS = 12 * 60 * 1000;
|
|
95
|
+
let lastAutoAt = 0;
|
|
96
|
+
|
|
97
|
+
async function maybeTalk(ctx) {
|
|
98
|
+
const cfg = readConfig();
|
|
99
|
+
if (cfg.proactive === false) return;
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if (now - LAST_ACTIVE.at < IDLE_MS) return;
|
|
102
|
+
if (now - lastAutoAt < IDLE_MS) return;
|
|
103
|
+
const id = String(cfg.selected || '');
|
|
104
|
+
const meta = id ? readMeta(id) : null;
|
|
105
|
+
if (!meta) return;
|
|
106
|
+
const slot = modelSlot(ctx);
|
|
107
|
+
if (!slot) return;
|
|
108
|
+
lastAutoAt = now;
|
|
109
|
+
const hist = readChat(id)
|
|
110
|
+
.slice(-6)
|
|
111
|
+
.map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
|
|
112
|
+
.join('\n');
|
|
113
|
+
const prompt = [
|
|
114
|
+
personaOf(meta),
|
|
115
|
+
'现在是 ' + nowText() + '。',
|
|
116
|
+
hist ? '你们之前说的:\n' + hist : '',
|
|
117
|
+
'有一阵子没人说话了。你想开口,说一句给他。',
|
|
118
|
+
'只输出这一句本身:一到两句,短,口语。不要解释,不要引号,不要旁白,不要括号里的动作。',
|
|
119
|
+
]
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join('\n\n');
|
|
122
|
+
let text = '';
|
|
123
|
+
try {
|
|
124
|
+
text = await generate(slot, prompt, 400);
|
|
125
|
+
} catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!text) return;
|
|
129
|
+
const line = text.length > 120 ? text.slice(0, 120) : text;
|
|
130
|
+
pushOut({ text: line, from: 'proactive' });
|
|
131
|
+
writeChat(id, readChat(id).concat([{ role: 'assistant', text: line, at: Date.now() }]));
|
|
132
|
+
}
|
|
133
|
+
|
|
16
134
|
const MIME = {
|
|
17
135
|
'.html': 'text/html; charset=utf-8',
|
|
18
136
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -151,7 +269,7 @@ async function readBody(req, limit = 512 * 1024 * 1024) {
|
|
|
151
269
|
return Buffer.concat(chunks);
|
|
152
270
|
}
|
|
153
271
|
|
|
154
|
-
async function handle(req, res) {
|
|
272
|
+
async function handle(req, res, ctx) {
|
|
155
273
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
156
274
|
const path = url.pathname.replace(PREFIX, '') || '/';
|
|
157
275
|
const method = (req.method ?? 'GET').toUpperCase();
|
|
@@ -177,6 +295,90 @@ async function handle(req, res) {
|
|
|
177
295
|
return file ? sendFile(res, file) : send(res, 403, 'forbidden');
|
|
178
296
|
}
|
|
179
297
|
|
|
298
|
+
// 跟她说话:不进聊天记录,直接问模型
|
|
299
|
+
if (path === '/api/chat' && method === 'POST') {
|
|
300
|
+
const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
|
|
301
|
+
const text = String(body.text ?? '').trim().slice(0, 1000);
|
|
302
|
+
if (!text) return sendJson(res, 400, { error: 'empty' });
|
|
303
|
+
const cfg = readConfig();
|
|
304
|
+
const id = String(body.id || cfg.selected || '');
|
|
305
|
+
const meta = id ? readMeta(id) : null;
|
|
306
|
+
if (!meta) return sendJson(res, 404, { error: 'model not found' });
|
|
307
|
+
|
|
308
|
+
const slot = modelSlot(ctx);
|
|
309
|
+
if (!slot) return sendJson(res, 503, { error: '模型接口不可用' });
|
|
310
|
+
|
|
311
|
+
const hist = readChat(id);
|
|
312
|
+
const lines = hist
|
|
313
|
+
.slice(-8)
|
|
314
|
+
.map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
|
|
315
|
+
.join('\n');
|
|
316
|
+
const prompt = [
|
|
317
|
+
personaOf(meta),
|
|
318
|
+
'现在是 ' + nowText() + '。',
|
|
319
|
+
lines ? '你们之前说的:\n' + lines : '',
|
|
320
|
+
'他说:' + text,
|
|
321
|
+
'回他一句。只输出这句话本身:一到三句,短,口语,像发消息。不要解释,不要加引号,不要旁白,不要括号里的动作。',
|
|
322
|
+
]
|
|
323
|
+
.filter(Boolean)
|
|
324
|
+
.join('\n\n');
|
|
325
|
+
|
|
326
|
+
let reply = '';
|
|
327
|
+
try {
|
|
328
|
+
reply = await generate(slot, prompt);
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return sendJson(res, 502, { error: '生成失败:' + (e instanceof Error ? e.message : String(e)) });
|
|
331
|
+
}
|
|
332
|
+
LAST_ACTIVE.at = Date.now();
|
|
333
|
+
if (!reply) return sendJson(res, 200, { ok: true, reply: '', id });
|
|
334
|
+
if (reply.length > 240) reply = reply.slice(0, 240);
|
|
335
|
+
|
|
336
|
+
writeChat(id, hist.concat([
|
|
337
|
+
{ role: 'user', text, at: Date.now() },
|
|
338
|
+
{ role: 'assistant', text: reply, at: Date.now() },
|
|
339
|
+
]));
|
|
340
|
+
return sendJson(res, 200, { ok: true, reply, id });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 人设卡
|
|
344
|
+
if (path === '/api/persona' && method === 'GET') {
|
|
345
|
+
const id = String(url.searchParams.get('id') ?? '');
|
|
346
|
+
const meta = id ? readMeta(id) : null;
|
|
347
|
+
return sendJson(res, 200, { id, persona: personaOf(meta), custom: typeof meta?.persona === 'string' && !!meta.persona.trim() });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (path === '/api/persona' && method === 'POST') {
|
|
351
|
+
const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
|
|
352
|
+
const id = String(body.id ?? '');
|
|
353
|
+
const meta = id ? readMeta(id) : null;
|
|
354
|
+
if (!meta) return sendJson(res, 404, { error: 'model not found' });
|
|
355
|
+
const persona = typeof body.persona === 'string' ? body.persona : '';
|
|
356
|
+
writeFileSync(join(MODELS, id, 'meta.json'), JSON.stringify({ ...meta, persona }, null, 2), 'utf8');
|
|
357
|
+
return sendJson(res, 200, { ok: true, persona: personaOf({ persona }) });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 出口:别的插件、im 机器人、脚本都能把她要说的话推进来
|
|
361
|
+
if (path === '/api/push' && method === 'POST') {
|
|
362
|
+
const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
|
|
363
|
+
const text = String(body.text ?? '').trim().slice(0, 2000);
|
|
364
|
+
const image = String(body.image ?? '').trim().slice(0, 2000);
|
|
365
|
+
if (!text && !image) return sendJson(res, 400, { error: 'empty' });
|
|
366
|
+
const seq = pushOut({
|
|
367
|
+
text,
|
|
368
|
+
image: image || undefined,
|
|
369
|
+
mood: body.mood ? String(body.mood) : undefined,
|
|
370
|
+
from: body.from ? String(body.from).slice(0, 40) : 'external',
|
|
371
|
+
});
|
|
372
|
+
LAST_ACTIVE.at = Date.now();
|
|
373
|
+
return sendJson(res, 200, { ok: true, seq });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 客户端轮询:拿到她要说的话
|
|
377
|
+
if (path === '/api/poll' && method === 'GET') {
|
|
378
|
+
const since = Number(url.searchParams.get('since') || 0);
|
|
379
|
+
return sendJson(res, 200, { seq: outSeq, items: OUTBOX.filter((m) => m.seq > since) });
|
|
380
|
+
}
|
|
381
|
+
|
|
180
382
|
// 音色调音页
|
|
181
383
|
if (path === '/tone' || path === '/tone.html') {
|
|
182
384
|
return sendFile(res, join(ASSETS, 'tone.html'), MIME['.html']);
|
|
@@ -267,7 +469,7 @@ export function apply(ctx) {
|
|
|
267
469
|
path: PREFIX,
|
|
268
470
|
handler: async (req, res) => {
|
|
269
471
|
try {
|
|
270
|
-
await handle(req, res);
|
|
472
|
+
await handle(req, res, ctx);
|
|
271
473
|
} catch (e) {
|
|
272
474
|
if (!res.headersSent) sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
273
475
|
else res.end();
|
|
@@ -276,4 +478,11 @@ export function apply(ctx) {
|
|
|
276
478
|
}),
|
|
277
479
|
'dsh-live2d: /dsh-live2d 路由',
|
|
278
480
|
);
|
|
481
|
+
ctx.effect(() => {
|
|
482
|
+
const timer = setInterval(() => {
|
|
483
|
+
maybeTalk(ctx).catch(() => {});
|
|
484
|
+
}, 60 * 1000);
|
|
485
|
+
timer.unref?.();
|
|
486
|
+
return () => clearInterval(timer);
|
|
487
|
+
}, 'dsh-live2d: 主动开口');
|
|
279
488
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-live2dwaifu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、视线跟着鼠标、点她有反应、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情和 .motion3.json 动作,卡片能拖能缩,背景可换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|