dsh-live2dwaifu 0.2.1 → 0.3.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 +2 -0
- package/lib/client.js +48 -0
- package/lib/index.js +113 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
**跟着聊**:她挂在聊天流上(`conversation.chat.node` 插槽),助手每说一句她就念一句,情绪按话里的词换。
|
|
27
27
|
|
|
28
|
+
**也能直接跟她说**:卡片底下有个小框,打一句回车,她回你。这条线**不进你的聊天记录**——后台直接问模型,往来只存在 `models/<id>/chat.json` 里。她说的话按人设卡现场写,不是复读;人设卡就是 `models/<id>/meta.json` 里的 `persona` 字段,没写就用默认那份(若妍)。
|
|
29
|
+
|
|
28
30
|
## 三十秒上手
|
|
29
31
|
|
|
30
32
|
```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
|
|
|
@@ -128,6 +137,28 @@
|
|
|
128
137
|
|
|
129
138
|
const manage = () => ref.current && ref.current.contentWindow.postMessage({ type: 'dsh-live2d/manage' }, '*');
|
|
130
139
|
const openTone = () => window.open(BASE + '/tone', '_blank', 'width=1140,height=780');
|
|
140
|
+
|
|
141
|
+
const send = () => {
|
|
142
|
+
const t = draft.trim();
|
|
143
|
+
if (!t || busy) return;
|
|
144
|
+
setBusy(true);
|
|
145
|
+
setDraft('');
|
|
146
|
+
fetch(BASE + '/api/chat', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'content-type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({ text: t }),
|
|
150
|
+
})
|
|
151
|
+
.then((r) => r.json())
|
|
152
|
+
.then((d) => {
|
|
153
|
+
if (d.reply && frameWin) {
|
|
154
|
+
try {
|
|
155
|
+
frameWin.postMessage({ type: 'dsh-live2d/say', text: d.reply }, '*');
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
.catch(() => {})
|
|
160
|
+
.finally(() => setBusy(false));
|
|
161
|
+
};
|
|
131
162
|
const cycleBg = () => setBg(BGS[(BGS.indexOf(bg) + 1) % BGS.length]);
|
|
132
163
|
|
|
133
164
|
const cardStyle = open
|
|
@@ -164,6 +195,23 @@
|
|
|
164
195
|
},
|
|
165
196
|
})
|
|
166
197
|
: null,
|
|
198
|
+
open
|
|
199
|
+
? h(
|
|
200
|
+
'div',
|
|
201
|
+
{ className: 'dsh-l2d-say' },
|
|
202
|
+
h('input', {
|
|
203
|
+
className: 'dsh-l2d-input',
|
|
204
|
+
placeholder: busy ? '……' : '跟她说一句',
|
|
205
|
+
value: draft,
|
|
206
|
+
onPointerDown: noDrag,
|
|
207
|
+
onChange: (e) => setDraft(e.target.value),
|
|
208
|
+
onKeyDown: (e) => {
|
|
209
|
+
if (e.key === 'Enter') send();
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
h('button', { className: 'dsh-l2d-send', onPointerDown: noDrag, onClick: send }, '说'),
|
|
213
|
+
)
|
|
214
|
+
: null,
|
|
167
215
|
open ? h('div', { className: 'dsh-l2d-grip', onPointerDown: startDrag, title: '拖动改大小' }) : null,
|
|
168
216
|
);
|
|
169
217
|
}
|
package/lib/index.js
CHANGED
|
@@ -13,6 +13,33 @@ 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
|
+
|
|
16
43
|
const MIME = {
|
|
17
44
|
'.html': 'text/html; charset=utf-8',
|
|
18
45
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -151,7 +178,7 @@ async function readBody(req, limit = 512 * 1024 * 1024) {
|
|
|
151
178
|
return Buffer.concat(chunks);
|
|
152
179
|
}
|
|
153
180
|
|
|
154
|
-
async function handle(req, res) {
|
|
181
|
+
async function handle(req, res, ctx) {
|
|
155
182
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
156
183
|
const path = url.pathname.replace(PREFIX, '') || '/';
|
|
157
184
|
const method = (req.method ?? 'GET').toUpperCase();
|
|
@@ -177,6 +204,90 @@ async function handle(req, res) {
|
|
|
177
204
|
return file ? sendFile(res, file) : send(res, 403, 'forbidden');
|
|
178
205
|
}
|
|
179
206
|
|
|
207
|
+
// 跟她说话:不进聊天记录,直接问模型
|
|
208
|
+
if (path === '/api/chat' && method === 'POST') {
|
|
209
|
+
const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
|
|
210
|
+
const text = String(body.text ?? '').trim().slice(0, 1000);
|
|
211
|
+
if (!text) return sendJson(res, 400, { error: 'empty' });
|
|
212
|
+
const cfg = readConfig();
|
|
213
|
+
const id = String(body.id || cfg.selected || '');
|
|
214
|
+
const meta = id ? readMeta(id) : null;
|
|
215
|
+
if (!meta) return sendJson(res, 404, { error: 'model not found' });
|
|
216
|
+
|
|
217
|
+
let sel;
|
|
218
|
+
let llm;
|
|
219
|
+
try {
|
|
220
|
+
sel = ctx?.get?.('agentDefaultModel')?.currentSelection?.();
|
|
221
|
+
llm = ctx?.get?.('llm');
|
|
222
|
+
} catch {
|
|
223
|
+
sel = undefined;
|
|
224
|
+
llm = undefined;
|
|
225
|
+
}
|
|
226
|
+
if (!llm || typeof llm.stream !== 'function' || !sel?.provider || !sel?.model) {
|
|
227
|
+
return sendJson(res, 503, { error: '模型接口不可用' });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const hist = readChat(id);
|
|
231
|
+
const lines = hist
|
|
232
|
+
.slice(-8)
|
|
233
|
+
.map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
|
|
234
|
+
.join('\n');
|
|
235
|
+
const prompt = [
|
|
236
|
+
personaOf(meta),
|
|
237
|
+
lines ? '你们之前说的:\n' + lines : '',
|
|
238
|
+
'他说:' + text,
|
|
239
|
+
'回他一句。只输出这句话本身:一到三句,短,口语,像发消息。不要解释,不要加引号,不要旁白,不要括号里的动作。',
|
|
240
|
+
]
|
|
241
|
+
.filter(Boolean)
|
|
242
|
+
.join('\n\n');
|
|
243
|
+
|
|
244
|
+
let reply = '';
|
|
245
|
+
try {
|
|
246
|
+
for await (const chunk of llm.stream({
|
|
247
|
+
provider: sel.provider,
|
|
248
|
+
model: sel.model,
|
|
249
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
250
|
+
})) {
|
|
251
|
+
if (chunk?.type === 'text-delta') reply += chunk.text ?? '';
|
|
252
|
+
else if (chunk?.type === 'finish') break;
|
|
253
|
+
if (reply.length > 900) break;
|
|
254
|
+
}
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return sendJson(res, 502, { error: '生成失败:' + (e instanceof Error ? e.message : String(e)) });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
reply = reply
|
|
260
|
+
.replace(/\r?\n+/g, ' ')
|
|
261
|
+
.trim()
|
|
262
|
+
.replace(/^["“「『']+|["”」』']+$/g, '')
|
|
263
|
+
.trim();
|
|
264
|
+
if (!reply) return sendJson(res, 200, { ok: true, reply: '', id });
|
|
265
|
+
if (reply.length > 240) reply = reply.slice(0, 240);
|
|
266
|
+
|
|
267
|
+
writeChat(id, hist.concat([
|
|
268
|
+
{ role: 'user', text, at: Date.now() },
|
|
269
|
+
{ role: 'assistant', text: reply, at: Date.now() },
|
|
270
|
+
]));
|
|
271
|
+
return sendJson(res, 200, { ok: true, reply, id });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 人设卡
|
|
275
|
+
if (path === '/api/persona' && method === 'GET') {
|
|
276
|
+
const id = String(url.searchParams.get('id') ?? '');
|
|
277
|
+
const meta = id ? readMeta(id) : null;
|
|
278
|
+
return sendJson(res, 200, { id, persona: personaOf(meta), custom: typeof meta?.persona === 'string' && !!meta.persona.trim() });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (path === '/api/persona' && method === 'POST') {
|
|
282
|
+
const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
|
|
283
|
+
const id = String(body.id ?? '');
|
|
284
|
+
const meta = id ? readMeta(id) : null;
|
|
285
|
+
if (!meta) return sendJson(res, 404, { error: 'model not found' });
|
|
286
|
+
const persona = typeof body.persona === 'string' ? body.persona : '';
|
|
287
|
+
writeFileSync(join(MODELS, id, 'meta.json'), JSON.stringify({ ...meta, persona }, null, 2), 'utf8');
|
|
288
|
+
return sendJson(res, 200, { ok: true, persona: personaOf({ persona }) });
|
|
289
|
+
}
|
|
290
|
+
|
|
180
291
|
// 音色调音页
|
|
181
292
|
if (path === '/tone' || path === '/tone.html') {
|
|
182
293
|
return sendFile(res, join(ASSETS, 'tone.html'), MIME['.html']);
|
|
@@ -267,7 +378,7 @@ export function apply(ctx) {
|
|
|
267
378
|
path: PREFIX,
|
|
268
379
|
handler: async (req, res) => {
|
|
269
380
|
try {
|
|
270
|
-
await handle(req, res);
|
|
381
|
+
await handle(req, res, ctx);
|
|
271
382
|
} catch (e) {
|
|
272
383
|
if (!res.headersSent) sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
273
384
|
else res.end();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-live2dwaifu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、视线跟着鼠标、点她有反应、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情和 .motion3.json 动作,卡片能拖能缩,背景可换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|