dsh-whale-live 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +85 -2
- package/client.js +555 -0
- package/cordis.patch.yml +23 -0
- package/icon.svg +7 -0
- package/index.js +81 -0
- package/lib/avatar.js +79 -0
- package/lib/bili.js +178 -0
- package/lib/http.js +271 -0
- package/lib/store.js +570 -0
- package/lib/tools.js +355 -0
- package/lib/voice.js +186 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +74 -4
- package/runtime/say.py +222 -0
- package/runtime/vts_reset.py +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lh1314-China
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-whale-live
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A **left-sidebar live console** for [DeepSeek Harness](https://github.com/deepseek-ai) (DSH): it turns a persona file pair into a readable "script", records every stream, reads Bilibili danmaku, speaks with edge-tts, and keeps a small inbox of memories for the model to hand-pick after the stream.
|
|
4
|
+
|
|
5
|
+
> **It is a local bridge, not a cloud service.** Persona files, logs and the memory inbox never leave your machine. The only network calls are the Bilibili room API (read-only), edge-tts (Microsoft), and — optionally — your local VTube Studio plugin API.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
| Piece | Behavior |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| Persona script | `意识.md` + `人类观察日记.md`; the panel and the model read them as the role to play |
|
|
12
|
+
| Stream log | Every session gets `场次/<id>/meta.json`, `直播.jsonl`, `会话.md` |
|
|
13
|
+
| Danmaku | Polls the Bilibili room API (read-only, no sending) |
|
|
14
|
+
| Speech | edge-tts synthesizes a line, Windows MCI plays it; optional VTube Studio mouth sync |
|
|
15
|
+
| Memory inbox | After a stream the model proposes a few candidates; nothing enters long-term memory automatically |
|
|
16
|
+
| Panel | "本场 / 意识 / 观察日记 / 入忆候选" tabs, say box, avatar switch |
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- **Windows** (audio playback uses MCI/`winmm`; `os: ["win32"]`).
|
|
21
|
+
- **DSH** `>=0.2.1-rc.2 <0.4.0` (tested on `0.2.2-rc.1`, desktop profile). The panel uses the sidebar/`main` slot API.
|
|
22
|
+
- **Python 3.10+** with [`edge-tts`](https://pypi.org/project/edge-tts/) for speech. Point `pythonPath` at that interpreter.
|
|
23
|
+
- **Bilibili cookies** (`sessdata`, `bili_jct`, `buvid3`, `dedeuserid`) in a local JSON file for `listen`.
|
|
24
|
+
- Optional: a VTube Studio mouth driver directory containing `vts_speak.py` / `vts_client.py`; without it speech still plays, just without lip-sync.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
1. Install this package into a DSH profile through the Plugin Manager (or `npm i dsh-whale-live` and link it).
|
|
29
|
+
2. Edit the `whale-live` row in the profile's `cordis.patch.yml`:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
- id: whale-live
|
|
33
|
+
name: dsh-whale-live
|
|
34
|
+
config:
|
|
35
|
+
roomId: 123456 # your Bilibili room id
|
|
36
|
+
roomName: 'My room'
|
|
37
|
+
dataDir: 'D:/WhaleLive' # empty = ~/.dsh/whale-live
|
|
38
|
+
credentialFile: 'D:/secret/bili.json'
|
|
39
|
+
pythonPath: 'C:/Python311/python.exe'
|
|
40
|
+
vtubeDir: '' # optional VTube Studio bridge
|
|
41
|
+
voice: zh-CN-XiaoyiNeural
|
|
42
|
+
rate: '+8%'
|
|
43
|
+
pitch: '+4Hz'
|
|
44
|
+
listenWaitSec: 45
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
3. Restart DSH (or let the Plugin Manager apply it), then open **鲸鱼直播台 / Whale live desk** in the left sidebar.
|
|
48
|
+
|
|
49
|
+
## Data layout
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
<dataDir>/
|
|
53
|
+
意识.md consciousness / role script
|
|
54
|
+
人类观察日记.md per-day, per-viewer diary
|
|
55
|
+
入忆候选.jsonl memory candidates (pending / committed / rejected)
|
|
56
|
+
状态.json current session pointer
|
|
57
|
+
场次/<id>/meta.json per-stream counters
|
|
58
|
+
场次/<id>/直播.jsonl per-stream events
|
|
59
|
+
场次/<id>/会话.md human-readable transcript
|
|
60
|
+
音频/ edge-tts mp3 cache
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Tools (model side)
|
|
64
|
+
|
|
65
|
+
- `whale_live` — `open`, `listen`, `note`, `close`, `read`, `status`, `propose` / `memory_list` / `commit` / `reject`, `avatar`.
|
|
66
|
+
- `whale_say` — say / stop / status (edge-tts).
|
|
67
|
+
|
|
68
|
+
The model acts **through** these tools; the plugin never writes to DSH long-term memory on its own.
|
|
69
|
+
|
|
70
|
+
## Compatibility notes
|
|
71
|
+
|
|
72
|
+
- **Windows only** in this release. The Host half itself is plain Node; only the speech helper (`runtime/say.py`) and `runtime/vts_reset.py` are Windows-specific.
|
|
73
|
+
- **DSH API**: depends on `sidebar.panellist`, `main`, `locale` and `webServer` services. A future DSH release outside the declared range may change them.
|
|
74
|
+
- **Bilibili**: uses the unofficial `ajax/msg` endpoint; it can change or rate-limit, and some rooms return empty. Read-only — the plugin never sends danmaku.
|
|
75
|
+
- **VTube Studio**: optional. Without a mouth driver directory the plugin degrades to plain audio playback.
|
|
76
|
+
|
|
77
|
+
## Privacy
|
|
78
|
+
|
|
79
|
+
- No telemetry; no accounts; nothing is uploaded.
|
|
80
|
+
- Bilibili cookies stay in the file you point `credentialFile` to.
|
|
81
|
+
- Persona documents and stream logs stay under `dataDir`.
|
|
82
|
+
- The panel's HTTP API is bound to the same loopback origin and rejects cross-site requests.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT.
|
package/client.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-whale-live',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
const { useCallback, useEffect, useMemo, useRef, useState } = React;
|
|
7
|
+
|
|
8
|
+
const API = '/api/whale-live';
|
|
9
|
+
const NS = 'dsh-whale-live';
|
|
10
|
+
|
|
11
|
+
const DICT = {
|
|
12
|
+
zh: {
|
|
13
|
+
navLabel: '鲸鱼直播台',
|
|
14
|
+
title: '鲸鱼直播台',
|
|
15
|
+
subtitle: '房间 {room} · 声音 {voice}',
|
|
16
|
+
open: '开播',
|
|
17
|
+
close: '下播',
|
|
18
|
+
stop: '闭嘴',
|
|
19
|
+
avatar: '皮套',
|
|
20
|
+
avatarOn: '皮套在',
|
|
21
|
+
refresh: '刷新',
|
|
22
|
+
tabLive: '本场',
|
|
23
|
+
tabConsciousness: '意识',
|
|
24
|
+
tabDiary: '观察日记',
|
|
25
|
+
tabCandidates: '入忆候选',
|
|
26
|
+
stateLive: '直播中',
|
|
27
|
+
stateIdle: '未开播',
|
|
28
|
+
stat: '说话 {says} · 弹幕 {danmaku} · 记 {notes}',
|
|
29
|
+
since: '{time} 开',
|
|
30
|
+
sayPlaceholder: '让她说一句……',
|
|
31
|
+
sayButton: '念',
|
|
32
|
+
sayHint: '念出来的话只记进本场,不会自己进记忆。',
|
|
33
|
+
said: '念完了。',
|
|
34
|
+
emptyLiveTitle: '还没开播',
|
|
35
|
+
emptyLiveBody: '开一场之后,本鲸会用工具去听弹幕、念话、记东西。你也可以直接跟本鲸说「开播」。',
|
|
36
|
+
emptyDoc: '(这份还空着)',
|
|
37
|
+
loading: '正在读……',
|
|
38
|
+
failed: '没读出来',
|
|
39
|
+
retry: '再试一次',
|
|
40
|
+
pendingChip: '待销 {n} 条',
|
|
41
|
+
candEmpty: '还没有入忆候选。下播后本鲸挑出来的事会先排在这里。',
|
|
42
|
+
candPending: '待定',
|
|
43
|
+
candCommitted: '已销账',
|
|
44
|
+
candRejected: '退回',
|
|
45
|
+
candCommit: '记下了',
|
|
46
|
+
candReject: '退回',
|
|
47
|
+
candNote: '这里只改候选状态;真正写进 cortex 的是本鲸自己。',
|
|
48
|
+
recentTitle: '本场流水',
|
|
49
|
+
noEvents: '这一场还没有动静。',
|
|
50
|
+
sayFailed: '没念出来',
|
|
51
|
+
actionFailed: '操作没成功',
|
|
52
|
+
hint: '直播时的一切只活在 whale-live 数据目录里:意识卡、观察日记、每场记录。cortex 只收本鲸亲手挑过的那几件。',
|
|
53
|
+
},
|
|
54
|
+
en: {
|
|
55
|
+
navLabel: 'Whale live desk',
|
|
56
|
+
title: 'Whale live desk',
|
|
57
|
+
subtitle: 'Room {room} · voice {voice}',
|
|
58
|
+
open: 'Go live',
|
|
59
|
+
close: 'End stream',
|
|
60
|
+
stop: 'Hush',
|
|
61
|
+
avatar: 'Avatar',
|
|
62
|
+
avatarOn: 'Avatar on',
|
|
63
|
+
refresh: 'Refresh',
|
|
64
|
+
tabLive: 'Stream',
|
|
65
|
+
tabConsciousness: 'Consciousness',
|
|
66
|
+
tabDiary: 'Field diary',
|
|
67
|
+
tabCandidates: 'Memory inbox',
|
|
68
|
+
stateLive: 'Live',
|
|
69
|
+
stateIdle: 'Off air',
|
|
70
|
+
stat: '{says} spoken · {danmaku} danmaku · {notes} notes',
|
|
71
|
+
since: 'since {time}',
|
|
72
|
+
sayPlaceholder: 'Make her say something…',
|
|
73
|
+
sayButton: 'Speak',
|
|
74
|
+
sayHint: 'Spoken lines only enter this stream log; they never enter memory by themselves.',
|
|
75
|
+
said: 'Said it.',
|
|
76
|
+
emptyLiveTitle: 'Not on air',
|
|
77
|
+
emptyLiveBody: 'Start a stream and the whale will listen, speak and take notes through the tools. You can also just tell me “go live”.',
|
|
78
|
+
emptyDoc: '(empty)',
|
|
79
|
+
loading: 'Reading…',
|
|
80
|
+
failed: 'Could not read',
|
|
81
|
+
retry: 'Try again',
|
|
82
|
+
pendingChip: '{n} pending',
|
|
83
|
+
candEmpty: 'No memory candidates yet. After a stream, the few things I picked land here first.',
|
|
84
|
+
candPending: 'Pending',
|
|
85
|
+
candCommitted: 'Filed',
|
|
86
|
+
candRejected: 'Dropped',
|
|
87
|
+
candCommit: 'Keep',
|
|
88
|
+
candReject: 'Drop',
|
|
89
|
+
candNote: 'This only changes the candidate’s state; writing to cortex is my own separate step.',
|
|
90
|
+
recentTitle: 'Stream log',
|
|
91
|
+
noEvents: 'Nothing has happened in this stream yet.',
|
|
92
|
+
sayFailed: 'Could not speak',
|
|
93
|
+
actionFailed: 'Action failed',
|
|
94
|
+
hint: 'Everything live stays inside the whale-live data folder. Cortex only receives the few things I hand-picked afterwards.',
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function fmtTime(iso) {
|
|
99
|
+
if (!iso) return '';
|
|
100
|
+
const date = new Date(iso);
|
|
101
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
102
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
103
|
+
return `${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function fmtDay(iso) {
|
|
107
|
+
if (!iso) return '';
|
|
108
|
+
const date = new Date(iso);
|
|
109
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
110
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
111
|
+
return `${p(date.getMonth() + 1)}-${p(date.getDate())} ${p(date.getHours())}:${p(date.getMinutes())}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function fill(template, values) {
|
|
115
|
+
return String(template ?? '').replace(/\{(\w+)\}/g, (_m, key) => (values[key] === undefined ? `{${key}}` : String(values[key])));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function getJson(path) {
|
|
119
|
+
const response = await fetch(`${API}${path}`, { cache: 'no-store', credentials: 'same-origin' });
|
|
120
|
+
const body = await response.json().catch(() => null);
|
|
121
|
+
if (!response.ok || !body?.ok) throw new Error(body?.error || `HTTP ${response.status}`);
|
|
122
|
+
return body.data;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function postAction(action, payload = {}) {
|
|
126
|
+
const response = await fetch(`${API}/action`, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
credentials: 'same-origin',
|
|
129
|
+
headers: { 'content-type': 'application/json', 'x-whale-live': '1' },
|
|
130
|
+
body: JSON.stringify({ action, ...payload }),
|
|
131
|
+
});
|
|
132
|
+
const body = await response.json().catch(() => null);
|
|
133
|
+
if (!response.ok || !body?.ok) throw new Error(body?.error || `HTTP ${response.status}`);
|
|
134
|
+
return body.data;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ── 图标:一头小鲸鱼 ───────────────────────────────── */
|
|
138
|
+
function Icon({ size = 18, active = false }) {
|
|
139
|
+
return h('svg', {
|
|
140
|
+
viewBox: '0 0 24 24',
|
|
141
|
+
width: size,
|
|
142
|
+
height: size,
|
|
143
|
+
fill: 'none',
|
|
144
|
+
'aria-hidden': true,
|
|
145
|
+
style: { display: 'block', color: active ? 'var(--dsw-alias-brand-primary, #2f6fed)' : 'currentColor' },
|
|
146
|
+
}, [
|
|
147
|
+
h('path', {
|
|
148
|
+
key: 'body',
|
|
149
|
+
d: 'M2.6 12.9c0-2.9 2.7-5 6.4-5 2.4 0 4.4.6 5.7 1.6.7.5 1.5.7 2.4.7h.4c1 0 1.8.7 1.8 1.7s-.8 1.7-1.8 1.7h-.6c-.5 2.3-3 3.9-6.4 3.9-3.9 0-7.9-1.3-7.9-4.6Z',
|
|
150
|
+
fill: 'currentColor',
|
|
151
|
+
}),
|
|
152
|
+
h('path', {
|
|
153
|
+
key: 'tail',
|
|
154
|
+
d: 'M16.2 10.6c1.1-.9 2.4-1.6 3.7-1.9-.1 1.2-.6 2.2-1.3 3 .7.8 1.2 1.8 1.3 3-1.3-.3-2.6-1-3.7-1.9.7-.7.9-1.6 0-2.2Z',
|
|
155
|
+
fill: 'currentColor',
|
|
156
|
+
}),
|
|
157
|
+
h('circle', { key: 'eye', cx: 5.6, cy: 11.6, r: 1.05, fill: 'var(--dsw-specific-sidebar-fill, var(--dsw-alias-bg-base, #fff))' }),
|
|
158
|
+
h('path', {
|
|
159
|
+
key: 'spout',
|
|
160
|
+
d: 'M7.4 6.5c.6-.7.9-1.5.9-2.4M10 6.9c1.2-.4 2-1.1 2.4-2.1',
|
|
161
|
+
stroke: 'currentColor',
|
|
162
|
+
strokeWidth: 1.2,
|
|
163
|
+
strokeLinecap: 'round',
|
|
164
|
+
}),
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const KIND_LABEL = {
|
|
169
|
+
danmaku: '弹幕',
|
|
170
|
+
gift: '礼物',
|
|
171
|
+
say: '大肥鱼',
|
|
172
|
+
note: '记一笔',
|
|
173
|
+
mood: '心情',
|
|
174
|
+
open: '开播',
|
|
175
|
+
close: '下播',
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
function EventRow({ row }) {
|
|
179
|
+
const kind = row.kind || 'note';
|
|
180
|
+
return h('div', { className: `dshwl-event dshwl-event-${kind}` }, [
|
|
181
|
+
h('span', { key: 't', className: 'dshwl-event-time' }, fmtTime(row.ts)),
|
|
182
|
+
h('span', { key: 'k', className: 'dshwl-event-kind' }, KIND_LABEL[kind] || kind),
|
|
183
|
+
h('span', { key: 'b', className: 'dshwl-event-body' }, kind === 'danmaku' || kind === 'gift'
|
|
184
|
+
? `${row.user || '观众'}:${row.text || ''}`
|
|
185
|
+
: row.text || ''),
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function Panel({ ctx }) {
|
|
190
|
+
const t = useMemo(() => ctx.locale.bind(NS), [ctx]);
|
|
191
|
+
const [state, setState] = useState(null);
|
|
192
|
+
const [error, setError] = useState('');
|
|
193
|
+
const [tab, setTab] = useState('live');
|
|
194
|
+
const [busy, setBusy] = useState('');
|
|
195
|
+
const [notice, setNotice] = useState('');
|
|
196
|
+
const [draft, setDraft] = useState('');
|
|
197
|
+
const [doc, setDoc] = useState({ kind: '', text: '', loading: false, error: '' });
|
|
198
|
+
const [docTick, setDocTick] = useState(0);
|
|
199
|
+
const alive = useRef(true);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
alive.current = true;
|
|
203
|
+
return () => { alive.current = false; };
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
206
|
+
const reload = useCallback(async () => {
|
|
207
|
+
try {
|
|
208
|
+
const data = await getJson('/state');
|
|
209
|
+
if (!alive.current) return;
|
|
210
|
+
setState(data);
|
|
211
|
+
setError('');
|
|
212
|
+
} catch (cause) {
|
|
213
|
+
if (!alive.current) return;
|
|
214
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
215
|
+
}
|
|
216
|
+
}, []);
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
reload();
|
|
220
|
+
const timer = setInterval(() => {
|
|
221
|
+
if (document.visibilityState === 'visible') reload();
|
|
222
|
+
}, 6000);
|
|
223
|
+
return () => clearInterval(timer);
|
|
224
|
+
}, [reload]);
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (tab !== 'consciousness' && tab !== 'diary') return;
|
|
228
|
+
let cancelled = false;
|
|
229
|
+
setDoc((prev) => ({ ...prev, kind: tab, loading: true, error: '' }));
|
|
230
|
+
getJson(`/doc?kind=${tab}&chars=60000`)
|
|
231
|
+
.then((data) => {
|
|
232
|
+
if (cancelled) return;
|
|
233
|
+
setDoc({ kind: tab, text: data.text || '', loading: false, error: '' });
|
|
234
|
+
})
|
|
235
|
+
.catch((cause) => {
|
|
236
|
+
if (cancelled) return;
|
|
237
|
+
setDoc({ kind: tab, text: '', loading: false, error: cause instanceof Error ? cause.message : String(cause) });
|
|
238
|
+
});
|
|
239
|
+
return () => { cancelled = true; };
|
|
240
|
+
}, [tab, docTick]);
|
|
241
|
+
|
|
242
|
+
const flash = useCallback((message) => {
|
|
243
|
+
setNotice(message);
|
|
244
|
+
setTimeout(() => setNotice(''), 3200);
|
|
245
|
+
}, []);
|
|
246
|
+
|
|
247
|
+
const run = useCallback(async (name, action, payload) => {
|
|
248
|
+
setBusy(name);
|
|
249
|
+
try {
|
|
250
|
+
const result = await postAction(action, payload);
|
|
251
|
+
if (result?.state) setState(result.state);
|
|
252
|
+
return result;
|
|
253
|
+
} catch (cause) {
|
|
254
|
+
flash(`${t('actionFailed')}:${cause instanceof Error ? cause.message : String(cause)}`);
|
|
255
|
+
return null;
|
|
256
|
+
} finally {
|
|
257
|
+
setBusy('');
|
|
258
|
+
}
|
|
259
|
+
}, [flash, t]);
|
|
260
|
+
|
|
261
|
+
const onSay = useCallback(async () => {
|
|
262
|
+
const text = draft.trim();
|
|
263
|
+
if (!text) return;
|
|
264
|
+
const result = await run('say', 'say', { text });
|
|
265
|
+
if (result?.voice?.ok) {
|
|
266
|
+
setDraft('');
|
|
267
|
+
flash(t('said'));
|
|
268
|
+
} else if (result?.voice) {
|
|
269
|
+
flash(`${t('sayFailed')}:${result.voice.error}`);
|
|
270
|
+
}
|
|
271
|
+
}, [draft, flash, run, t]);
|
|
272
|
+
|
|
273
|
+
const session = state?.session ?? null;
|
|
274
|
+
const config = state?.config ?? {};
|
|
275
|
+
const candidates = state?.candidates ?? [];
|
|
276
|
+
const pending = state?.pendingCount ?? 0;
|
|
277
|
+
const events = state?.recent ?? [];
|
|
278
|
+
|
|
279
|
+
const tabs = [
|
|
280
|
+
['live', t('tabLive')],
|
|
281
|
+
['consciousness', t('tabConsciousness')],
|
|
282
|
+
['diary', t('tabDiary')],
|
|
283
|
+
['candidates', t('tabCandidates')],
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
const head = h('div', { className: 'dshwl-head' }, [
|
|
287
|
+
h('div', { key: 'title' }, [
|
|
288
|
+
h('h1', { key: 'h', className: 'dshwl-title' }, t('title')),
|
|
289
|
+
h('p', { key: 's', className: 'dshwl-sub' }, fill(t('subtitle'), {
|
|
290
|
+
room: config.roomName || config.roomId || '—',
|
|
291
|
+
voice: config.voice || '—',
|
|
292
|
+
})),
|
|
293
|
+
]),
|
|
294
|
+
h('div', { key: 'actions', className: 'dshwl-actions' }, [
|
|
295
|
+
h('button', {
|
|
296
|
+
key: 'live',
|
|
297
|
+
type: 'button',
|
|
298
|
+
className: 'dshwl-btn primary',
|
|
299
|
+
disabled: busy !== '',
|
|
300
|
+
onClick: () => run('session', session ? 'close' : 'open'),
|
|
301
|
+
}, session ? t('close') : t('open')),
|
|
302
|
+
h('button', {
|
|
303
|
+
key: 'stop',
|
|
304
|
+
type: 'button',
|
|
305
|
+
className: 'dshwl-btn',
|
|
306
|
+
disabled: busy !== '' || !state?.voice?.speaking,
|
|
307
|
+
onClick: () => run('stop', 'stop'),
|
|
308
|
+
}, t('stop')),
|
|
309
|
+
h('button', {
|
|
310
|
+
key: 'avatar',
|
|
311
|
+
type: 'button',
|
|
312
|
+
className: 'dshwl-btn',
|
|
313
|
+
disabled: busy !== '' || !state?.avatar || state.avatar.up === true,
|
|
314
|
+
onClick: () => run('avatar', 'avatar', { step: 'open' }),
|
|
315
|
+
}, t('avatar')),
|
|
316
|
+
h('button', {
|
|
317
|
+
key: 'refresh',
|
|
318
|
+
type: 'button',
|
|
319
|
+
className: 'dshwl-btn',
|
|
320
|
+
disabled: busy !== '',
|
|
321
|
+
onClick: reload,
|
|
322
|
+
}, t('refresh')),
|
|
323
|
+
]),
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
const status = h('div', { className: 'dshwl-status' }, [
|
|
327
|
+
h('span', { key: 'dot', className: `dshwl-dot${session ? ' on' : ''}` }),
|
|
328
|
+
h('span', { key: 'text' }, session
|
|
329
|
+
? `${t('stateLive')} · ${fill(t('since'), { time: fmtTime(session.startedAt) })} · ${fill(t('stat'), {
|
|
330
|
+
says: session.says ?? 0,
|
|
331
|
+
danmaku: session.danmaku ?? 0,
|
|
332
|
+
notes: session.notes ?? 0,
|
|
333
|
+
})}`
|
|
334
|
+
: `${t('stateIdle')} · ${fill(t('stat'), { says: 0, danmaku: 0, notes: 0 })}`),
|
|
335
|
+
pending > 0
|
|
336
|
+
? h('span', { key: 'chip', className: 'dshwl-chip' }, fill(t('pendingChip'), { n: pending }))
|
|
337
|
+
: null,
|
|
338
|
+
state?.avatar?.up ? h('span', { key: 'skin', className: 'dshwl-chip on' }, t('avatarOn')) : null,
|
|
339
|
+
state?.voice?.speaking ? h('span', { key: 'voice', className: 'dshwl-chip on' }, '♪') : null,
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
const tabRow = h('div', { key: 'tabs', className: 'dshwl-tabs', role: 'tablist' }, tabs.map(([key, label]) => h('button', {
|
|
343
|
+
key,
|
|
344
|
+
type: 'button',
|
|
345
|
+
role: 'tab',
|
|
346
|
+
'aria-selected': tab === key,
|
|
347
|
+
className: `dshwl-tab${tab === key ? ' on' : ''}`,
|
|
348
|
+
onClick: () => setTab(key),
|
|
349
|
+
}, label)));
|
|
350
|
+
|
|
351
|
+
let body = null;
|
|
352
|
+
|
|
353
|
+
if (tab === 'live') {
|
|
354
|
+
if (!session) {
|
|
355
|
+
body = h('div', { className: 'dshwl-empty' }, [
|
|
356
|
+
h('div', { key: 't', className: 'dshwl-empty-title' }, t('emptyLiveTitle')),
|
|
357
|
+
h('p', { key: 'b', className: 'dshwl-empty-body' }, t('emptyLiveBody')),
|
|
358
|
+
h('button', {
|
|
359
|
+
key: 'go',
|
|
360
|
+
type: 'button',
|
|
361
|
+
className: 'dshwl-btn primary',
|
|
362
|
+
disabled: busy !== '',
|
|
363
|
+
onClick: () => run('session', 'open'),
|
|
364
|
+
}, t('open')),
|
|
365
|
+
]);
|
|
366
|
+
} else {
|
|
367
|
+
body = h('div', {}, [
|
|
368
|
+
h('div', { key: 'composer', className: 'dshwl-composer' }, [
|
|
369
|
+
h('input', {
|
|
370
|
+
key: 'input',
|
|
371
|
+
className: 'dshwl-input',
|
|
372
|
+
value: draft,
|
|
373
|
+
placeholder: t('sayPlaceholder'),
|
|
374
|
+
onChange: (event) => setDraft(event.target.value),
|
|
375
|
+
onKeyDown: (event) => {
|
|
376
|
+
if (event.key === 'Enter' && !event.nativeEvent?.isComposing) {
|
|
377
|
+
event.preventDefault();
|
|
378
|
+
onSay();
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
}),
|
|
382
|
+
h('button', {
|
|
383
|
+
key: 'send',
|
|
384
|
+
type: 'button',
|
|
385
|
+
className: 'dshwl-btn primary',
|
|
386
|
+
disabled: busy !== '' || !draft.trim(),
|
|
387
|
+
onClick: onSay,
|
|
388
|
+
}, busy === 'say' ? '…' : t('sayButton')),
|
|
389
|
+
]),
|
|
390
|
+
h('p', { key: 'hint', className: 'dshwl-hint' }, t('sayHint')),
|
|
391
|
+
h('h3', { key: 'rt', className: 'dshwl-section-title' }, t('recentTitle')),
|
|
392
|
+
events.length
|
|
393
|
+
? h('div', { key: 'list', className: 'dshwl-events' }, events.slice().reverse().map((row, index) => h(EventRow, {
|
|
394
|
+
key: `${row.ts}-${index}`,
|
|
395
|
+
row,
|
|
396
|
+
})))
|
|
397
|
+
: h('p', { key: 'none', className: 'dshwl-muted' }, t('noEvents')),
|
|
398
|
+
]);
|
|
399
|
+
}
|
|
400
|
+
} else if (tab === 'candidates') {
|
|
401
|
+
body = h('div', {}, [
|
|
402
|
+
h('p', { key: 'note', className: 'dshwl-hint' }, t('candNote')),
|
|
403
|
+
candidates.length
|
|
404
|
+
? h('div', { key: 'list', className: 'dshwl-cands' }, candidates.map((row) => h('div', {
|
|
405
|
+
key: row.id,
|
|
406
|
+
className: 'dshwl-cand',
|
|
407
|
+
}, [
|
|
408
|
+
h('div', { key: 'head', className: 'dshwl-cand-head' }, [
|
|
409
|
+
h('span', { key: 'k', className: `dshwl-badge ${row.status}` }, row.status === 'pending'
|
|
410
|
+
? t('candPending')
|
|
411
|
+
: row.status === 'committed' ? t('candCommitted') : t('candRejected')),
|
|
412
|
+
h('span', { key: 'meta', className: 'dshwl-cand-meta' }, `${row.kind || 'observation'} · ${fmtDay(row.createdAt)} · ${Math.round((row.importance ?? 0.6) * 100)}%`),
|
|
413
|
+
]),
|
|
414
|
+
h('p', { key: 'text', className: 'dshwl-cand-text' }, row.text),
|
|
415
|
+
row.reason ? h('p', { key: 'reason', className: 'dshwl-muted' }, `↳ ${row.reason}`) : null,
|
|
416
|
+
row.status === 'pending' ? h('div', { key: 'actions', className: 'dshwl-actions' }, [
|
|
417
|
+
h('button', {
|
|
418
|
+
key: 'keep',
|
|
419
|
+
type: 'button',
|
|
420
|
+
className: 'dshwl-btn',
|
|
421
|
+
disabled: busy !== '',
|
|
422
|
+
onClick: () => run(`cand-${row.id}`, 'candidate', { id: row.id, decision: 'commit' }),
|
|
423
|
+
}, t('candCommit')),
|
|
424
|
+
h('button', {
|
|
425
|
+
key: 'drop',
|
|
426
|
+
type: 'button',
|
|
427
|
+
className: 'dshwl-btn',
|
|
428
|
+
disabled: busy !== '',
|
|
429
|
+
onClick: () => run(`cand-${row.id}`, 'candidate', { id: row.id, decision: 'reject' }),
|
|
430
|
+
}, t('candReject')),
|
|
431
|
+
]) : null,
|
|
432
|
+
])))
|
|
433
|
+
: h('div', { key: 'empty', className: 'dshwl-empty' }, t('candEmpty')),
|
|
434
|
+
]);
|
|
435
|
+
} else {
|
|
436
|
+
const kind = tab;
|
|
437
|
+
const current = doc.kind === kind ? doc : null;
|
|
438
|
+
body = h('div', {}, [
|
|
439
|
+
h('div', { key: 'bar', className: 'dshwl-toolbar' }, [
|
|
440
|
+
h('span', { key: 'meta', className: 'dshwl-muted' }, state?.docs?.[kind]
|
|
441
|
+
? `${state.docs[kind].chars} 字 · ${fmtDay(state.docs[kind].updatedAt)}`
|
|
442
|
+
: ''),
|
|
443
|
+
h('button', {
|
|
444
|
+
key: 'reload',
|
|
445
|
+
type: 'button',
|
|
446
|
+
className: 'dshwl-btn',
|
|
447
|
+
onClick: () => setDocTick((value) => value + 1),
|
|
448
|
+
}, t('refresh')),
|
|
449
|
+
]),
|
|
450
|
+
!current || current.loading
|
|
451
|
+
? h('p', { key: 'loading', className: 'dshwl-muted' }, t('loading'))
|
|
452
|
+
: current.error
|
|
453
|
+
? h('p', { key: 'err', className: 'dshwl-error' }, `${t('failed')}:${current.error}`)
|
|
454
|
+
: h('pre', { key: 'doc', className: 'dshwl-doc' }, current.text || t('emptyDoc')),
|
|
455
|
+
]);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return h('div', { className: 'dshwl-root' }, [
|
|
459
|
+
head,
|
|
460
|
+
status,
|
|
461
|
+
error ? h('div', { key: 'err', className: 'dshwl-error' }, `${t('failed')}:${error}`) : null,
|
|
462
|
+
tabRow,
|
|
463
|
+
h('div', { key: 'body', className: 'dshwl-body', role: 'tabpanel' }, body),
|
|
464
|
+
h('p', { key: 'foot', className: 'dshwl-foot' }, t('hint')),
|
|
465
|
+
notice ? h('div', { key: 'notice', className: 'dshwl-notice' }, notice) : null,
|
|
466
|
+
]);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const CSS = `
|
|
470
|
+
.dshwl-root{height:100%;overflow:auto;box-sizing:border-box;padding:20px 24px 40px;color:var(--dsw-alias-label-primary,#1f2329);background:var(--dsw-alias-bg-base,#fff);font:13px/1.55 "Segoe UI","Microsoft YaHei",system-ui,sans-serif}
|
|
471
|
+
.dshwl-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
472
|
+
.dshwl-title{margin:0;font-size:18px;font-weight:650}
|
|
473
|
+
.dshwl-sub{margin:2px 0 0;color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px}
|
|
474
|
+
.dshwl-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
475
|
+
.dshwl-btn{appearance:none;border:1px solid var(--dsw-alias-border-l1,#e5e7eb);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#1f2329);border-radius:8px;padding:5px 12px;font:inherit;font-size:12px;cursor:pointer;line-height:1.6}
|
|
476
|
+
.dshwl-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#2f6fed);color:var(--dsw-alias-brand-primary,#2f6fed)}
|
|
477
|
+
.dshwl-btn.primary{background:var(--dsw-alias-brand-primary,#2f6fed);border-color:var(--dsw-alias-brand-primary,#2f6fed);color:#fff}
|
|
478
|
+
.dshwl-btn.primary:hover:not(:disabled){filter:brightness(1.06);color:#fff}
|
|
479
|
+
.dshwl-btn:disabled{opacity:.5;cursor:not-allowed}
|
|
480
|
+
.dshwl-btn:focus-visible,.dshwl-tab:focus-visible,.dshwl-input:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#2f6fed);outline-offset:2px}
|
|
481
|
+
.dshwl-status{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:14px 0 10px;padding:8px 12px;border:1px solid var(--dsw-alias-border-l1,#e5e7eb);background:var(--dsw-alias-bg-layer-1,#fff);border-radius:10px;font-size:12px;color:var(--dsw-alias-label-secondary,#6b7280)}
|
|
482
|
+
.dshwl-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary,#9ca3af);flex:none}
|
|
483
|
+
.dshwl-dot.on{background:var(--dsw-alias-state-success-primary,#15803d);box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary,#15803d) 22%, transparent)}
|
|
484
|
+
.dshwl-chip{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l1,#e5e7eb);background:var(--dsw-alias-bg-layer-2,#f7f8fa);border-radius:999px;padding:1px 8px;font-size:11px}
|
|
485
|
+
.dshwl-chip.on{color:var(--dsw-alias-brand-primary,#2f6fed);border-color:var(--dsw-alias-brand-primary,#2f6fed)}
|
|
486
|
+
.dshwl-tabs{display:flex;gap:2px;border-bottom:1px solid var(--dsw-alias-border-l1,#e5e7eb);margin-bottom:16px;overflow-x:auto}
|
|
487
|
+
.dshwl-tab{appearance:none;border:0;background:transparent;color:var(--dsw-alias-label-secondary,#6b7280);padding:7px 10px;border-bottom:2px solid transparent;font:inherit;cursor:pointer;white-space:nowrap}
|
|
488
|
+
.dshwl-tab.on{color:var(--dsw-alias-brand-primary,#2f6fed);border-bottom-color:var(--dsw-alias-brand-primary,#2f6fed);font-weight:600}
|
|
489
|
+
.dshwl-body{min-height:160px}
|
|
490
|
+
.dshwl-composer{display:flex;gap:8px;align-items:center}
|
|
491
|
+
.dshwl-input{flex:1;min-width:0;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1,#e5e7eb);background:var(--dsw-alias-bg-layer-1,#fff);color:inherit;border-radius:9px;padding:8px 10px;font:inherit;font-size:13px}
|
|
492
|
+
.dshwl-hint{margin:8px 0 14px;color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px;line-height:1.5}
|
|
493
|
+
.dshwl-section-title{margin:10px 0 8px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#6b7280)}
|
|
494
|
+
.dshwl-events{display:flex;flex-direction:column;gap:2px;border:1px solid var(--dsw-alias-border-l1,#e5e7eb);border-radius:10px;padding:6px 4px;background:var(--dsw-alias-bg-layer-1,#fff);max-height:46vh;overflow:auto}
|
|
495
|
+
.dshwl-event{display:grid;grid-template-columns:58px 56px 1fr;gap:8px;align-items:baseline;padding:4px 8px;border-radius:7px;font-size:12px;line-height:1.5}
|
|
496
|
+
.dshwl-event:hover{background:var(--dsw-alias-bg-layer-2,#f7f8fa)}
|
|
497
|
+
.dshwl-event-time{color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px;font-variant-numeric:tabular-nums}
|
|
498
|
+
.dshwl-event-kind{font-size:10px;color:var(--dsw-alias-label-secondary,#6b7280);border:1px solid var(--dsw-alias-border-l1,#e5e7eb);border-radius:999px;padding:0 6px;justify-self:start}
|
|
499
|
+
.dshwl-event-body{min-width:0;word-break:break-word}
|
|
500
|
+
.dshwl-event-say .dshwl-event-kind{color:var(--dsw-alias-brand-primary,#2f6fed);border-color:var(--dsw-alias-brand-primary,#2f6fed)}
|
|
501
|
+
.dshwl-event-say .dshwl-event-body{color:var(--dsw-alias-brand-primary,#2f6fed)}
|
|
502
|
+
.dshwl-event-danmaku .dshwl-event-body{color:var(--dsw-alias-label-primary,#1f2329)}
|
|
503
|
+
.dshwl-doc{margin:0;white-space:pre-wrap;word-break:break-word;background:var(--dsw-alias-bg-layer-2,#f7f8fa);border:1px solid var(--dsw-alias-border-l1,#e5e7eb);border-radius:10px;padding:14px 16px;font:12px/1.7 "Cascadia Code","Consolas","Microsoft YaHei",monospace;max-height:58vh;overflow:auto}
|
|
504
|
+
.dshwl-cands{display:flex;flex-direction:column;gap:8px}
|
|
505
|
+
.dshwl-cand{border:1px solid var(--dsw-alias-border-l1,#e5e7eb);background:var(--dsw-alias-bg-layer-1,#fff);border-radius:10px;padding:10px 12px}
|
|
506
|
+
.dshwl-cand-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
507
|
+
.dshwl-badge{font-size:10px;border-radius:999px;padding:1px 8px;border:1px solid var(--dsw-alias-border-l1,#e5e7eb);color:var(--dsw-alias-label-secondary,#6b7280)}
|
|
508
|
+
.dshwl-badge.pending{color:var(--dsw-alias-state-warn-primary,#b45309);border-color:var(--dsw-alias-state-warn-primary,#b45309)}
|
|
509
|
+
.dshwl-badge.committed{color:var(--dsw-alias-state-success-primary,#15803d);border-color:var(--dsw-alias-state-success-primary,#15803d)}
|
|
510
|
+
.dshwl-badge.rejected{color:var(--dsw-alias-state-error-primary,#d33);border-color:var(--dsw-alias-state-error-primary,#d33)}
|
|
511
|
+
.dshwl-cand-meta{color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px}
|
|
512
|
+
.dshwl-cand-text{margin:6px 0 4px;font-size:13px;line-height:1.55;word-break:break-word}
|
|
513
|
+
.dshwl-cand .dshwl-actions{margin-top:8px}
|
|
514
|
+
.dshwl-empty{border:1px dashed var(--dsw-alias-border-l2,#d1d5db);border-radius:12px;padding:26px 20px;text-align:center;color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;line-height:1.6}
|
|
515
|
+
.dshwl-empty-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary,#1f2329);margin-bottom:6px}
|
|
516
|
+
.dshwl-empty-body{margin:0 auto 14px;max-width:460px}
|
|
517
|
+
.dshwl-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}
|
|
518
|
+
.dshwl-muted{color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px;margin:4px 0}
|
|
519
|
+
.dshwl-error{border:1px solid var(--dsw-alias-state-error-primary,#d33);color:var(--dsw-alias-state-error-primary,#d33);border-radius:8px;padding:7px 10px;margin:10px 0;font-size:12px;word-break:break-word}
|
|
520
|
+
.dshwl-foot{margin:22px 0 0;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1,#e5e7eb);color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px;line-height:1.6}
|
|
521
|
+
.dshwl-notice{position:sticky;bottom:0;margin-top:12px;border:1px solid var(--dsw-alias-border-l2,#d1d5db);background:var(--dsw-alias-bg-overlay,#fff);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--dsw-alias-label-primary,#1f2329);box-shadow:0 2px 10px rgba(0,0,0,.06);word-break:break-word}
|
|
522
|
+
`;
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
inject: ['slots', 'locale'],
|
|
526
|
+
apply(ctx) {
|
|
527
|
+
ctx.effect(() => {
|
|
528
|
+
const zh = ctx.locale.register(NS, 'zh', DICT.zh);
|
|
529
|
+
const en = ctx.locale.register(NS, 'en', DICT.en);
|
|
530
|
+
return () => { en(); zh(); };
|
|
531
|
+
}, 'whale-live: locale');
|
|
532
|
+
|
|
533
|
+
ctx.effect(() => {
|
|
534
|
+
const style = document.createElement('style');
|
|
535
|
+
style.id = 'dsh-whale-live-style';
|
|
536
|
+
style.textContent = CSS;
|
|
537
|
+
document.head.appendChild(style);
|
|
538
|
+
return () => style.remove();
|
|
539
|
+
}, 'whale-live: style');
|
|
540
|
+
|
|
541
|
+
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
542
|
+
name: 'sidebar.panellist',
|
|
543
|
+
id: 'whale-live',
|
|
544
|
+
order: 7,
|
|
545
|
+
label: () => ctx.locale.bind(NS)('navLabel'),
|
|
546
|
+
}, (owner) => h(Icon, { size: owner?.size ?? 18, active: owner?.active === true })));
|
|
547
|
+
|
|
548
|
+
ctx.slots.inject('main', () => ctx.slots.register({
|
|
549
|
+
name: 'main',
|
|
550
|
+
key: 'whale-live',
|
|
551
|
+
}, () => h(Panel, { ctx })));
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
},
|
|
555
|
+
});
|