@pointylab/dsh 0.0.0-stage → 0.4.3
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/ASSETS.md +20 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/cordis.patch.yml +13 -0
- package/lib/client.js +33 -0
- package/package.json +38 -4
- package/pet/app/card/card.css +365 -0
- package/pet/app/card/card.html +68 -0
- package/pet/app/card/card.js +896 -0
- package/pet/app/card/markdown.js +64 -0
- package/pet/app/card/mascot/blink.png +0 -0
- package/pet/app/card/mascot/done.png +0 -0
- package/pet/app/card/mascot/error.png +0 -0
- package/pet/app/card/mascot/idle.png +0 -0
- package/pet/app/card/mascot/peek.png +0 -0
- package/pet/app/card/mascot/think.png +0 -0
- package/pet/app/card/preload.js +21 -0
- package/pet/app/fullscreen.js +131 -0
- package/pet/app/main.js +1252 -0
- package/pet/app/native/uia-selection.ps1 +158 -0
- package/pet/app/notice/notice.html +37 -0
- package/pet/app/notice/notice.js +31 -0
- package/pet/app/notice/preload.js +7 -0
- package/pet/app/overlay/overlay.html +30 -0
- package/pet/app/overlay/overlay.js +42 -0
- package/pet/app/overlay/preload.js +7 -0
- package/pet/app/pill/pill.css +73 -0
- package/pet/app/pill/pill.html +11 -0
- package/pet/app/pill/pill.js +35 -0
- package/pet/app/pill/preload.js +8 -0
- package/pet/app/preload.js +16 -0
- package/pet/app/renderer/index.html +24 -0
- package/pet/app/renderer/lines.js +25 -0
- package/pet/app/renderer/pet.js +292 -0
- package/pet/app/renderer/renderer2d.js +71 -0
- package/pet/app/renderer/style.css +157 -0
- package/pet/app/selection.js +246 -0
- package/pet/app/shake.js +76 -0
- package/pet/app/uipick.js +314 -0
- package/pet/assets/cursor/cursor-32.cur +0 -0
- package/pet/assets/cursor/cursor-32.png +0 -0
- package/pet/assets/cursor/cursor-48.cur +0 -0
- package/pet/assets/cursor/cursor-48.png +0 -0
- package/pet/assets/cursor/cursor-64.cur +0 -0
- package/pet/assets/cursor/cursor-64.png +0 -0
- package/pet/assets/cursor/cursor-clean.png +0 -0
- package/pet/assets/cursor/make_cursor.py +85 -0
- package/pet/assets/cursor/source.png +0 -0
- package/pet/assets/pointy.ico +0 -0
- package/pet/assets/tray.png +0 -0
- package/pet/assets/tray@2x.png +0 -0
- package/pet/boot.js +6 -0
- package/pet/extension/background.js +252 -0
- package/pet/extension/cursor-32.png +0 -0
- package/pet/extension/cursor-48.png +0 -0
- package/pet/extension/cursor-64.png +0 -0
- package/pet/extension/icon.png +0 -0
- package/pet/extension/manifest.json +12 -0
- package/pet/extension/pick.js +391 -0
- package/pet/extension/popup.html +29 -0
- package/pet/extension/popup.js +30 -0
- package/pet/package.json +11 -0
- package/pet/vendor/koffi/LICENSE.txt +22 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.exp +0 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.lib +0 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.node +0 -0
- package/pet/vendor/koffi/index.js +634 -0
- package/pet/vendor/koffi/indirect.js +533 -0
- package/pet/vendor/koffi/package.json +35 -0
- package/pet/vendor/ws/LICENSE +20 -0
- package/pet/vendor/ws/index.js +22 -0
- package/pet/vendor/ws/lib/buffer-util.js +142 -0
- package/pet/vendor/ws/lib/constants.js +19 -0
- package/pet/vendor/ws/lib/event-target.js +292 -0
- package/pet/vendor/ws/lib/extension.js +203 -0
- package/pet/vendor/ws/lib/limiter.js +55 -0
- package/pet/vendor/ws/lib/permessage-deflate.js +530 -0
- package/pet/vendor/ws/lib/receiver.js +743 -0
- package/pet/vendor/ws/lib/sender.js +610 -0
- package/pet/vendor/ws/lib/stream.js +161 -0
- package/pet/vendor/ws/lib/subprotocol.js +62 -0
- package/pet/vendor/ws/lib/validation.js +152 -0
- package/pet/vendor/ws/lib/websocket-server.js +562 -0
- package/pet/vendor/ws/lib/websocket.js +1426 -0
- package/pet/vendor/ws/package.json +74 -0
- package/pet/vendor/ws/wrapper.mjs +21 -0
- package/src/node.js +732 -0
- package/src/runtime.js +178 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { Sprite2DRenderer } from './renderer2d.js'
|
|
2
|
+
import { pickLine } from './lines.js'
|
|
3
|
+
|
|
4
|
+
const $ = (id) => document.getElementById(id)
|
|
5
|
+
const characterEl = $('character')
|
|
6
|
+
const bubble = $('bubble')
|
|
7
|
+
const bubbleText = $('bubble-text')
|
|
8
|
+
const bubbleDetail = $('bubble-detail')
|
|
9
|
+
const bubbleActions = $('bubble-actions')
|
|
10
|
+
const badge = $('badge')
|
|
11
|
+
|
|
12
|
+
const renderer = new Sprite2DRenderer(characterEl)
|
|
13
|
+
|
|
14
|
+
const HOLD_MS = { done: 6000, error: 10000 }
|
|
15
|
+
const SLEEP_AFTER_MS = 10 * 60 * 1000
|
|
16
|
+
const LINE_GAP_MS = 20000
|
|
17
|
+
const LINE_TIMEOUT_MS = 10000
|
|
18
|
+
const BUBBLE_MS = 6000
|
|
19
|
+
const SCALE_STEP = 0.05
|
|
20
|
+
const SCREEN_EDGE = 8
|
|
21
|
+
// 这些状态一进入就说一句;工具类状态在同一轮里只在类别变化时说
|
|
22
|
+
const SPEAK_ON = new Set(['done', 'error', 'waiting', 'running', 'editing', 'reading', 'web'])
|
|
23
|
+
const ACTIVE = new Set(['thinking', 'running', 'editing', 'reading', 'web'])
|
|
24
|
+
|
|
25
|
+
let connected = false
|
|
26
|
+
let suppressed = false // 全屏 / 勿扰:窗口已藏起来,不说话也不请求台词
|
|
27
|
+
let state = 'idle'
|
|
28
|
+
let holdUntil = 0
|
|
29
|
+
let holdTimer
|
|
30
|
+
let sleepTimer
|
|
31
|
+
let lastLineAt = 0
|
|
32
|
+
let lastSpokenState
|
|
33
|
+
let bubbleTimer
|
|
34
|
+
let errorDetail = ''
|
|
35
|
+
const approvals = [] // 等待中的批准,队首显示在气泡里
|
|
36
|
+
let reqSeq = 0
|
|
37
|
+
const lineWaiters = new Map()
|
|
38
|
+
|
|
39
|
+
function setState(next, { hold = 0, force = false } = {}) {
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
if (!force && now < holdUntil && !(next === 'waiting' || next === 'error' || ACTIVE.has(next))) {
|
|
42
|
+
// done/error 的表情至少留一会儿,期间来的 idle 等到时候再切
|
|
43
|
+
clearTimeout(holdTimer)
|
|
44
|
+
holdTimer = setTimeout(() => setState(next, { force: true }), holdUntil - now)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
clearTimeout(holdTimer)
|
|
48
|
+
holdUntil = hold ? now + hold : 0
|
|
49
|
+
const changed = next !== state
|
|
50
|
+
state = next
|
|
51
|
+
if (approvals.length === 0) renderer.setState(next)
|
|
52
|
+
if (changed) window.pet.rendered(next)
|
|
53
|
+
clearTimeout(sleepTimer)
|
|
54
|
+
if (next === 'idle') sleepTimer = setTimeout(() => setState('sleep'), SLEEP_AFTER_MS)
|
|
55
|
+
return changed
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function showBubble(text, { kind = '', detail = '', sticky = false } = {}) {
|
|
59
|
+
clearTimeout(bubbleTimer)
|
|
60
|
+
bubble.hidden = false
|
|
61
|
+
bubble.className = kind
|
|
62
|
+
bubbleText.textContent = text
|
|
63
|
+
bubbleDetail.hidden = true
|
|
64
|
+
bubbleDetail.textContent = detail
|
|
65
|
+
bubbleActions.hidden = kind !== 'approval'
|
|
66
|
+
if (kind === 'error' && detail) bubble.classList.add('clickable')
|
|
67
|
+
if (kind === 'approval' && detail) bubbleDetail.hidden = false
|
|
68
|
+
if (!sticky) bubbleTimer = setTimeout(hideBubble, kind === 'error' ? BUBBLE_MS * 2 : BUBBLE_MS)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hideBubble() {
|
|
72
|
+
if (approvals.length) return renderApproval()
|
|
73
|
+
bubble.hidden = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 先问插件要一句 DSH 模型生成的台词,超时或失败就用内置的
|
|
77
|
+
async function say(kind, detail, { urgent = false } = {}) {
|
|
78
|
+
const now = Date.now()
|
|
79
|
+
if (suppressed) return
|
|
80
|
+
if (!urgent && now - lastLineAt < LINE_GAP_MS) return
|
|
81
|
+
lastLineAt = now
|
|
82
|
+
let text
|
|
83
|
+
if (connected) {
|
|
84
|
+
const reqId = ++reqSeq
|
|
85
|
+
text = await new Promise((resolve) => {
|
|
86
|
+
lineWaiters.set(reqId, resolve)
|
|
87
|
+
window.pet.send({ type: 'line-request', reqId, state: kind, detail })
|
|
88
|
+
setTimeout(() => { lineWaiters.delete(reqId); resolve(undefined) }, LINE_TIMEOUT_MS)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
if (approvals.length) return
|
|
92
|
+
text ||= pickLine(connected ? kind : 'offline')
|
|
93
|
+
if (kind === 'error') showBubble(`${text}(点我看原因)`, { kind: 'error', detail: errorDetail })
|
|
94
|
+
else showBubble(text)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderApproval() {
|
|
98
|
+
const a = approvals[0]
|
|
99
|
+
if (!a) return
|
|
100
|
+
renderer.setState('waiting')
|
|
101
|
+
const where = a.title ? `「${a.title}」` : '会话'
|
|
102
|
+
showBubble(`${where}要执行 ${a.toolName}:${a.reason || '需要你批准'}`, { kind: 'approval', detail: a.detail, sticky: true })
|
|
103
|
+
badge.hidden = approvals.length < 2
|
|
104
|
+
badge.textContent = String(approvals.length)
|
|
105
|
+
window.pet.approvals(approvals.length)
|
|
106
|
+
window.pet.rendered('approval')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function closeApproval(id) {
|
|
110
|
+
const i = approvals.findIndex(a => a.id === id)
|
|
111
|
+
if (i < 0) return
|
|
112
|
+
approvals.splice(i, 1)
|
|
113
|
+
window.pet.approvals(approvals.length)
|
|
114
|
+
badge.hidden = approvals.length < 2
|
|
115
|
+
if (approvals.length) return renderApproval()
|
|
116
|
+
window.pet.front(false)
|
|
117
|
+
bubble.hidden = true
|
|
118
|
+
renderer.setState(state)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function answer(decision) {
|
|
122
|
+
const a = approvals[0]
|
|
123
|
+
if (!a) return
|
|
124
|
+
window.pet.send({ type: 'approve', id: a.id, decision })
|
|
125
|
+
closeApproval(a.id)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
window.pet.onDsh((msg) => {
|
|
129
|
+
switch (msg.type) {
|
|
130
|
+
case 'link':
|
|
131
|
+
connected = msg.connected
|
|
132
|
+
renderer.setOffline(!connected)
|
|
133
|
+
if (!connected) {
|
|
134
|
+
approvals.length = 0
|
|
135
|
+
window.pet.approvals(0)
|
|
136
|
+
window.pet.front(false)
|
|
137
|
+
setState('idle', { force: true })
|
|
138
|
+
say('offline', '', { urgent: true })
|
|
139
|
+
} else {
|
|
140
|
+
showBubble('连上 DSH 了,开工吧~')
|
|
141
|
+
}
|
|
142
|
+
break
|
|
143
|
+
case 'state': {
|
|
144
|
+
const s = msg.state
|
|
145
|
+
if (s === 'error') errorDetail = msg.detail || ''
|
|
146
|
+
const changed = setState(s, { hold: HOLD_MS[s] ?? 0 })
|
|
147
|
+
if (s === 'thinking') lastSpokenState = undefined
|
|
148
|
+
if (changed && SPEAK_ON.has(s) && s !== lastSpokenState && s !== 'waiting') {
|
|
149
|
+
lastSpokenState = s
|
|
150
|
+
if (s === 'error') renderer.alarm()
|
|
151
|
+
say(s, msg.tool ? `${msg.tool} ${msg.detail ?? ''}` : '', { urgent: s === 'error' || s === 'done' })
|
|
152
|
+
}
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
case 'notice':
|
|
156
|
+
showBubble(msg.text, { kind: msg.kind ?? '' })
|
|
157
|
+
break
|
|
158
|
+
case 'kept':
|
|
159
|
+
// 没有批准在等时,角标用来显示后台任务:✓ 做完待看,… 还在做
|
|
160
|
+
if (approvals.length < 2) {
|
|
161
|
+
badge.hidden = msg.running + msg.finished === 0
|
|
162
|
+
badge.textContent = msg.finished ? `✓${msg.finished}` : `…${msg.running}`
|
|
163
|
+
}
|
|
164
|
+
break
|
|
165
|
+
case 'suppressed':
|
|
166
|
+
suppressed = msg.on
|
|
167
|
+
break
|
|
168
|
+
case 'status':
|
|
169
|
+
if (!msg.running && ACTIVE.has(state)) setState('idle')
|
|
170
|
+
break
|
|
171
|
+
case 'approval':
|
|
172
|
+
approvals.push(msg)
|
|
173
|
+
window.pet.front(true)
|
|
174
|
+
renderer.poke()
|
|
175
|
+
renderApproval()
|
|
176
|
+
break
|
|
177
|
+
case 'approval-closed':
|
|
178
|
+
closeApproval(msg.id)
|
|
179
|
+
break
|
|
180
|
+
case 'line': {
|
|
181
|
+
const resolve = lineWaiters.get(msg.reqId)
|
|
182
|
+
lineWaiters.delete(msg.reqId)
|
|
183
|
+
resolve?.(msg.text)
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
case 'files-result':
|
|
187
|
+
showBubble(msg.ok ? `收到 ${msg.count} 个文件,交给 DSH 了` : msg.error, { kind: msg.ok ? '' : 'error' })
|
|
188
|
+
break
|
|
189
|
+
case 'layout':
|
|
190
|
+
document.documentElement.style.setProperty('--scale', String(msg.scale))
|
|
191
|
+
break
|
|
192
|
+
case 'server-error':
|
|
193
|
+
showBubble(`端口被占用了:${msg.error}`, { kind: 'error', sticky: true })
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
$('btn-allow').addEventListener('click', () => answer('allow'))
|
|
199
|
+
$('btn-reject').addEventListener('click', () => answer('reject'))
|
|
200
|
+
|
|
201
|
+
bubble.addEventListener('click', (e) => {
|
|
202
|
+
if (e.target.closest('button')) return
|
|
203
|
+
if (bubble.classList.contains('error') && bubbleDetail.textContent) {
|
|
204
|
+
clearTimeout(bubbleTimer)
|
|
205
|
+
bubbleDetail.hidden = !bubbleDetail.hidden
|
|
206
|
+
if (bubbleDetail.hidden) bubbleTimer = setTimeout(hideBubble, BUBBLE_MS)
|
|
207
|
+
} else if (!approvals.length) {
|
|
208
|
+
hideBubble()
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// 拖动窗口;没怎么动就算戳了一下
|
|
213
|
+
let drag
|
|
214
|
+
characterEl.addEventListener('pointerdown', (e) => {
|
|
215
|
+
if (e.button !== 0) return
|
|
216
|
+
characterEl.setPointerCapture(e.pointerId)
|
|
217
|
+
drag = { x: e.screenX, y: e.screenY, moved: 0, at: Date.now() }
|
|
218
|
+
window.pet.dragging(true)
|
|
219
|
+
})
|
|
220
|
+
characterEl.addEventListener('lostpointercapture', () => window.pet.dragging(false))
|
|
221
|
+
characterEl.addEventListener('pointermove', (e) => {
|
|
222
|
+
if (!drag) return
|
|
223
|
+
const dx = e.screenX - drag.x
|
|
224
|
+
const dy = e.screenY - drag.y
|
|
225
|
+
if (!dx && !dy) return
|
|
226
|
+
drag.moved += Math.abs(dx) + Math.abs(dy)
|
|
227
|
+
drag.x = e.screenX
|
|
228
|
+
drag.y = e.screenY
|
|
229
|
+
window.pet.moveBy(dx, dy)
|
|
230
|
+
})
|
|
231
|
+
characterEl.addEventListener('pointerup', () => {
|
|
232
|
+
if (drag && drag.moved < 5 && Date.now() - drag.at < 500) {
|
|
233
|
+
window.pet.clicked()
|
|
234
|
+
renderer.poke()
|
|
235
|
+
if (state === 'sleep') setState('idle', { force: true })
|
|
236
|
+
say('poke', state, { urgent: true })
|
|
237
|
+
}
|
|
238
|
+
drag = undefined
|
|
239
|
+
})
|
|
240
|
+
window.addEventListener('contextmenu', (e) => { e.preventDefault(); window.pet.menu() })
|
|
241
|
+
// 在人物上 Ctrl+滚轮调大小
|
|
242
|
+
characterEl.addEventListener('wheel', (e) => {
|
|
243
|
+
if (!e.ctrlKey) return
|
|
244
|
+
e.preventDefault()
|
|
245
|
+
window.pet.scaleBy(e.deltaY < 0 ? SCALE_STEP : -SCALE_STEP)
|
|
246
|
+
}, { passive: false })
|
|
247
|
+
|
|
248
|
+
// 拖文件给她
|
|
249
|
+
document.addEventListener('dragover', (e) => { e.preventDefault(); characterEl.classList.add('drop-hover') })
|
|
250
|
+
document.addEventListener('dragleave', () => characterEl.classList.remove('drop-hover'))
|
|
251
|
+
document.addEventListener('drop', (e) => {
|
|
252
|
+
e.preventDefault()
|
|
253
|
+
characterEl.classList.remove('drop-hover')
|
|
254
|
+
const paths = [...e.dataTransfer.files].map(f => window.pet.pathForFile(f)).filter(Boolean)
|
|
255
|
+
if (!paths.length) return
|
|
256
|
+
if (!connected) return showBubble('DSH 没连上,交不出去……', { kind: 'error' })
|
|
257
|
+
renderer.poke()
|
|
258
|
+
window.pet.send({ type: 'files', paths })
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// 立绘、气泡、角标在窗口里的位置报给主进程,主进程据此决定哪里接鼠标、哪里点击穿透
|
|
262
|
+
let lastLayout = ''
|
|
263
|
+
const rectOf = (el) => {
|
|
264
|
+
if (el.hidden) return null
|
|
265
|
+
const r = el.getBoundingClientRect()
|
|
266
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height }
|
|
267
|
+
}
|
|
268
|
+
// 气泡默认在头顶正中;快出屏幕(工作区)时横移回来
|
|
269
|
+
let bubbleShift = 0
|
|
270
|
+
function keepBubbleOnScreen() {
|
|
271
|
+
if (bubble.hidden) return
|
|
272
|
+
const r = bubble.getBoundingClientRect()
|
|
273
|
+
const left = window.screenX + r.left - bubbleShift
|
|
274
|
+
const right = left + r.width
|
|
275
|
+
const min = screen.availLeft + SCREEN_EDGE
|
|
276
|
+
const max = screen.availLeft + screen.availWidth - SCREEN_EDGE
|
|
277
|
+
const shift = Math.round(right > max ? max - right : left < min ? min - left : 0)
|
|
278
|
+
if (shift === bubbleShift) return
|
|
279
|
+
bubbleShift = shift
|
|
280
|
+
bubble.style.setProperty('--shift', `${shift}px`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
setInterval(() => {
|
|
284
|
+
keepBubbleOnScreen()
|
|
285
|
+
const layout = { character: rectOf(characterEl), bubble: rectOf(bubble), badge: rectOf(badge) }
|
|
286
|
+
const key = JSON.stringify(layout)
|
|
287
|
+
if (key === lastLayout) return
|
|
288
|
+
lastLayout = key
|
|
289
|
+
window.pet.layout(layout)
|
|
290
|
+
}, 100)
|
|
291
|
+
|
|
292
|
+
setState('idle', { force: true })
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// 2D 精灵渲染器。以后的 3D 渲染器(three.js + GLB/VRM)实现同样的接口:
|
|
2
|
+
// setState(state) / setOffline(bool) / poke() / alarm() / dispose()
|
|
3
|
+
|
|
4
|
+
const FRAME_OF = {
|
|
5
|
+
idle: 'idle',
|
|
6
|
+
thinking: 'thinking',
|
|
7
|
+
running: 'working',
|
|
8
|
+
editing: 'working',
|
|
9
|
+
reading: 'working',
|
|
10
|
+
web: 'working',
|
|
11
|
+
waiting: 'waiting',
|
|
12
|
+
done: 'done',
|
|
13
|
+
error: 'error',
|
|
14
|
+
sleep: 'sleep',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class Sprite2DRenderer {
|
|
18
|
+
constructor(el, base = '../../assets/sprites/') {
|
|
19
|
+
this.el = el
|
|
20
|
+
this.img = document.createElement('img')
|
|
21
|
+
this.img.alt = ''
|
|
22
|
+
el.appendChild(this.img)
|
|
23
|
+
this.frames = {}
|
|
24
|
+
for (const name of new Set([...Object.values(FRAME_OF), 'blink'])) {
|
|
25
|
+
const img = new Image()
|
|
26
|
+
img.src = `${base}${name}.png`
|
|
27
|
+
this.frames[name] = img.src
|
|
28
|
+
}
|
|
29
|
+
this.state = 'idle'
|
|
30
|
+
this.show('idle')
|
|
31
|
+
this.scheduleBlink()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
show(frame) {
|
|
35
|
+
this.img.src = this.frames[frame]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setState(state) {
|
|
39
|
+
this.state = state
|
|
40
|
+
this.show(FRAME_OF[state] ?? 'idle')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setOffline(offline) {
|
|
44
|
+
this.el.classList.toggle('offline', offline)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// idle 时随机眨眼
|
|
48
|
+
scheduleBlink() {
|
|
49
|
+
this.blinkTimer = setTimeout(() => {
|
|
50
|
+
if (this.state === 'idle') {
|
|
51
|
+
this.show('blink')
|
|
52
|
+
setTimeout(() => { if (this.state === 'idle') this.show('idle') }, 160)
|
|
53
|
+
}
|
|
54
|
+
this.scheduleBlink()
|
|
55
|
+
}, 2500 + Math.random() * 3500)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
replay(cls) {
|
|
59
|
+
this.el.classList.remove(cls)
|
|
60
|
+
void this.el.offsetWidth
|
|
61
|
+
this.el.classList.add(cls)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
poke() { this.replay('hop') }
|
|
65
|
+
alarm() { this.replay('shake') }
|
|
66
|
+
|
|
67
|
+
dispose() {
|
|
68
|
+
clearTimeout(this.blinkTimer)
|
|
69
|
+
this.img.remove()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
[hidden] { display: none !important; }
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--scale: 0.6;
|
|
5
|
+
--cw: calc(200px * var(--scale));
|
|
6
|
+
--ch: calc(240px * var(--scale));
|
|
7
|
+
--bg: #ffffff;
|
|
8
|
+
--text: #1f2328;
|
|
9
|
+
--muted: #6e7781;
|
|
10
|
+
--line: #e6e8eb;
|
|
11
|
+
--chip: #f2f3f5;
|
|
12
|
+
--accent: #1f2328;
|
|
13
|
+
--accent-text: #ffffff;
|
|
14
|
+
--error: #cf222e;
|
|
15
|
+
--shadow: 0 6px 24px rgba(15, 23, 42, 0.14), 0 1px 4px rgba(15, 23, 42, 0.06);
|
|
16
|
+
}
|
|
17
|
+
@media (prefers-color-scheme: dark) {
|
|
18
|
+
:root {
|
|
19
|
+
--bg: #23252a;
|
|
20
|
+
--text: #e6e8eb;
|
|
21
|
+
--muted: #a0a7b1;
|
|
22
|
+
--line: #34373d;
|
|
23
|
+
--chip: #2f3237;
|
|
24
|
+
--accent: #e6e8eb;
|
|
25
|
+
--accent-text: #1f2328;
|
|
26
|
+
--error: #ff7b72;
|
|
27
|
+
--shadow: 0 6px 24px rgba(0, 0, 0, 0.45), 0 1px 4px rgba(0, 0, 0, 0.3);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
html, body {
|
|
32
|
+
margin: 0;
|
|
33
|
+
height: 100%;
|
|
34
|
+
background: transparent;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
user-select: none;
|
|
37
|
+
font-family: "Microsoft YaHei UI", "PingFang SC", sans-serif;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#stage {
|
|
41
|
+
position: relative;
|
|
42
|
+
width: 100%;
|
|
43
|
+
height: 100%;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* 人物大小由主进程定(--scale),人物在窗口底部正中 */
|
|
47
|
+
#character {
|
|
48
|
+
position: absolute;
|
|
49
|
+
bottom: 0;
|
|
50
|
+
left: 50%;
|
|
51
|
+
margin-left: calc(var(--cw) / -2);
|
|
52
|
+
width: var(--cw);
|
|
53
|
+
height: var(--ch);
|
|
54
|
+
cursor: grab;
|
|
55
|
+
}
|
|
56
|
+
#character img {
|
|
57
|
+
width: 100%;
|
|
58
|
+
height: 100%;
|
|
59
|
+
object-fit: contain;
|
|
60
|
+
pointer-events: none;
|
|
61
|
+
-webkit-user-drag: none;
|
|
62
|
+
}
|
|
63
|
+
#character.offline img { filter: grayscale(1) opacity(0.7); }
|
|
64
|
+
#character.hop { animation: hop 0.35s ease-out; }
|
|
65
|
+
#character.shake { animation: shake 0.5s ease-in-out 2; }
|
|
66
|
+
#character.drop-hover { filter: drop-shadow(0 0 10px #ffcc33); }
|
|
67
|
+
|
|
68
|
+
@keyframes hop {
|
|
69
|
+
0%, 100% { transform: translateY(0); }
|
|
70
|
+
40% { transform: translateY(calc(-18px * var(--scale))); }
|
|
71
|
+
}
|
|
72
|
+
@keyframes shake {
|
|
73
|
+
0%, 100% { transform: translateX(0); }
|
|
74
|
+
25% { transform: translateX(-5px); }
|
|
75
|
+
75% { transform: translateX(5px); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* 气泡:跟卡片一个风格,宽度随字走,最宽 260 再换行;在头顶正中,小尾巴朝下指着头顶。
|
|
79
|
+
只有快出屏幕时才横移 --shift,尾巴反向挪回去,仍然指着人物 */
|
|
80
|
+
#bubble {
|
|
81
|
+
position: absolute;
|
|
82
|
+
bottom: calc(var(--ch) + 10px);
|
|
83
|
+
left: 50%;
|
|
84
|
+
transform: translateX(calc(-50% + var(--shift, 0px)));
|
|
85
|
+
box-sizing: border-box;
|
|
86
|
+
width: max-content;
|
|
87
|
+
max-width: 260px;
|
|
88
|
+
padding: 7px 12px;
|
|
89
|
+
border-radius: 12px;
|
|
90
|
+
background: var(--bg);
|
|
91
|
+
border: 1px solid var(--line);
|
|
92
|
+
color: var(--text);
|
|
93
|
+
font-size: 13px;
|
|
94
|
+
line-height: 1.5;
|
|
95
|
+
box-shadow: var(--shadow);
|
|
96
|
+
}
|
|
97
|
+
#bubble::after {
|
|
98
|
+
content: "";
|
|
99
|
+
position: absolute;
|
|
100
|
+
bottom: -6px;
|
|
101
|
+
left: clamp(12px, calc(50% - 5px - var(--shift, 0px)), calc(100% - 22px));
|
|
102
|
+
width: 10px;
|
|
103
|
+
height: 10px;
|
|
104
|
+
background: var(--bg);
|
|
105
|
+
border-right: 1px solid var(--line);
|
|
106
|
+
border-bottom: 1px solid var(--line);
|
|
107
|
+
transform: rotate(45deg);
|
|
108
|
+
}
|
|
109
|
+
#bubble.error #bubble-text { color: var(--error); }
|
|
110
|
+
#bubble.clickable { cursor: pointer; }
|
|
111
|
+
|
|
112
|
+
#bubble-text {
|
|
113
|
+
max-height: 84px;
|
|
114
|
+
overflow: auto;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#bubble-detail {
|
|
118
|
+
margin-top: 6px;
|
|
119
|
+
max-height: 44px;
|
|
120
|
+
overflow: auto;
|
|
121
|
+
font-family: Consolas, monospace;
|
|
122
|
+
font-size: 12px;
|
|
123
|
+
white-space: pre-wrap;
|
|
124
|
+
word-break: break-all;
|
|
125
|
+
color: var(--muted);
|
|
126
|
+
user-select: text;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#bubble-actions {
|
|
130
|
+
display: flex;
|
|
131
|
+
gap: 8px;
|
|
132
|
+
margin-top: 8px;
|
|
133
|
+
}
|
|
134
|
+
#bubble-actions button {
|
|
135
|
+
flex: 1;
|
|
136
|
+
padding: 4px 0;
|
|
137
|
+
border: 1px solid var(--line);
|
|
138
|
+
border-radius: 8px;
|
|
139
|
+
background: var(--chip);
|
|
140
|
+
color: var(--text);
|
|
141
|
+
font: inherit;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
}
|
|
144
|
+
#bubble-actions #btn-allow { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
|
145
|
+
#bubble-actions button:hover { filter: brightness(0.94); }
|
|
146
|
+
|
|
147
|
+
#badge {
|
|
148
|
+
position: absolute;
|
|
149
|
+
left: calc(50% + var(--cw) * 0.28);
|
|
150
|
+
bottom: calc(var(--ch) * 0.86);
|
|
151
|
+
padding: 1px 7px;
|
|
152
|
+
border-radius: 9px;
|
|
153
|
+
background: #c0392b;
|
|
154
|
+
color: #fff;
|
|
155
|
+
font-size: 12px;
|
|
156
|
+
font-weight: bold;
|
|
157
|
+
}
|