dsh-mini-remote 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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/README.zh.md +138 -0
- package/client/client.js +387 -0
- package/client/verify.mjs +514 -0
- package/cordis.patch.yml +5 -0
- package/lib/art/work-1-ready.webp +0 -0
- package/lib/art/work-2-reading.webp +0 -0
- package/lib/art/work-3-typing.webp +0 -0
- package/lib/art/work-4-checking.webp +0 -0
- package/lib/art/work-5-thinking.webp +0 -0
- package/lib/art/work-6-running.webp +0 -0
- package/lib/art/work-7-waiting.webp +0 -0
- package/lib/art/work-8-sprinting.webp +0 -0
- package/lib/build.js +59 -0
- package/lib/cloudflared.js +165 -0
- package/lib/events.js +165 -0
- package/lib/index.js +1206 -0
- package/lib/net.js +104 -0
- package/lib/notify.js +82 -0
- package/lib/page.html +1891 -0
- package/lib/page.js +46 -0
- package/lib/pairing.js +172 -0
- package/lib/server.js +590 -0
- package/lib/store.js +334 -0
- package/lib/tree.js +185 -0
- package/lib/tunnel.js +144 -0
- package/package.json +74 -0
package/lib/page.html
ADDED
|
@@ -0,0 +1,1891 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#0f1115">
|
|
7
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
9
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
10
|
+
<title>DSH mini remote</title>
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
--bg: #0f1115;
|
|
14
|
+
--panel: #171a21;
|
|
15
|
+
--panel-2: #1e222b;
|
|
16
|
+
--line: #2a2f3a;
|
|
17
|
+
--fg: #e8eaed;
|
|
18
|
+
--muted: #8b93a1;
|
|
19
|
+
--accent: #4c8dff;
|
|
20
|
+
--ok: #35c46b;
|
|
21
|
+
--warn: #e0a33a;
|
|
22
|
+
--err: #e5604d;
|
|
23
|
+
--radius: 14px;
|
|
24
|
+
}
|
|
25
|
+
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
|
26
|
+
html, body {
|
|
27
|
+
margin: 0; height: 100%; overflow: hidden;
|
|
28
|
+
background: var(--bg); color: var(--fg);
|
|
29
|
+
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
|
30
|
+
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
31
|
+
overscroll-behavior: none;
|
|
32
|
+
}
|
|
33
|
+
body { display: flex; flex-direction: column; }
|
|
34
|
+
|
|
35
|
+
/* ---------- 顶栏 ---------- */
|
|
36
|
+
header {
|
|
37
|
+
flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
|
|
38
|
+
padding: calc(env(safe-area-inset-top) + 10px) 14px 10px;
|
|
39
|
+
background: var(--panel); border-bottom: 1px solid var(--line);
|
|
40
|
+
}
|
|
41
|
+
.brand { font-weight: 600; letter-spacing: .3px; }
|
|
42
|
+
.dot {
|
|
43
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
44
|
+
background: var(--muted); flex: 0 0 auto; transition: background .2s;
|
|
45
|
+
}
|
|
46
|
+
.dot.ok { background: var(--ok); }
|
|
47
|
+
.dot.run { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
|
|
48
|
+
.dot.err { background: var(--err); }
|
|
49
|
+
@keyframes pulse { 50% { opacity: .25; } }
|
|
50
|
+
.status { color: var(--muted); font-size: 13px; flex: 1 1 auto;
|
|
51
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
52
|
+
.icon-btn {
|
|
53
|
+
flex: 0 0 auto; width: 36px; height: 36px; border-radius: 10px;
|
|
54
|
+
border: 1px solid var(--line); background: var(--panel-2); color: var(--fg);
|
|
55
|
+
font-size: 16px; display: grid; place-items: center; cursor: pointer;
|
|
56
|
+
}
|
|
57
|
+
.icon-btn:active { transform: scale(.94); }
|
|
58
|
+
|
|
59
|
+
/* ---------- 显示区 ---------- */
|
|
60
|
+
main { flex: 1 1 auto; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 16px 14px; }
|
|
61
|
+
main.center { display: grid; place-items: center; }
|
|
62
|
+
|
|
63
|
+
#reply {
|
|
64
|
+
white-space: pre-wrap; word-break: break-word;
|
|
65
|
+
font-size: 16px; line-height: 1.75;
|
|
66
|
+
}
|
|
67
|
+
#reply a { color: var(--accent); }
|
|
68
|
+
.placeholder { color: var(--muted); font-size: 15px; text-align: center; line-height: 1.9; }
|
|
69
|
+
.placeholder kbd {
|
|
70
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
71
|
+
border-radius: 6px; padding: 1px 6px; font-size: 13px; font-family: inherit;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ---------- 聊天模式 ---------- */
|
|
75
|
+
/* width: fit-content 是关键,别删。
|
|
76
|
+
气泡是个块级元素,而块级元素的宽度默认是「撑满容器」——只写 max-width 的话,
|
|
77
|
+
那只是给它封了个顶,短消息(「好的」「继续」)照样会撑成一个 86% 宽的大方块,
|
|
78
|
+
右边留一大片空白。fit-content 让它按内容收:短的自适应,长的到 86% 就折行。
|
|
79
|
+
两种气泡都要,只改一边的话,短回复那边还是会露出大方块,看着像坏了。 */
|
|
80
|
+
.bubble {
|
|
81
|
+
width: fit-content;
|
|
82
|
+
max-width: 86%; padding: 10px 13px; border-radius: var(--radius);
|
|
83
|
+
/* 18px,和 .said 的下边距一致。原来是 10px,用户 2026-09-22 实机提「指令气泡和
|
|
84
|
+
回答正文第一行之间贴得太紧」——10px 是「同一条消息内部」的距离,
|
|
85
|
+
而这里跨的是「我说的话」和「它答的话」,是更大的一个断点,该给更多。 */
|
|
86
|
+
margin-bottom: 18px; white-space: pre-wrap; word-break: break-word;
|
|
87
|
+
font-size: 15.5px; line-height: 1.7;
|
|
88
|
+
}
|
|
89
|
+
.bubble.user { margin-left: auto; background: var(--panel-2); border: 1px solid var(--line); }
|
|
90
|
+
.bubble.assistant { margin-right: auto; background: var(--panel); border: 1px solid var(--line); }
|
|
91
|
+
.bubble a { color: var(--accent); }
|
|
92
|
+
.bubble .meta { display: block; margin-top: 6px; font-size: 11px; color: var(--muted); }
|
|
93
|
+
/* 「已停止」标签:被按停的那一轮,手机上收到的只是**已经吐出来的那半句**。
|
|
94
|
+
不标出来的话,它看着就像模型答崩了。 */
|
|
95
|
+
.stopped {
|
|
96
|
+
display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px;
|
|
97
|
+
color: var(--muted); border: 1px solid var(--line); vertical-align: middle;
|
|
98
|
+
}
|
|
99
|
+
.reply-bar .stopped { margin-right: auto; }
|
|
100
|
+
/* ---------- Markdown 正文 ---------- */
|
|
101
|
+
/* 外层是 pre-wrap(用户消息和旧内容靠它),markdown 输出的是真块级元素,
|
|
102
|
+
所以要在这里把 pre-wrap 收掉,否则段落之间会多出一堆空行。 */
|
|
103
|
+
.md { white-space: normal; }
|
|
104
|
+
.md > :first-child { margin-top: 0; }
|
|
105
|
+
.md > :last-child { margin-bottom: 0; }
|
|
106
|
+
.md p { margin: 0 0 .7em; }
|
|
107
|
+
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 {
|
|
108
|
+
margin: 1em 0 .5em; line-height: 1.35; font-weight: 650;
|
|
109
|
+
}
|
|
110
|
+
.md h1 { font-size: 1.35em; }
|
|
111
|
+
.md h2 { font-size: 1.22em; }
|
|
112
|
+
.md h3 { font-size: 1.1em; }
|
|
113
|
+
.md h4, .md h5, .md h6 { font-size: 1em; }
|
|
114
|
+
.md ul, .md ol { margin: 0 0 .7em; padding-left: 1.4em; }
|
|
115
|
+
.md li { margin: .18em 0; }
|
|
116
|
+
.md a { color: var(--accent); word-break: break-all; }
|
|
117
|
+
.md strong { font-weight: 650; }
|
|
118
|
+
.md hr { border: none; border-top: 1px solid var(--line); margin: 1em 0; }
|
|
119
|
+
.md blockquote {
|
|
120
|
+
margin: 0 0 .7em; padding: .1em 0 .1em .8em;
|
|
121
|
+
border-left: 3px solid var(--line); color: var(--muted);
|
|
122
|
+
}
|
|
123
|
+
.md code {
|
|
124
|
+
background: var(--panel-2); border: 1px solid var(--line); border-radius: 5px;
|
|
125
|
+
padding: .1em .35em; font-size: .88em;
|
|
126
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
127
|
+
}
|
|
128
|
+
.md .code {
|
|
129
|
+
margin: 0 0 .7em; border: 1px solid var(--line); border-radius: 10px;
|
|
130
|
+
background: var(--panel-2); overflow: hidden;
|
|
131
|
+
}
|
|
132
|
+
.md .code-head {
|
|
133
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
134
|
+
gap: 8px; padding: 5px 8px 5px 10px; border-bottom: 1px solid var(--line);
|
|
135
|
+
}
|
|
136
|
+
.md .code-head .lang { font-size: 11px; color: var(--muted); }
|
|
137
|
+
.md .code pre { margin: 0; padding: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
|
138
|
+
.md .code code {
|
|
139
|
+
background: none; border: none; padding: 0; font-size: 12.5px; line-height: 1.6;
|
|
140
|
+
white-space: pre;
|
|
141
|
+
}
|
|
142
|
+
/* 表格窄屏放不下就横向滚,别把页面撑破 */
|
|
143
|
+
.md table { border-collapse: collapse; margin: 0 0 .7em; font-size: .92em; display: block; overflow-x: auto; }
|
|
144
|
+
.md th, .md td { border: 1px solid var(--line); padding: 5px 9px; text-align: left; white-space: nowrap; }
|
|
145
|
+
.md th { background: var(--panel-2); font-weight: 600; }
|
|
146
|
+
|
|
147
|
+
/* 复制按钮:方形的图标按钮,图标是两个重叠的方框(就是常见那个复制图标)。
|
|
148
|
+
点下去换成对勾,1.4 秒后自己变回来——手机上没法悬停,也没有「已复制」的
|
|
149
|
+
系统提示,这个反馈是唯一的,所以必须有。
|
|
150
|
+
三个地方共用:单帧模式的整篇、聊天模式的气泡、代码块。 */
|
|
151
|
+
.copy {
|
|
152
|
+
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
|
|
153
|
+
width: 28px; height: 28px; padding: 0; border-radius: 8px; cursor: pointer;
|
|
154
|
+
border: 1px solid var(--line); background: var(--panel-2); color: var(--muted);
|
|
155
|
+
}
|
|
156
|
+
.copy svg {
|
|
157
|
+
width: 15px; height: 15px; fill: none; stroke: currentColor;
|
|
158
|
+
stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
|
|
159
|
+
}
|
|
160
|
+
.copy:active { transform: scale(.95); }
|
|
161
|
+
.copy.done { color: var(--accent); border-color: var(--accent); }
|
|
162
|
+
.bubble .meta .copy { margin-left: 8px; vertical-align: middle; }
|
|
163
|
+
/* 上面那条只放「已停止」标签——它是给正文打的预防针,得在正文之前出现。
|
|
164
|
+
复制按钮挪到正文下面去了(.reply-foot)。 */
|
|
165
|
+
.reply-bar { display: flex; justify-content: flex-start; margin-bottom: 8px; }
|
|
166
|
+
/* 复制按钮在正文下方、靠左——用户要求。原来在上面靠右,拇指够不着,
|
|
167
|
+
而且会比正文先入眼。 */
|
|
168
|
+
.reply-foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
|
169
|
+
.reply-foot .time { font-size: 11.5px; color: var(--muted); }
|
|
170
|
+
/* 聊天模式下 AI 的回答**不套气泡**(用户要求「参考单帧模式那样」)。
|
|
171
|
+
用户自己的指令仍然是气泡,一左一右、一个有框一个没框,正好分得清谁说的。 */
|
|
172
|
+
.said { margin-bottom: 18px; }
|
|
173
|
+
/* ---------- 正在流出来的那一段 ---------- */
|
|
174
|
+
/* 和最终答案**刻意画得不一样**:上面一条虚线、末尾一个闪动的竖条、没有复制按钮。
|
|
175
|
+
三样都在说同一句话——「这不是最终答案,还会变」。等答案落定整块被换掉。 */
|
|
176
|
+
.live {
|
|
177
|
+
margin-top: 18px; padding-top: 14px;
|
|
178
|
+
border-top: 1px dashed var(--line);
|
|
179
|
+
}
|
|
180
|
+
.live .md > *:last-child::after {
|
|
181
|
+
content: ''; display: inline-block; width: 2px; height: 1em;
|
|
182
|
+
margin-left: 2px; vertical-align: -2px;
|
|
183
|
+
background: var(--accent, #2563eb);
|
|
184
|
+
animation: caret 1s steps(2, start) infinite;
|
|
185
|
+
}
|
|
186
|
+
@keyframes caret { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
|
|
187
|
+
/* ---------- 「正在执行」:鲸鱼娘 + 进度条 + 气泡 ----------
|
|
188
|
+
单帧模式下它出现在上一条回答的**下方**,不顶掉它,所以要用虚线跟正文分开,
|
|
189
|
+
不然会被当成那条回答的一部分。 */
|
|
190
|
+
.work {
|
|
191
|
+
margin-top: 20px; padding-top: 14px;
|
|
192
|
+
border-top: 1px dashed var(--line);
|
|
193
|
+
}
|
|
194
|
+
/* 立绘和气泡是**一组**。这里把两件事拆开:
|
|
195
|
+
立绘站哪儿 → 由**这一行的宽度**决定,跟词条无关;
|
|
196
|
+
气泡多宽 → 由词条自己的长短决定。
|
|
197
|
+
行宽取「最长那句」的整组宽度,再把这一行居中,于是立绘永远站在同一个位置。
|
|
198
|
+
算账:立绘 122 + 间隙 6 + 最长那句的气泡 171 = 299。
|
|
199
|
+
那个 171 = 10 个全角字 × 14.5px,加左右内边距 24、边框 2。
|
|
200
|
+
用 max-content 而不是写死 width:将来真加了更长的词条,这行会自己撑开——
|
|
201
|
+
宁可立绘挪一点,也不能让字顶出屏幕。
|
|
202
|
+
min() 是窄屏退路:屏幕比 299 还窄时,这行跟着屏幕收,别溢出。 */
|
|
203
|
+
.work-top {
|
|
204
|
+
display: flex; align-items: center; gap: 6px;
|
|
205
|
+
width: max-content; min-width: min(299px, 100%);
|
|
206
|
+
margin: 0 auto;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* 立绘:每个姿势是一张「两帧并排」的雪碧图(760×330),靠 background-position
|
|
210
|
+
在左右两帧之间硬切,手脚就真的在动——不是把一张静图晃一晃。
|
|
211
|
+
七层叠在一起,换姿势时交叉淡入。 */
|
|
212
|
+
.work-stage {
|
|
213
|
+
position: relative; flex: 0 0 auto;
|
|
214
|
+
width: 122px; height: 106px;
|
|
215
|
+
/* 整块再轻轻浮动一下。它的节奏和翻帧错开,两下叠起来才像活的。 */
|
|
216
|
+
animation: workBob 2.4s ease-in-out infinite;
|
|
217
|
+
}
|
|
218
|
+
@keyframes workBob { 50% { transform: translateY(-3px); } }
|
|
219
|
+
.pose {
|
|
220
|
+
position: absolute; inset: 0;
|
|
221
|
+
background-repeat: no-repeat;
|
|
222
|
+
background-position: 0 0;
|
|
223
|
+
background-size: 244px 106px; /* 两帧并排的显示宽度 = 122 × 2 */
|
|
224
|
+
opacity: 0; transition: opacity .35s ease;
|
|
225
|
+
animation-name: poseFlip;
|
|
226
|
+
animation-timing-function: step-end;
|
|
227
|
+
animation-iteration-count: infinite;
|
|
228
|
+
animation-play-state: paused; /* 没轮到它的层就别动,省电 */
|
|
229
|
+
}
|
|
230
|
+
.pose.on { opacity: 1; animation-play-state: running; }
|
|
231
|
+
/* 前半段左帧、后半段右帧。49.9%→50% 那一下是硬切、不插值,
|
|
232
|
+
不然会看到两张图横向滑过去。 */
|
|
233
|
+
@keyframes poseFlip {
|
|
234
|
+
0%, 49.9% { background-position: 0 0; }
|
|
235
|
+
50%, 100% { background-position: -122px 0; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* 气泡:左边一个小尖角指向立绘。
|
|
239
|
+
宽度**贴着自己的词条**,多长就多宽,右边不留空。
|
|
240
|
+
它推不动立绘——立绘的位置由上面 .work-top 的固定行宽管着,跟气泡宽窄无关。
|
|
241
|
+
所以「词条每 3.6 秒换一次」这件事不会让立绘左右晃。
|
|
242
|
+
max-width 是窄屏退路:立绘 122 + 间隙 6,剩下的都给气泡,宁可折行也别顶出屏幕。
|
|
243
|
+
抬到 flex-start 是让尖角对着她的头,而不是对着身子中间。 */
|
|
244
|
+
.work-bubble {
|
|
245
|
+
position: relative; flex: 0 1 auto;
|
|
246
|
+
max-width: calc(100% - 128px);
|
|
247
|
+
align-self: flex-start; margin-top: 6px;
|
|
248
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
249
|
+
border-radius: 12px; padding: 9px 12px;
|
|
250
|
+
font-size: 14.5px; line-height: 1.5; color: var(--fg);
|
|
251
|
+
}
|
|
252
|
+
.work-bubble::before, .work-bubble::after {
|
|
253
|
+
content: ''; position: absolute; top: 50%; width: 0; height: 0;
|
|
254
|
+
border: 6px solid transparent; margin-top: -6px;
|
|
255
|
+
}
|
|
256
|
+
.work-bubble::before { left: -12px; border-right-color: var(--line); }
|
|
257
|
+
.work-bubble::after { left: -11px; border-right-color: var(--panel-2); }
|
|
258
|
+
|
|
259
|
+
/* 进度条故意做成**不确定进度**的流动条,不是百分比。
|
|
260
|
+
插件按设计只收「指令 + 最终回复」,拿不到 Agent 此刻跑到哪一步,
|
|
261
|
+
所以画一个填到 80% 的假进度条等于撒谎。它只表达「在跑」,不表达「跑到哪了」。 */
|
|
262
|
+
.work-progress {
|
|
263
|
+
margin-top: 10px; height: 4px; border-radius: 2px;
|
|
264
|
+
background: var(--panel-2); overflow: hidden;
|
|
265
|
+
}
|
|
266
|
+
.work-progress i {
|
|
267
|
+
display: block; width: 38%; height: 100%; border-radius: 2px;
|
|
268
|
+
background: linear-gradient(90deg, transparent, var(--accent) 45%, var(--accent) 55%, transparent);
|
|
269
|
+
animation: workSweep 1.5s ease-in-out infinite;
|
|
270
|
+
}
|
|
271
|
+
@keyframes workSweep {
|
|
272
|
+
from { transform: translateX(-105%); }
|
|
273
|
+
to { transform: translateX(265%); }
|
|
274
|
+
}
|
|
275
|
+
/* 秒数在左、停止按钮在右。秒数原来是自己一行右对齐,现在跟按钮同一行。 */
|
|
276
|
+
.work-foot {
|
|
277
|
+
margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
|
278
|
+
}
|
|
279
|
+
.work-elapsed {
|
|
280
|
+
color: var(--muted); font-size: 12px;
|
|
281
|
+
font-variant-numeric: tabular-nums;
|
|
282
|
+
}
|
|
283
|
+
/* 停止按钮。做成描边而不是实心:它是个「打断」,不该比「发送」还显眼。
|
|
284
|
+
按下去之后自己禁用并改文案——cancel() 只是递请求,真正停下要等状态翻,
|
|
285
|
+
这中间必须让用户看见「已经按到了」,否则会连按好几下。 */
|
|
286
|
+
.stop {
|
|
287
|
+
padding: 5px 14px; border-radius: 999px; font-size: 13px;
|
|
288
|
+
border: 1px solid var(--line); background: transparent; color: var(--fg);
|
|
289
|
+
transition: opacity .15s;
|
|
290
|
+
}
|
|
291
|
+
.stop:disabled { opacity: .5; }
|
|
292
|
+
|
|
293
|
+
/* 排队中的指令。跑着任务时接着发的那几条排在这儿,等这一轮跑完再跑。
|
|
294
|
+
单帧模式下你发出去的那句话在别处一个字都看不见(那一模式只显示回复),
|
|
295
|
+
所以这块是唯一的反馈,不能省。 */
|
|
296
|
+
.queue {
|
|
297
|
+
margin-top: 12px; padding: 8px 10px; border-radius: 10px;
|
|
298
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
299
|
+
}
|
|
300
|
+
.queue .q-head { font-size: 11.5px; color: var(--muted); margin-bottom: 6px; }
|
|
301
|
+
.queue .q-row {
|
|
302
|
+
display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
|
303
|
+
font-size: 13.5px; line-height: 1.45;
|
|
304
|
+
}
|
|
305
|
+
/* 长指令折行,但最多两行——队列是「提醒还有几条」,不是给人重读全文的地方。 */
|
|
306
|
+
.queue .q-text {
|
|
307
|
+
flex: 1 1 auto; min-width: 0; color: var(--fg);
|
|
308
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
309
|
+
overflow: hidden;
|
|
310
|
+
}
|
|
311
|
+
.queue .q-drop {
|
|
312
|
+
flex: 0 0 auto; width: 24px; height: 24px; padding: 0; line-height: 1;
|
|
313
|
+
border-radius: 999px; font-size: 12px;
|
|
314
|
+
border: 1px solid var(--line); background: transparent; color: var(--muted);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* 系统里设了「减少动态效果」的人:不浮动、不翻帧、不轮播、进度条常亮。
|
|
318
|
+
秒数照常走——那是信息,不是装饰。 */
|
|
319
|
+
@media (prefers-reduced-motion: reduce) {
|
|
320
|
+
.work-stage { animation: none; }
|
|
321
|
+
.pose { animation: none; transition: none; }
|
|
322
|
+
.work-progress i { animation: none; width: 100%; opacity: .45; }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ---------- 底栏 ---------- */
|
|
326
|
+
footer {
|
|
327
|
+
flex: 0 0 auto; display: flex; align-items: flex-end; gap: 8px;
|
|
328
|
+
padding: 10px 12px calc(env(safe-area-inset-bottom) + 10px);
|
|
329
|
+
background: var(--panel); border-top: 1px solid var(--line);
|
|
330
|
+
}
|
|
331
|
+
#input {
|
|
332
|
+
flex: 1 1 auto; resize: none; min-height: 42px; max-height: 140px;
|
|
333
|
+
padding: 10px 12px; border-radius: 12px; border: 1px solid var(--line);
|
|
334
|
+
background: var(--panel-2); color: var(--fg); font: inherit; font-size: 16px;
|
|
335
|
+
line-height: 1.5; outline: none;
|
|
336
|
+
}
|
|
337
|
+
#input:focus { border-color: var(--accent); }
|
|
338
|
+
.send {
|
|
339
|
+
flex: 0 0 auto; height: 42px; padding: 0 16px; border-radius: 12px;
|
|
340
|
+
border: none; background: var(--accent); color: #fff;
|
|
341
|
+
font-size: 15px; font-weight: 600; cursor: pointer;
|
|
342
|
+
}
|
|
343
|
+
.send:disabled { opacity: .4; }
|
|
344
|
+
|
|
345
|
+
/* ---------- 上传附件 ---------- */
|
|
346
|
+
.attach {
|
|
347
|
+
flex: 0 0 auto; width: 42px; height: 42px; padding: 0; border-radius: 12px;
|
|
348
|
+
border: 1px solid var(--line); background: var(--panel-2); color: var(--muted);
|
|
349
|
+
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
|
|
350
|
+
}
|
|
351
|
+
.attach svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
352
|
+
.attach:disabled { opacity: .4; }
|
|
353
|
+
/* 上传中:图标转起来。这条路上没有进度条,至少要让人看出「它在动」。 */
|
|
354
|
+
.attach.busy svg { animation: spin 1s linear infinite; }
|
|
355
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
356
|
+
|
|
357
|
+
/* 附件小条:排在输入框上面,让你发之前看得见自己带了什么。 */
|
|
358
|
+
.attach-bar {
|
|
359
|
+
flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 6px;
|
|
360
|
+
padding: 8px 12px 0; background: var(--panel); border-top: 1px solid var(--line);
|
|
361
|
+
}
|
|
362
|
+
.attach-bar[hidden] { display: none; }
|
|
363
|
+
.chip {
|
|
364
|
+
display: inline-flex; align-items: center; gap: 6px; max-width: 100%;
|
|
365
|
+
padding: 5px 6px 5px 10px; border-radius: 999px;
|
|
366
|
+
border: 1px solid var(--line); background: var(--panel-2);
|
|
367
|
+
font-size: 12.5px; color: var(--fg);
|
|
368
|
+
}
|
|
369
|
+
.chip .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 42vw; }
|
|
370
|
+
.chip .sz { color: var(--muted); flex: none; }
|
|
371
|
+
.chip .x {
|
|
372
|
+
flex: none; width: 20px; height: 20px; padding: 0; border-radius: 50%;
|
|
373
|
+
border: none; background: transparent; color: var(--muted);
|
|
374
|
+
font-size: 15px; line-height: 1; cursor: pointer;
|
|
375
|
+
}
|
|
376
|
+
.chip.failed { border-color: var(--danger, #dc2626); }
|
|
377
|
+
|
|
378
|
+
/* ---------- 左侧导航栏 ---------- */
|
|
379
|
+
#nav {
|
|
380
|
+
position: fixed; inset: 0; z-index: 60; display: none;
|
|
381
|
+
background: rgba(0, 0, 0, .5);
|
|
382
|
+
}
|
|
383
|
+
#nav.open { display: block; }
|
|
384
|
+
.nav-panel {
|
|
385
|
+
position: absolute; top: 0; bottom: 0; left: 0; width: min(86vw, 350px);
|
|
386
|
+
display: flex; flex-direction: column;
|
|
387
|
+
background: var(--panel); border-right: 1px solid var(--line);
|
|
388
|
+
padding-top: env(safe-area-inset-top);
|
|
389
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
390
|
+
animation: navIn .18s ease-out;
|
|
391
|
+
}
|
|
392
|
+
@keyframes navIn { from { transform: translateX(-100%); } }
|
|
393
|
+
.nav-head {
|
|
394
|
+
flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
|
|
395
|
+
padding: 12px 12px 12px 16px; border-bottom: 1px solid var(--line);
|
|
396
|
+
font-weight: 600;
|
|
397
|
+
}
|
|
398
|
+
.nav-head > span:first-child { flex: 1 1 auto; }
|
|
399
|
+
.nav-body { flex: 1 1 auto; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
|
400
|
+
.nav-body .placeholder { padding: 24px 16px; font-size: 14px; }
|
|
401
|
+
|
|
402
|
+
.ws { border-bottom: 1px solid var(--line); }
|
|
403
|
+
.ws-head {
|
|
404
|
+
display: flex; align-items: center; gap: 8px; width: 100%;
|
|
405
|
+
padding: 12px 14px; background: none; border: none; color: var(--fg);
|
|
406
|
+
font: inherit; font-size: 14.5px; text-align: left; cursor: pointer;
|
|
407
|
+
}
|
|
408
|
+
.ws-head .caret {
|
|
409
|
+
flex: 0 0 auto; color: var(--muted); font-size: 10px;
|
|
410
|
+
transition: transform .15s;
|
|
411
|
+
}
|
|
412
|
+
.ws.open .ws-head .caret { transform: rotate(90deg); }
|
|
413
|
+
.ws-head .ws-title { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
414
|
+
.ws-head .ws-count { flex: 0 0 auto; color: var(--muted); font-size: 12px; }
|
|
415
|
+
.ws-body { display: none; }
|
|
416
|
+
.ws.open .ws-body { display: block; }
|
|
417
|
+
|
|
418
|
+
.sess {
|
|
419
|
+
display: flex; align-items: center; gap: 8px; width: 100%;
|
|
420
|
+
padding: 10px 14px 10px 30px; background: none; border: none; color: var(--fg);
|
|
421
|
+
font: inherit; font-size: 14px; text-align: left; cursor: pointer;
|
|
422
|
+
}
|
|
423
|
+
.sess:active { background: var(--panel-2); }
|
|
424
|
+
.sess.cur { background: var(--panel-2); box-shadow: inset 3px 0 0 var(--accent); }
|
|
425
|
+
.sess .s-title { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
426
|
+
.sess .s-meta { flex: 0 0 auto; color: var(--muted); font-size: 11.5px; }
|
|
427
|
+
.sess .s-dot, .ws-head .s-dot {
|
|
428
|
+
flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%;
|
|
429
|
+
background: var(--warn); animation: pulse 1.2s ease-in-out infinite;
|
|
430
|
+
}
|
|
431
|
+
.nav-note { padding: 10px 16px; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
|
432
|
+
|
|
433
|
+
/* ---------- 设置抽屉 ---------- */
|
|
434
|
+
#settings {
|
|
435
|
+
position: fixed; inset: 0; background: rgba(0,0,0,.55);
|
|
436
|
+
display: none; align-items: flex-end; z-index: 50;
|
|
437
|
+
}
|
|
438
|
+
#settings.open { display: flex; }
|
|
439
|
+
.sheet {
|
|
440
|
+
width: 100%; max-height: 86vh; overflow-y: auto;
|
|
441
|
+
background: var(--panel); border-radius: 18px 18px 0 0;
|
|
442
|
+
padding: 18px 16px calc(env(safe-area-inset-bottom) + 18px);
|
|
443
|
+
border-top: 1px solid var(--line);
|
|
444
|
+
}
|
|
445
|
+
.sheet h2 { margin: 0 0 14px; font-size: 16px; }
|
|
446
|
+
.row {
|
|
447
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
448
|
+
gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line);
|
|
449
|
+
}
|
|
450
|
+
.row:last-of-type { border-bottom: none; }
|
|
451
|
+
.row .label { font-size: 15px; }
|
|
452
|
+
.row .hint { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
453
|
+
.seg { display: flex; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
454
|
+
.seg button {
|
|
455
|
+
padding: 7px 12px; border: none; background: var(--panel-2); color: var(--muted);
|
|
456
|
+
font-size: 13px; cursor: pointer;
|
|
457
|
+
}
|
|
458
|
+
.seg button.active { background: var(--accent); color: #fff; }
|
|
459
|
+
select, .btn {
|
|
460
|
+
padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line);
|
|
461
|
+
background: var(--panel-2); color: var(--fg); font: inherit; font-size: 14px;
|
|
462
|
+
}
|
|
463
|
+
.btn { cursor: pointer; }
|
|
464
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
|
465
|
+
.sheet .foot { margin-top: 18px; display: flex; gap: 10px; }
|
|
466
|
+
.sheet .foot .btn { flex: 1 1 0; text-align: center; }
|
|
467
|
+
|
|
468
|
+
/* ---------- token 输入 ---------- */
|
|
469
|
+
#gate { position: fixed; inset: 0; background: var(--bg); z-index: 100;
|
|
470
|
+
display: none; flex-direction: column; justify-content: center;
|
|
471
|
+
padding: 24px; gap: 14px; }
|
|
472
|
+
#gate.open { display: flex; }
|
|
473
|
+
#gate h1 { font-size: 20px; margin: 0; }
|
|
474
|
+
#gate p { color: var(--muted); font-size: 14px; margin: 0; }
|
|
475
|
+
#gate input {
|
|
476
|
+
padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line);
|
|
477
|
+
background: var(--panel-2); color: var(--fg); font: inherit; font-size: 16px;
|
|
478
|
+
}
|
|
479
|
+
#gate button {
|
|
480
|
+
padding: 13px; border-radius: 12px; border: none; background: var(--accent);
|
|
481
|
+
color: #fff; font-size: 16px; font-weight: 600;
|
|
482
|
+
}
|
|
483
|
+
#gate .err { color: var(--err); font-size: 13px; min-height: 18px; }
|
|
484
|
+
|
|
485
|
+
#toast {
|
|
486
|
+
position: fixed; left: 50%; bottom: 84px; transform: translateX(-50%) translateY(8px);
|
|
487
|
+
background: rgba(40,44,54,.97); color: var(--fg); padding: 9px 16px;
|
|
488
|
+
border-radius: 20px; font-size: 13.5px; opacity: 0; pointer-events: none;
|
|
489
|
+
transition: opacity .2s, transform .2s; z-index: 200; max-width: 86vw; text-align: center;
|
|
490
|
+
}
|
|
491
|
+
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
492
|
+
</style>
|
|
493
|
+
</head>
|
|
494
|
+
<body>
|
|
495
|
+
|
|
496
|
+
<header>
|
|
497
|
+
<button class="icon-btn" id="btnNav" aria-label="工作区与会话">☰</button>
|
|
498
|
+
<span class="dot" id="dot"></span>
|
|
499
|
+
<span class="brand">DSH mini remote</span>
|
|
500
|
+
<span class="status" id="status">连接中…</span>
|
|
501
|
+
<button class="icon-btn" id="btnSettings" aria-label="设置">⚙</button>
|
|
502
|
+
</header>
|
|
503
|
+
|
|
504
|
+
<main id="main">
|
|
505
|
+
<div id="reply"></div>
|
|
506
|
+
<!-- 「正在执行」是常驻元素,不参与 #reply 的整块重绘。
|
|
507
|
+
拼进重绘里的话,每次状态广播都会把它重建一次,轮播和浮动动画会一直从头开始。 -->
|
|
508
|
+
<div id="work" class="work" hidden>
|
|
509
|
+
<div class="work-top">
|
|
510
|
+
<div class="work-stage" id="workStage"></div>
|
|
511
|
+
<div class="work-bubble" id="workBubble">正在执行…</div>
|
|
512
|
+
</div>
|
|
513
|
+
<div class="work-progress"><i></i></div>
|
|
514
|
+
<div class="work-foot">
|
|
515
|
+
<span class="work-elapsed" id="workElapsed"></span>
|
|
516
|
+
<button class="stop" id="btnStop" type="button">停止</button>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
|
|
520
|
+
<!-- 排队中的指令。**它必须留在 #work 外面。**
|
|
521
|
+
原来它挂在 #work 里面,而 #work 在回答开始流式生成时会整块隐藏
|
|
522
|
+
(用户 2026-09-22 提的「内容开始流式生成的时候,鲸鱼娘的动画就可以消失了」),
|
|
523
|
+
于是排队列表被一起藏掉了。用户 2026-09-22 报:「排队列表现在手机端直接没出现了」。
|
|
524
|
+
这个 bug 是**被流式修好之后才变明显的**:门槛从 120 降到 40 之后,回答很早就开始
|
|
525
|
+
流式、state.live 长期有值,用户接着发指令时正好落在那个窗口里。
|
|
526
|
+
排队和鲸鱼娘是两件事——前者是「还有几条在等」,后者是「她正在忙」,
|
|
527
|
+
别因为后者要藏就把前者一起藏了。 -->
|
|
528
|
+
<div class="queue" id="queue" hidden></div>
|
|
529
|
+
</main>
|
|
530
|
+
|
|
531
|
+
<div class="attach-bar" id="attachBar" hidden></div>
|
|
532
|
+
<footer>
|
|
533
|
+
<!-- 回形针。input 藏起来,按钮点它——iOS 上直接放一个 file input 样式很难统一。 -->
|
|
534
|
+
<button class="attach" id="btnAttach" type="button" aria-label="上传文件" title="上传文件">
|
|
535
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
536
|
+
</button>
|
|
537
|
+
<input type="file" id="filePick" hidden>
|
|
538
|
+
<textarea id="input" rows="1" placeholder="说点什么…" enterkeyhint="send"></textarea>
|
|
539
|
+
<button class="send" id="btnSend">发送</button>
|
|
540
|
+
</footer>
|
|
541
|
+
|
|
542
|
+
<div id="nav">
|
|
543
|
+
<aside class="nav-panel">
|
|
544
|
+
<div class="nav-head">
|
|
545
|
+
<span>工作区与会话</span>
|
|
546
|
+
<button class="icon-btn" id="btnNavRefresh" aria-label="刷新">⟳</button>
|
|
547
|
+
<button class="icon-btn" id="btnNavClose" aria-label="关闭">✕</button>
|
|
548
|
+
</div>
|
|
549
|
+
<div class="nav-body" id="navBody"></div>
|
|
550
|
+
</aside>
|
|
551
|
+
</div>
|
|
552
|
+
|
|
553
|
+
<div id="settings">
|
|
554
|
+
<div class="sheet">
|
|
555
|
+
<h2>设置</h2>
|
|
556
|
+
|
|
557
|
+
<div class="row">
|
|
558
|
+
<div>
|
|
559
|
+
<div class="label">显示模式</div>
|
|
560
|
+
<div class="hint">单帧:只留最新一条回复</div>
|
|
561
|
+
</div>
|
|
562
|
+
<div class="seg" id="segMode">
|
|
563
|
+
<button data-mode="minimal">单帧</button>
|
|
564
|
+
<button data-mode="chat">聊天</button>
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
|
|
568
|
+
<!--
|
|
569
|
+
这里原来有两行:「完成后提醒」和「语音输入」。
|
|
570
|
+
|
|
571
|
+
2026-09-21 用户裁决:**两行都从设置里撤掉,不让用户看到**——
|
|
572
|
+
它们都是浏览器安全上下文的限制(明文 http 下 Notification 和麦克风都用不了),
|
|
573
|
+
写在设置里只是把浏览器的一条规矩转嫁给用户去理解。相关知识记在下面
|
|
574
|
+
「浏览器系统通知」那段注释和 tasks/lessons.md 里,不占用户界面。
|
|
575
|
+
|
|
576
|
+
想用语音输入:手机键盘自带的 🎤 听写键,任何连接下都能用。
|
|
577
|
+
想人不在也能收到提醒:电脑端的外部推送(Bark / ntfy / Telegram),
|
|
578
|
+
从服务器发出去,不受浏览器这条规矩限制。
|
|
579
|
+
-->
|
|
580
|
+
|
|
581
|
+
<div class="row">
|
|
582
|
+
<div>
|
|
583
|
+
<div class="label">提示音与震动</div>
|
|
584
|
+
<div class="hint">回复到达时提醒(需先点一次页面)</div>
|
|
585
|
+
</div>
|
|
586
|
+
<div class="seg" id="segSound">
|
|
587
|
+
<button data-sound="on">开</button>
|
|
588
|
+
<button data-sound="off">关</button>
|
|
589
|
+
</div>
|
|
590
|
+
</div>
|
|
591
|
+
|
|
592
|
+
<div class="row">
|
|
593
|
+
<div>
|
|
594
|
+
<div class="label">连接</div>
|
|
595
|
+
<div class="hint" id="connHint">—</div>
|
|
596
|
+
</div>
|
|
597
|
+
<button class="btn" id="btnReconnect">重连</button>
|
|
598
|
+
</div>
|
|
599
|
+
|
|
600
|
+
<div class="foot">
|
|
601
|
+
<button class="btn" id="btnForget">清除 token</button>
|
|
602
|
+
<button class="btn primary" id="btnClose">完成</button>
|
|
603
|
+
</div>
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
|
|
607
|
+
<div id="gate">
|
|
608
|
+
<h1>需要访问令牌</h1>
|
|
609
|
+
<p>在电脑上打开 DSH 的「设置 → 手机遥控」,用手机重新扫一次二维码就能进来。
|
|
610
|
+
或者把那一页上的链接整条复制到浏览器地址栏打开。</p>
|
|
611
|
+
<input id="gateInput" type="text" placeholder="粘贴 token" autocomplete="off"
|
|
612
|
+
autocapitalize="off" autocorrect="off" spellcheck="false">
|
|
613
|
+
<div class="err" id="gateErr"></div>
|
|
614
|
+
<button id="gateBtn">进入</button>
|
|
615
|
+
</div>
|
|
616
|
+
|
|
617
|
+
<div id="toast"></div>
|
|
618
|
+
|
|
619
|
+
<script>
|
|
620
|
+
(function () {
|
|
621
|
+
'use strict';
|
|
622
|
+
|
|
623
|
+
var DEFAULT_MODE = '__DEFAULT_MODE__';
|
|
624
|
+
var NEEDS_TOKEN = __NEEDS_TOKEN__;
|
|
625
|
+
// 构建指纹,由服务端注入。拼在立绘地址后面,换了图浏览器就不会一直用缓存那张。
|
|
626
|
+
var BUILD = '__BUILD__';
|
|
627
|
+
// 单个文件的大小上限(字节),由服务端注入。页面里不写第二份——两处各写一个数,
|
|
628
|
+
// 迟早会不一致,而那时候的表现是「页面让你传,服务端拒绝」。
|
|
629
|
+
var MAX_UPLOAD = __MAX_UPLOAD__;
|
|
630
|
+
|
|
631
|
+
// ---------------- 状态 ----------------
|
|
632
|
+
var params = new URLSearchParams(location.search);
|
|
633
|
+
var state = {
|
|
634
|
+
token: params.get('token') || localStorage.getItem('dshMiniToken') || '',
|
|
635
|
+
mode: localStorage.getItem('dshMiniMode') || DEFAULT_MODE || 'minimal',
|
|
636
|
+
sound: localStorage.getItem('dshMiniSound') !== 'off',
|
|
637
|
+
running: false,
|
|
638
|
+
sessions: [],
|
|
639
|
+
// 排队中的指令([{ id, text, placement }])。跑着任务时接着发的那几条排在这儿,
|
|
640
|
+
// 由服务端每次取快照时从 agent 的收件箱现读,手机不自己记。
|
|
641
|
+
queued: [],
|
|
642
|
+
boundSessionId: null,
|
|
643
|
+
connected: false,
|
|
644
|
+
lastReplyStamp: 0,
|
|
645
|
+
// 传好了、还没发出去的文件([{ uploadId, name, bytes }])。
|
|
646
|
+
// 只存服务端发回来的那张小票,不存附件本身——真正的东西已经在电脑上了。
|
|
647
|
+
uploads: [],
|
|
648
|
+
// 正在流出来的那一段。空字符串 = 这会儿没有东西要显示。
|
|
649
|
+
live: ''
|
|
650
|
+
};
|
|
651
|
+
if (params.get('token')) {
|
|
652
|
+
localStorage.setItem('dshMiniToken', params.get('token'));
|
|
653
|
+
// 别把 token 留在地址栏里,避免被截图/分享带出去。
|
|
654
|
+
history.replaceState(null, '', location.pathname);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
658
|
+
var mainEl = $('main'), replyEl = $('reply'), inputEl = $('input');
|
|
659
|
+
var dotEl = $('dot'), statusEl = $('status');
|
|
660
|
+
|
|
661
|
+
// ---------------- 小工具 ----------------
|
|
662
|
+
function escapeHtml(s) {
|
|
663
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
664
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
// 复制按钮的两个图标。做成常量而不是散在各处:以后换图标只改这一处。
|
|
668
|
+
// 形状(两个重叠的方框)和配色都在 CSS 的 .copy 里,这里只管画线。
|
|
669
|
+
var ICON_COPY = '<svg viewBox="0 0 16 16" aria-hidden="true">'
|
|
670
|
+
+ '<rect x="5.6" y="1.6" width="8.8" height="8.8" rx="2.2"/>'
|
|
671
|
+
+ '<rect x="1.6" y="5.6" width="8.8" height="8.8" rx="2.2"/></svg>';
|
|
672
|
+
var ICON_DONE = '<svg viewBox="0 0 16 16" aria-hidden="true">'
|
|
673
|
+
+ '<path d="M2.6 8.4l3.6 3.6 7.2-7.2"/></svg>';
|
|
674
|
+
function linkify(text) {
|
|
675
|
+
return escapeHtml(text).replace(/(https?:\/\/[^\s<]+)/g, function (u) {
|
|
676
|
+
return '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' + u + '</a>';
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ---------------- Markdown 渲染 ----------------
|
|
681
|
+
// 手写的最小实现,不引依赖(这个页面是单文件、免构建的)。
|
|
682
|
+
//
|
|
683
|
+
// 顺序不能换:**先转义,再上标签**。模型吐出来的东西一律当纯文本,
|
|
684
|
+
// 只把它写成的那几种语法变成 HTML——绝不让原文里的标签生效。
|
|
685
|
+
// 认不出来的语法原样留着:宁可少渲染,不能吃掉内容。
|
|
686
|
+
var CODE_SLOT = '\u0000';
|
|
687
|
+
|
|
688
|
+
function mdInline(raw) {
|
|
689
|
+
// 行内代码先挖出来换成占位符。不这么做的话,`**x**` 这种字面量
|
|
690
|
+
// 会被后面的粗体规则吃掉,代码里写的东西就显示错了。
|
|
691
|
+
var codes = [];
|
|
692
|
+
var out = escapeHtml(raw).replace(/`([^`]+)`/g, function (_, code) {
|
|
693
|
+
codes.push(code);
|
|
694
|
+
return CODE_SLOT + (codes.length - 1) + CODE_SLOT;
|
|
695
|
+
});
|
|
696
|
+
out = out
|
|
697
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
698
|
+
.replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>')
|
|
699
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
|
700
|
+
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
|
701
|
+
.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g,
|
|
702
|
+
'$1<a href="$2" target="_blank" rel="noopener noreferrer">$2</a>');
|
|
703
|
+
return out.replace(new RegExp(CODE_SLOT + '(\\d+)' + CODE_SLOT, 'g'), function (_, i) {
|
|
704
|
+
return '<code>' + codes[+i] + '</code>';
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function codeBlock(raw, lang) {
|
|
709
|
+
// data-copy 里放转义后的原文,读回来时浏览器会自动还原成原始字符。
|
|
710
|
+
var attr = escapeHtml(raw);
|
|
711
|
+
return '<div class="code"><div class="code-head">'
|
|
712
|
+
+ (lang ? '<span class="lang">' + escapeHtml(lang) + '</span>' : '<span></span>')
|
|
713
|
+
+ '<button class="copy" type="button" aria-label="复制代码" title="复制代码"'
|
|
714
|
+
+ ' data-copy="' + attr + '">' + ICON_COPY + '</button></div>'
|
|
715
|
+
+ '<pre><code>' + attr + '</code></pre></div>';
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function isBlockStart(line) {
|
|
719
|
+
return /^\s*```/.test(line)
|
|
720
|
+
|| /^\s*#{1,6}\s/.test(line)
|
|
721
|
+
|| /^\s*[-*+]\s/.test(line)
|
|
722
|
+
|| /^\s*\d+[.)]\s/.test(line)
|
|
723
|
+
|| /^\s*>/.test(line)
|
|
724
|
+
|| /^\s*\|/.test(line)
|
|
725
|
+
|| /^\s*([-*_])\s*\1\s*\1[\s\1]*$/.test(line);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function mdToHtml(src) {
|
|
729
|
+
var lines = String(src).split('\n');
|
|
730
|
+
var html = [];
|
|
731
|
+
var i = 0;
|
|
732
|
+
while (i < lines.length) {
|
|
733
|
+
var line = lines[i];
|
|
734
|
+
|
|
735
|
+
// 围栏代码块:内容只转义,不走行内规则
|
|
736
|
+
var fence = /^\s*```\s*([\w+#.-]*)\s*$/.exec(line);
|
|
737
|
+
if (fence) {
|
|
738
|
+
var buf = [];
|
|
739
|
+
i += 1;
|
|
740
|
+
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { buf.push(lines[i]); i += 1; }
|
|
741
|
+
i += 1;
|
|
742
|
+
html.push(codeBlock(buf.join('\n'), fence[1]));
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (!line.trim()) { i += 1; continue; }
|
|
747
|
+
|
|
748
|
+
var h = /^\s*(#{1,6})\s+(.*)$/.exec(line);
|
|
749
|
+
if (h) {
|
|
750
|
+
var n = h[1].length;
|
|
751
|
+
html.push('<h' + n + '>' + mdInline(h[2]) + '</h' + n + '>');
|
|
752
|
+
i += 1;
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (/^\s*([-*_])\s*\1\s*\1[\s\1]*$/.test(line)) { html.push('<hr>'); i += 1; continue; }
|
|
757
|
+
|
|
758
|
+
if (/^\s*[-*+]\s+/.test(line)) {
|
|
759
|
+
var ul = [];
|
|
760
|
+
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
|
|
761
|
+
ul.push('<li>' + mdInline(lines[i].replace(/^\s*[-*+]\s+/, '')) + '</li>');
|
|
762
|
+
i += 1;
|
|
763
|
+
}
|
|
764
|
+
html.push('<ul>' + ul.join('') + '</ul>');
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (/^\s*\d+[.)]\s+/.test(line)) {
|
|
769
|
+
var ol = [];
|
|
770
|
+
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
|
|
771
|
+
ol.push('<li>' + mdInline(lines[i].replace(/^\s*\d+[.)]\s+/, '')) + '</li>');
|
|
772
|
+
i += 1;
|
|
773
|
+
}
|
|
774
|
+
html.push('<ol>' + ol.join('') + '</ol>');
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (/^\s*>/.test(line)) {
|
|
779
|
+
var quote = [];
|
|
780
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) {
|
|
781
|
+
quote.push(lines[i].replace(/^\s*>\s?/, ''));
|
|
782
|
+
i += 1;
|
|
783
|
+
}
|
|
784
|
+
html.push('<blockquote>' + mdToHtml(quote.join('\n')) + '</blockquote>');
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// 表格:当前行是 | … |,下一行是 |---|---| 这种分隔行
|
|
789
|
+
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
|
|
790
|
+
var cells = function (l) {
|
|
791
|
+
return l.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return c.trim(); });
|
|
792
|
+
};
|
|
793
|
+
var head = cells(line);
|
|
794
|
+
i += 2;
|
|
795
|
+
var body = [];
|
|
796
|
+
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { body.push(cells(lines[i])); i += 1; }
|
|
797
|
+
html.push('<table><thead><tr>'
|
|
798
|
+
+ head.map(function (c) { return '<th>' + mdInline(c) + '</th>'; }).join('')
|
|
799
|
+
+ '</tr></thead><tbody>'
|
|
800
|
+
+ body.map(function (r) {
|
|
801
|
+
return '<tr>' + r.map(function (c) { return '<td>' + mdInline(c) + '</td>'; }).join('') + '</tr>';
|
|
802
|
+
}).join('')
|
|
803
|
+
+ '</tbody></table>');
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// 段落:连续的非空、非块起始行合成一段
|
|
808
|
+
var para = [line];
|
|
809
|
+
i += 1;
|
|
810
|
+
while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i])) { para.push(lines[i]); i += 1; }
|
|
811
|
+
html.push('<p>' + para.map(mdInline).join('<br>') + '</p>');
|
|
812
|
+
}
|
|
813
|
+
return html.join('');
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// 复制。navigator.clipboard 要求安全上下文(HTTPS),手机走局域网明文 HTTP 时
|
|
817
|
+
// 它是 undefined——所以必须有 execCommand 这条退路,否则按钮点下去毫无反应。
|
|
818
|
+
function copyText(text, btn) {
|
|
819
|
+
// 点一下换成对勾,过 1.4 秒自己变回来。
|
|
820
|
+
// 连点的时候要 clearTimeout:不然后一个定时器会被前一个提前触发,
|
|
821
|
+
// 图标在对勾和复制之间闪,或者干脆卡在对勾上不回来。
|
|
822
|
+
var done = function () {
|
|
823
|
+
if (!btn) return;
|
|
824
|
+
btn.classList.add('done');
|
|
825
|
+
btn.innerHTML = ICON_DONE;
|
|
826
|
+
clearTimeout(btn._doneTimer);
|
|
827
|
+
btn._doneTimer = setTimeout(function () {
|
|
828
|
+
btn.classList.remove('done');
|
|
829
|
+
btn.innerHTML = ICON_COPY;
|
|
830
|
+
}, 1400);
|
|
831
|
+
};
|
|
832
|
+
var fallback = function () {
|
|
833
|
+
try {
|
|
834
|
+
var ta = document.createElement('textarea');
|
|
835
|
+
ta.value = text;
|
|
836
|
+
ta.setAttribute('readonly', '');
|
|
837
|
+
ta.style.position = 'fixed';
|
|
838
|
+
ta.style.top = '-1000px';
|
|
839
|
+
document.body.appendChild(ta);
|
|
840
|
+
ta.select();
|
|
841
|
+
ta.setSelectionRange(0, ta.value.length);
|
|
842
|
+
var ok = document.execCommand('copy');
|
|
843
|
+
document.body.removeChild(ta);
|
|
844
|
+
if (ok) done(); else toast('复制不了,长按选中文字手动复制');
|
|
845
|
+
} catch (err) {
|
|
846
|
+
toast('复制不了,长按选中文字手动复制');
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
850
|
+
navigator.clipboard.writeText(text).then(done, fallback);
|
|
851
|
+
} else {
|
|
852
|
+
fallback();
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
function timeLabel(ts) {
|
|
856
|
+
if (!ts) return '';
|
|
857
|
+
var d = new Date(ts);
|
|
858
|
+
var p = function (n) { return n < 10 ? '0' + n : String(n); };
|
|
859
|
+
return p(d.getHours()) + ':' + p(d.getMinutes());
|
|
860
|
+
}
|
|
861
|
+
var toastTimer;
|
|
862
|
+
function toast(msg) {
|
|
863
|
+
var el = $('toast');
|
|
864
|
+
el.textContent = msg;
|
|
865
|
+
el.classList.add('show');
|
|
866
|
+
clearTimeout(toastTimer);
|
|
867
|
+
toastTimer = setTimeout(function () { el.classList.remove('show'); }, 1800);
|
|
868
|
+
}
|
|
869
|
+
function authHeaders(isJson) {
|
|
870
|
+
return isJson === false
|
|
871
|
+
? { 'X-Mini-Token': state.token }
|
|
872
|
+
: { 'Content-Type': 'application/json', 'X-Mini-Token': state.token };
|
|
873
|
+
}
|
|
874
|
+
function api(path, options) {
|
|
875
|
+
var opts = options || {};
|
|
876
|
+
// 传文件的时候**不能**写 Content-Type: application/json——那是给 JSON 用的。
|
|
877
|
+
// 硬写上去,服务端收到的就是一个错的文件类型声明,而浏览器也没机会自己定。
|
|
878
|
+
var raw = typeof Blob !== 'undefined' && (opts.body instanceof Blob
|
|
879
|
+
|| (typeof FormData !== 'undefined' && opts.body instanceof FormData));
|
|
880
|
+
opts.headers = Object.assign(authHeaders(!raw), opts.headers || {});
|
|
881
|
+
return fetch(path, opts).then(function (r) {
|
|
882
|
+
return r.json().catch(function () { return {}; }).then(function (body) {
|
|
883
|
+
if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status));
|
|
884
|
+
return body;
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ---------------- 声音与震动 ----------------
|
|
890
|
+
var audioCtx = null;
|
|
891
|
+
function unlockAudio() {
|
|
892
|
+
try {
|
|
893
|
+
if (!audioCtx) {
|
|
894
|
+
var Ctx = window.AudioContext || window.webkitAudioContext;
|
|
895
|
+
if (!Ctx) return;
|
|
896
|
+
audioCtx = new Ctx();
|
|
897
|
+
}
|
|
898
|
+
if (audioCtx.state === 'suspended') audioCtx.resume();
|
|
899
|
+
// 播放一个静音脉冲,iOS 要求真正出过声才算解锁。
|
|
900
|
+
var o = audioCtx.createOscillator(), g = audioCtx.createGain();
|
|
901
|
+
g.gain.value = 0;
|
|
902
|
+
o.connect(g); g.connect(audioCtx.destination);
|
|
903
|
+
o.start(); o.stop(audioCtx.currentTime + 0.01);
|
|
904
|
+
} catch (e) { /* 不支持就算了,不影响主流程 */ }
|
|
905
|
+
}
|
|
906
|
+
function playDing() {
|
|
907
|
+
if (!state.sound || !audioCtx || audioCtx.state !== 'running') return;
|
|
908
|
+
try {
|
|
909
|
+
var t = audioCtx.currentTime;
|
|
910
|
+
var o = audioCtx.createOscillator(), g = audioCtx.createGain();
|
|
911
|
+
o.type = 'sine';
|
|
912
|
+
o.frequency.setValueAtTime(880, t);
|
|
913
|
+
o.frequency.setValueAtTime(1180, t + 0.11);
|
|
914
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
915
|
+
g.gain.exponentialRampToValueAtTime(0.3, t + 0.01);
|
|
916
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.34);
|
|
917
|
+
o.connect(g); g.connect(audioCtx.destination);
|
|
918
|
+
o.start(t); o.stop(t + 0.36);
|
|
919
|
+
} catch (e) { /* 忽略 */ }
|
|
920
|
+
}
|
|
921
|
+
function buzz() {
|
|
922
|
+
if (state.sound && navigator.vibrate) navigator.vibrate([200, 100, 200]);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ---------------- 「正在执行」的鲸鱼娘 ----------------
|
|
926
|
+
// 这些姿态是**装饰性**的:插件按设计只收「用户指令 + 最终回复」,拿不到 Agent
|
|
927
|
+
// 此刻在干什么,所以轮播不是进度播报,只是让等待有点人味。
|
|
928
|
+
// 气泡里的话也守着这条线——说的是「还在忙」,不谎报「已完成 60%」。
|
|
929
|
+
//
|
|
930
|
+
// **还有一条更硬的规矩:不许报忧。** 用户正在等结果,这时候冒出「出错了」
|
|
931
|
+
// 「不对劲」之类的话,他会当真。可这些话跟 Agent 实际状态毫无关系,纯粹是
|
|
932
|
+
// 轮到第几张图。原来第 4 条写的「这里有点不对劲」就是这么个东西,2026-09-22
|
|
933
|
+
// 被用户指着问「这一句有问题还是什么」,已改掉。测试钉着这条:见 page.test.mjs
|
|
934
|
+
// 的「鲸鱼娘说的话里不许出现让人以为出事的字眼」。
|
|
935
|
+
//
|
|
936
|
+
// 每个姿势是一张「两帧并排」的雪碧图,flip 是翻帧的间隔(秒):
|
|
937
|
+
// 敲键盘和小跑要快,端着茶等和托腮想事情要慢。节奏跟着动作走,才不像机器在闪。
|
|
938
|
+
var POSES = [
|
|
939
|
+
{ file: 'work-1-ready', line: '收到,这就去办', flip: 0.5 },
|
|
940
|
+
{ file: 'work-2-reading', line: '先翻翻资料', flip: 1.5 },
|
|
941
|
+
{ file: 'work-3-typing', line: '正在写…', flip: 0.34 },
|
|
942
|
+
// 这一句原来是「这里有点不对劲」。**那是错的,而且是有害的**:轮播是装饰性的,
|
|
943
|
+
// 轮到哪张图跟 Agent 实际在干什么毫无关系,可用户正在等结果,看到手机上写
|
|
944
|
+
// 「不对劲」只会以为出事了。用户 2026-09-22 实机指着这条问「这一句有问题还是
|
|
945
|
+
// 什么」,并指定改成「让我检查检查」——姿态叫 checking(核对),这句正好相称:
|
|
946
|
+
// 只说自己在看,不下判断。
|
|
947
|
+
{ file: 'work-4-checking', line: '让我检查检查', flip: 1.2 },
|
|
948
|
+
{ file: 'work-5-thinking', line: '嗯…让我想想', flip: 1.7 },
|
|
949
|
+
{ file: 'work-6-running', line: '跑一遍看看', flip: 0.4 },
|
|
950
|
+
// 用户 2026-09-22 要的「跑步的动作」。它**不是第 8 个姿势,而是姿势之间的过渡**:
|
|
951
|
+
// 序列是「姿势1 → 冲刺 → 姿势2 → 冲刺 → …」,表达「她干完一件、跑去干下一件」。
|
|
952
|
+
// 所以它带 gap: true——不进轮播循环,只在每个姿势后面闪一下。
|
|
953
|
+
// **停留时长改过一次方向**:原本刻意比姿势短(怕它「成了又一个姿势」),
|
|
954
|
+
// 但用户两次要求拉长,现在 GAP_MS 和 POSE_MS 一样是 3.6 秒。理由见 GAP_MS 的注释。
|
|
955
|
+
// 它和上面那张小跑**刻意画得不一样**:侧面视角、身体前倾、步子拉得更开。
|
|
956
|
+
// **不带词条**(line 是空串)——用户指定的,冲刺是纯动作,硬配一句话反而弱。
|
|
957
|
+
// 文件名里的 8 只是个名字,**顺序由数组决定**,别拿它当序号。
|
|
958
|
+
{ file: 'work-8-sprinting', line: '', flip: 0.28, gap: true },
|
|
959
|
+
// 最后一个只在任务真的跑够久了才进轮播——到那时候说这句话才是实话。
|
|
960
|
+
// **它必须留在最后**:posePool 用的是 POSES.length - 1,靠位置认它。
|
|
961
|
+
{ file: 'work-7-waiting', line: '还在忙,再等我一会儿', flip: 2.0 }
|
|
962
|
+
];
|
|
963
|
+
var POSE_MS = 3600;
|
|
964
|
+
/** 冲刺那张停多久。
|
|
965
|
+
*
|
|
966
|
+
* 用户同一天提了两次,方向一致:2026-09-22 上午「间隙跑步动画的持续时间太短了」,
|
|
967
|
+
* 从 1.4 秒拉到 2.4 秒;同日下午又说「出现时间可以再久一些」,再拉到 3.6 秒,
|
|
968
|
+
* **和一个正常姿势一样长**。
|
|
969
|
+
*
|
|
970
|
+
* 原先这里写着「过渡就该快,跟姿势一样长的话它就成了又一个姿势」——那个顾虑
|
|
971
|
+
* 被用户两次要求推翻了:他要的就是看清这个动作,而 2.4 秒(约 8 个跑步循环)
|
|
972
|
+
* 看着还是「一闪而过」。既然他愿意为它多等,就别再按「过渡」对待它。
|
|
973
|
+
* 代价是整圈轮播从 48 秒变 57.6 秒,「还在忙」那张(SLOW_MS = 60 秒)会在
|
|
974
|
+
* 第二圈开头才轮得到——比原来晚十几秒,可接受。
|
|
975
|
+
*
|
|
976
|
+
* 再要调就调这一个数,别再动 flip:那是跑步循环的快慢,跟停留时长是两回事。 */
|
|
977
|
+
var GAP_MS = 3600;
|
|
978
|
+
var SLOW_MS = 60000;
|
|
979
|
+
|
|
980
|
+
var reducedMotion = false;
|
|
981
|
+
try { reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
|
|
982
|
+
|
|
983
|
+
var work = { startedAt: 0, pose: 0, poseTimer: null, tickTimer: null, layers: [] };
|
|
984
|
+
|
|
985
|
+
/** 现在该用哪几条姿态:跑够一分钟才把「还在忙」放进来。 */
|
|
986
|
+
function posePool() {
|
|
987
|
+
return Date.now() - work.startedAt >= SLOW_MS ? POSES.length : POSES.length - 1;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* 轮播的完整顺序:**每个姿势之间插一次冲刺**。
|
|
992
|
+
*
|
|
993
|
+
* 姿势1 → 冲刺 → 姿势2 → 冲刺 → 姿势3 → …
|
|
994
|
+
*
|
|
995
|
+
* 用户 2026-09-22 要的效果是「她在干活」:一个姿势带着词条出场,
|
|
996
|
+
* 然后跑动的她横穿过去,再出现下一个姿势。所以冲刺(带 gap 的那些)
|
|
997
|
+
* 自己不进这个循环——它只在间隙里出现,而且每次姿势后面都跟一次。
|
|
998
|
+
*
|
|
999
|
+
* 返回的是 POSES 的下标序列,长度随「跑了多久」变(前 60 秒不含「还在忙」)。
|
|
1000
|
+
*/
|
|
1001
|
+
function poseSequence() {
|
|
1002
|
+
var n = posePool();
|
|
1003
|
+
var gaps = [];
|
|
1004
|
+
for (var g = 0; g < POSES.length; g += 1) {
|
|
1005
|
+
if (POSES[g].gap) gaps.push(g);
|
|
1006
|
+
}
|
|
1007
|
+
var out = [];
|
|
1008
|
+
for (var i = 0; i < n; i += 1) {
|
|
1009
|
+
if (POSES[i].gap) continue;
|
|
1010
|
+
out.push(i);
|
|
1011
|
+
for (var k = 0; k < gaps.length; k += 1) out.push(gaps[k]);
|
|
1012
|
+
}
|
|
1013
|
+
return out;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** 立绘地址。走 query 而不是靠 cookie:从门禁页手输 token 进来时是没有 cookie 的。 */
|
|
1017
|
+
function artUrl(file) {
|
|
1018
|
+
return '/mini/art/' + file + '.webp?token=' + encodeURIComponent(state.token)
|
|
1019
|
+
+ '&v=' + encodeURIComponent(BUILD);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function paintPose(index) {
|
|
1023
|
+
for (var i = 0; i < work.layers.length; i += 1) {
|
|
1024
|
+
work.layers[i].classList.toggle('on', i === index);
|
|
1025
|
+
}
|
|
1026
|
+
// 有的姿势不带词条(比如那张冲刺):词条是空串就把整个气泡收起来。
|
|
1027
|
+
// 留一个空泡泡在那儿比不放气泡更怪——那个尖角还指着她的头,像话没说出来。
|
|
1028
|
+
// 收起气泡不会让立绘挪位:.work-top 有 min-width 兜着,行宽不变。
|
|
1029
|
+
var line = POSES[index].line || '';
|
|
1030
|
+
$('workBubble').hidden = !line;
|
|
1031
|
+
$('workBubble').textContent = line;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function elapsedLabel(ms) {
|
|
1035
|
+
var s = Math.max(0, Math.floor(ms / 1000));
|
|
1036
|
+
if (s < 60) return '已 ' + s + ' 秒';
|
|
1037
|
+
return '已 ' + Math.floor(s / 60) + ' 分 ' + (s % 60) + ' 秒';
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function tickElapsed() {
|
|
1041
|
+
$('workElapsed').textContent = elapsedLabel(Date.now() - work.startedAt);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** 立绘在页面一打开就全部加载好——不然第一次「正在执行」会闪一下空白。 */
|
|
1045
|
+
function preloadPoses() {
|
|
1046
|
+
var stage = $('workStage');
|
|
1047
|
+
stage.innerHTML = '';
|
|
1048
|
+
work.layers = [];
|
|
1049
|
+
for (var i = 0; i < POSES.length; i += 1) {
|
|
1050
|
+
var layer = document.createElement('div');
|
|
1051
|
+
layer.className = 'pose';
|
|
1052
|
+
layer.style.backgroundImage = 'url("' + artUrl(POSES[i].file) + '")';
|
|
1053
|
+
layer.style.animationDuration = POSES[i].flip + 's';
|
|
1054
|
+
stage.appendChild(layer);
|
|
1055
|
+
work.layers.push(layer);
|
|
1056
|
+
// #work 是 hidden(display:none),浏览器**不会**为它下载背景图。
|
|
1057
|
+
// 所以这里得拿一个游离的 <img> 主动拉一次,否则第一次「正在执行」会先闪空白。
|
|
1058
|
+
var warm = document.createElement('img');
|
|
1059
|
+
warm.src = artUrl(POSES[i].file);
|
|
1060
|
+
}
|
|
1061
|
+
paintPose(0);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* 轮播往前走一步,然后按**这一步是什么**决定下次隔多久。
|
|
1066
|
+
*
|
|
1067
|
+
* 原来是一个固定的 setInterval,每 3.6 秒换一张。现在冲刺要停得短,
|
|
1068
|
+
* 固定间隔就做不到——所以改成自己排下一次的 setTimeout。
|
|
1069
|
+
* 停止时 stopWork 会把待排的那一次清掉,不会漏跑。
|
|
1070
|
+
*/
|
|
1071
|
+
function nextPose() {
|
|
1072
|
+
var seq = poseSequence();
|
|
1073
|
+
work.pose = (work.pose + 1) % seq.length;
|
|
1074
|
+
var index = seq[work.pose];
|
|
1075
|
+
paintPose(index);
|
|
1076
|
+
work.poseTimer = setTimeout(nextPose, POSES[index].gap ? GAP_MS : POSE_MS);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function startWork() {
|
|
1080
|
+
// 已经在跑就别重来。状态广播来得很密,每次重置的话姿态会永远卡在第一张。
|
|
1081
|
+
if (work.startedAt) return;
|
|
1082
|
+
work.startedAt = Date.now();
|
|
1083
|
+
work.pose = 0;
|
|
1084
|
+
paintPose(0);
|
|
1085
|
+
tickElapsed();
|
|
1086
|
+
work.tickTimer = setInterval(tickElapsed, 1000);
|
|
1087
|
+
if (reducedMotion) return; // 关掉动画的人就停在一张上,秒数照常走
|
|
1088
|
+
work.poseTimer = setTimeout(nextPose, POSE_MS);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** 停止请求的兜底计时器。放在 stopWork 前面,别靠 var 提升兜着。 */
|
|
1092
|
+
var stopTimer = null;
|
|
1093
|
+
|
|
1094
|
+
function stopWork() {
|
|
1095
|
+
work.startedAt = 0;
|
|
1096
|
+
// 轮播现在是自己排下一次的 setTimeout(见 nextPose),用 clearInterval 清
|
|
1097
|
+
// setTimeout 在浏览器里也行,但写 clearTimeout 更不容易让人误会。
|
|
1098
|
+
if (work.poseTimer) { clearTimeout(work.poseTimer); work.poseTimer = null; }
|
|
1099
|
+
if (work.tickTimer) { clearInterval(work.tickTimer); work.tickTimer = null; }
|
|
1100
|
+
// 跑完了就把按钮复位,下一次「正在执行」才是干干净净的一个「停止」。
|
|
1101
|
+
if (stopTimer) { clearTimeout(stopTimer); stopTimer = null; }
|
|
1102
|
+
paintStop(false);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// ---------------- 停止 ----------------
|
|
1106
|
+
//
|
|
1107
|
+
// cancel() 在服务端只是**递个请求**,不是「等它停」——真正停下要等驱动收敛,
|
|
1108
|
+
// 到那时候服务端才把 running 翻成 false 推过来。所以按下去之后按钮自己禁用、
|
|
1109
|
+
// 改文案,等那个状态翻:既让人看见「按到了」,也防连按好几下。
|
|
1110
|
+
function paintStop(stopping) {
|
|
1111
|
+
var b = $('btnStop');
|
|
1112
|
+
b.disabled = stopping;
|
|
1113
|
+
b.textContent = stopping ? '正在停…' : '停止';
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function requestStop() {
|
|
1117
|
+
var b = $('btnStop');
|
|
1118
|
+
if (!state.running || b.disabled) return;
|
|
1119
|
+
paintStop(true);
|
|
1120
|
+
api('/mini/api/stop', { method: 'POST', body: '{}' })
|
|
1121
|
+
.then(function () {
|
|
1122
|
+
// 正常情况:几百毫秒内 status 事件就到了,stopWork() 会把按钮复位。
|
|
1123
|
+
// 但万一那一刻连接正好断了,就会永远卡在「正在停…」——留个兜底,
|
|
1124
|
+
// 十秒还没翻就把按钮放回去,让人能再按一次,而不是干瞪眼。
|
|
1125
|
+
stopTimer = setTimeout(function () {
|
|
1126
|
+
stopTimer = null;
|
|
1127
|
+
if (state.running) {
|
|
1128
|
+
paintStop(false);
|
|
1129
|
+
toast('还没停下来。可以再按一次,或者到电脑上看看。');
|
|
1130
|
+
}
|
|
1131
|
+
}, 10000);
|
|
1132
|
+
})
|
|
1133
|
+
.catch(function (err) {
|
|
1134
|
+
paintStop(false);
|
|
1135
|
+
toast(err.message || '停止失败');
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
$('btnStop').addEventListener('click', requestStop);
|
|
1140
|
+
|
|
1141
|
+
// ---------------- 排队中的指令 ----------------
|
|
1142
|
+
//
|
|
1143
|
+
// 跑着任务时接着发的那几条不会丢,而是排进 agent 的收件箱(`followup()` 的语义
|
|
1144
|
+
// 就是「排队一个后续轮次」),等当前这一轮结束再跑。既然收下了,就必须让用户看见
|
|
1145
|
+
// 它排在哪——尤其单帧模式下,你发出去的那句话在别处**一个字都看不见**
|
|
1146
|
+
// (那一模式只显示回复),没有这块就等于「发出去了没反应」。
|
|
1147
|
+
//
|
|
1148
|
+
// 队列每次由服务端现读 agent 的收件箱,手机不自己记一份,所以不存在两边对不上。
|
|
1149
|
+
function paintQueue() {
|
|
1150
|
+
var el = $('queue');
|
|
1151
|
+
var list = state.queued || [];
|
|
1152
|
+
if (!list.length) { el.hidden = true; el.innerHTML = ''; return; }
|
|
1153
|
+
el.hidden = false;
|
|
1154
|
+
var rows = '';
|
|
1155
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
1156
|
+
rows += '<div class="q-row">'
|
|
1157
|
+
+ '<span class="q-text">' + escapeHtml(list[i].text) + '</span>'
|
|
1158
|
+
+ '<button class="q-drop" type="button" data-drop="' + escapeHtml(list[i].id) + '"'
|
|
1159
|
+
+ ' aria-label="撤掉这条">✕</button></div>';
|
|
1160
|
+
}
|
|
1161
|
+
el.innerHTML = '<div class="q-head">排队中 ' + list.length + ' 条 · 跑完这轮就轮到它们</div>' + rows;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* 撤掉一条还在排队的指令。
|
|
1166
|
+
*
|
|
1167
|
+
* 撤不掉是正常情况之一:手机上这份队列是上一次推送时的样子,这中间它完全可能
|
|
1168
|
+
* 已经轮到自己开始跑了。那时候服务端会说「撤不回来了」,这里如实转达,
|
|
1169
|
+
* 并且刷新一次,让界面跟上真实状态——不能假装撤掉了。
|
|
1170
|
+
*/
|
|
1171
|
+
function requestUnqueue(id) {
|
|
1172
|
+
// 成功失败都由服务端推一份最新的队列过来(失败时也要推——见 onUnqueue 的说明),
|
|
1173
|
+
// 所以这里不用自己去拉,只需要把失败原因转达给用户。
|
|
1174
|
+
api('/mini/api/unqueue', { method: 'POST', body: JSON.stringify({ id: id }) })
|
|
1175
|
+
.catch(function (err) { toast(err.message || '撤不回来'); });
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
$('queue').addEventListener('click', function (e) {
|
|
1179
|
+
var t = e.target;
|
|
1180
|
+
var id = t && t.getAttribute ? t.getAttribute('data-drop') : null;
|
|
1181
|
+
if (id) requestUnqueue(id);
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
/** 让「正在执行」那块跟着 state.running 显隐。 */
|
|
1185
|
+
function paintWork() {
|
|
1186
|
+
var el = $('work');
|
|
1187
|
+
var queued = (state.queued || []).length;
|
|
1188
|
+
// 队列也算「还有活儿」。正常情况到不了「闲着但队列非空」(followup 总会唤醒驱动),
|
|
1189
|
+
// 但停掉当前这一轮之后、队列还没接上的那一小段就是这种状态——
|
|
1190
|
+
// 那块东西不该先消失一下再冒出来。
|
|
1191
|
+
//
|
|
1192
|
+
// 但**正在流式写回答的时候要收起来**:回答本身已经在屏幕上长出来了,
|
|
1193
|
+
// 底下再挂一只鲸鱼娘就是重复——用户要看的字正在往上冒,那块既占地方又抢注意力。
|
|
1194
|
+
// 用户 2026-09-22 实机提的:「内容开始流式生成的时候,鲸鱼娘的动画就可以消失了」。
|
|
1195
|
+
// 注意这里用 state.live 判断,不是「有没有在跑」——跑着但还没到吐字那一步
|
|
1196
|
+
// (比如正在调工具)时,鲸鱼娘还是要留着,那是它唯一该出现的时候。
|
|
1197
|
+
var busy = (state.running || queued > 0) && !state.live;
|
|
1198
|
+
el.hidden = !busy;
|
|
1199
|
+
if (busy) startWork(); else stopWork();
|
|
1200
|
+
paintQueue();
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// ---------------- 渲染 ----------------
|
|
1204
|
+
function setStatus(kind, text) {
|
|
1205
|
+
dotEl.className = 'dot ' + kind;
|
|
1206
|
+
statusEl.textContent = text;
|
|
1207
|
+
}
|
|
1208
|
+
function updateStatusUI() {
|
|
1209
|
+
if (!state.connected) { setStatus('err', '未连接'); return; }
|
|
1210
|
+
if (state.running) { setStatus('run', '运行中…'); return; }
|
|
1211
|
+
setStatus('ok', '已连接');
|
|
1212
|
+
}
|
|
1213
|
+
function setRunning(running) {
|
|
1214
|
+
state.running = !!running;
|
|
1215
|
+
// 这里原来会把输入框和发送按钮一起禁用。现在不禁了:跑着的时候**可以接着发**,
|
|
1216
|
+
// 那条会排队(见 send 的说明)。禁掉输入框等于把「排队」这条路从界面上封死,
|
|
1217
|
+
// 而 DSH 本身在跑着的时候是收的。
|
|
1218
|
+
updateStatusUI();
|
|
1219
|
+
// 放这里而不是 render() 里:SSE 的 status 事件只调 setRunning、不重绘,
|
|
1220
|
+
// 漏了这一处的话「运行中」变了而鲸鱼娘不出现。
|
|
1221
|
+
// 另外它也是**唯一**会调 paintWork 的地方,而 paintWork 还要看 state.live
|
|
1222
|
+
// (流式一露头就把鲸鱼娘收起来)。快照每次都带 running,所以 applySnapshot
|
|
1223
|
+
// 会走到这儿、live 的变化也就跟着生效了——别把这个调用挪走。
|
|
1224
|
+
paintWork();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function renderMinimal() {
|
|
1228
|
+
mainEl.classList.remove('center');
|
|
1229
|
+
var latest = state.latest;
|
|
1230
|
+
var live = state.live || '';
|
|
1231
|
+
var parts = [];
|
|
1232
|
+
// 「正在执行」不拼进这块 HTML——它是常驻的 #work,由 paintWork() 控制显隐。
|
|
1233
|
+
// 但它仍然显示在回答**下方**,不顶掉回答:原来它排在最前面并且直接 return,
|
|
1234
|
+
// 于是刚发完指令,上一步的结果就整块消失,用户看不到自己上一步得到了什么。
|
|
1235
|
+
if (latest && latest.text) {
|
|
1236
|
+
// 「已停止」留在正文**上方**:它是给正文打的预防针(你收到的只是半句),
|
|
1237
|
+
// 得在读到正文之前看到。
|
|
1238
|
+
// 复制按钮则挪到正文**下方、靠左**——原来在上面靠右,手机上拇指够不着,
|
|
1239
|
+
// 而且会比正文先入眼。
|
|
1240
|
+
var stopped = latest.interrupted ? '<span class="stopped">已停止</span>' : '';
|
|
1241
|
+
parts.push((stopped ? '<div class="reply-bar">' + stopped + '</div>' : '')
|
|
1242
|
+
+ '<div class="md">' + mdToHtml(latest.text) + '</div>'
|
|
1243
|
+
+ '<div class="reply-foot">'
|
|
1244
|
+
+ '<button class="copy" type="button" aria-label="复制全文" title="复制全文"'
|
|
1245
|
+
+ ' data-copy="' + escapeHtml(latest.text) + '">' + ICON_COPY + '</button>'
|
|
1246
|
+
+ '</div>');
|
|
1247
|
+
}
|
|
1248
|
+
if (live) parts.push(liveBlock(live));
|
|
1249
|
+
if (parts.length) {
|
|
1250
|
+
var stick = atBottom();
|
|
1251
|
+
replyEl.innerHTML = parts.join('');
|
|
1252
|
+
// 流式的时候得跟着往下滚,不然新写出来的内容在屏幕外面,看起来像卡住了。
|
|
1253
|
+
// 但只在用户本来就在底部时才跟——他翻上去看历史的时候别拽他回来。
|
|
1254
|
+
if (live && stick) mainEl.scrollTop = mainEl.scrollHeight;
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
if (state.running) {
|
|
1258
|
+
replyEl.innerHTML = '';
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
replyEl.innerHTML = '<div class="placeholder">在下面输入一条指令,'
|
|
1262
|
+
+ '这里只会显示 AI 的最终回复。<br>中间的工具调用和执行过程不会传过来。</div>';
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* 正在流出来的那一段。
|
|
1267
|
+
*
|
|
1268
|
+
* **刻意和答案画得不一样**:上面一条虚线,末尾一个一闪一闪的竖条,而且**没有
|
|
1269
|
+
* 复制按钮**。这三样都在说同一句话——「这不是最终答案,还会变」。等真正的答案
|
|
1270
|
+
* 落定,这一整块会被换掉。
|
|
1271
|
+
*
|
|
1272
|
+
* 为什么要区分得这么清楚:流式阶段拿不到完整内容,没法提前知道模型这一步会不会
|
|
1273
|
+
* 去调工具。服务端已经先用一小段「按兵不动」把绝大多数中间旁白挡在外面了
|
|
1274
|
+
* (见 lib/index.js 的 STREAM_GRACE_MS),但那是启发式、不是判据。万一漏过来
|
|
1275
|
+
* 一句旁白,它会被撤回——那时候用户看到的应该是「刚才那段是过程,不作数」,
|
|
1276
|
+
* 而不是「我的回答怎么没了」。
|
|
1277
|
+
*/
|
|
1278
|
+
function liveBlock(text) {
|
|
1279
|
+
return '<div class="live"><div class="md">' + mdToHtml(text) + '</div></div>';
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* 该不该自动滚到底。
|
|
1284
|
+
*
|
|
1285
|
+
* 用户 2026-09-22 实机提的:「有动画的时候手机端页面运动到上面都会自动调回底部,
|
|
1286
|
+
* 这种机制没必要吧。」原来每次重画都无条件滚到底,而鲸鱼娘一帧一动、流式一秒七次,
|
|
1287
|
+
* 于是想往上翻根本翻不动——手一松就被拽回去。
|
|
1288
|
+
*
|
|
1289
|
+
* 改成只在**本来就在底部**的时候才跟着滚:新内容进来时视野跟着走,
|
|
1290
|
+
* 用户自己翻上去看历史时就不打扰他。
|
|
1291
|
+
*
|
|
1292
|
+
* 注意:这个判断必须在改 innerHTML **之前**做。改写内容本身会把滚动位置顶回去,
|
|
1293
|
+
* 改完再问「在不在底部」永远是「在」。
|
|
1294
|
+
*/
|
|
1295
|
+
function atBottom() {
|
|
1296
|
+
// 48px 是「差不多到底了」的容差。手指滚动的惯性、地址栏收起都会差几个像素,
|
|
1297
|
+
// 卡得太死会变成「我明明就在底部,它却不跟着走了」。
|
|
1298
|
+
return mainEl.scrollHeight - mainEl.scrollTop - (mainEl.clientHeight || 0) < 48;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function renderChat() {
|
|
1302
|
+
mainEl.classList.remove('center');
|
|
1303
|
+
// **还在排队的那条不算「已发出」,这里要把它从聊天记录里滤掉。**
|
|
1304
|
+
//
|
|
1305
|
+
// 用户 2026-09-22 实机提的:「排队的指令在轮到之前不该出现在指令气泡里,
|
|
1306
|
+
// 就像发出去了一样,应该轮到再发出去。」
|
|
1307
|
+
//
|
|
1308
|
+
// 光在发送时不记账还不够。DSH 的一条排队消息可能被**领走一步、那一步又被驳回、
|
|
1309
|
+
// 于是退回队列**(dsh-agent 的原话:preserve queued and steering inbox items
|
|
1310
|
+
// instead of discarding them)。退回去的时候,早先那条「它开始跑了」的事件已经
|
|
1311
|
+
// 发过,聊天记录就此留下一笔——而队列里也还有它。实测抓到过同一个 id 两处都在。
|
|
1312
|
+
//
|
|
1313
|
+
// 所以判据不能是事件,得是**队列本身**:队列里有它,它就还没轮到。
|
|
1314
|
+
// 等它真跑起来、从队列里消失,这一笔自然就露出来了。
|
|
1315
|
+
var queuedIds = {};
|
|
1316
|
+
(state.queued || []).forEach(function (q) { if (q && q.id) queuedIds[q.id] = true; });
|
|
1317
|
+
var list = (state.history || []).filter(function (m) {
|
|
1318
|
+
return !(m.role === 'user' && m.id && queuedIds[m.id]);
|
|
1319
|
+
});
|
|
1320
|
+
if (!list.length && !state.running && !state.live && !(state.queued || []).length) {
|
|
1321
|
+
replyEl.innerHTML = '<div class="placeholder">还没有对话记录。</div>';
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
var html = list.map(function (m) {
|
|
1325
|
+
// 被按停的那一轮:标一句,别让人以为模型答到一半断了。
|
|
1326
|
+
var stopped = m.interrupted ? '<span class="stopped">已停止</span>' : '';
|
|
1327
|
+
// 两种消息都给复制按钮。自己敲的指令也常有「再发一遍」「拿去别处用」的时候,
|
|
1328
|
+
// 原来只给 AI 气泡,用户反馈自己的话复制不了。
|
|
1329
|
+
var copyBtn = '<button class="copy" type="button" aria-label="复制" title="复制"'
|
|
1330
|
+
+ ' data-copy="' + escapeHtml(m.text) + '">' + ICON_COPY + '</button>';
|
|
1331
|
+
if (m.role === 'user') {
|
|
1332
|
+
// 自己的指令:仍然是气泡,靠右。自己敲的是纯文本,不走 markdown——
|
|
1333
|
+
// 不然打几个星号就变样了。
|
|
1334
|
+
return '<div class="bubble user">' + linkify(m.text)
|
|
1335
|
+
+ '<span class="meta">' + stopped + timeLabel(m.timestamp) + copyBtn + '</span></div>';
|
|
1336
|
+
}
|
|
1337
|
+
// AI 的回答:**不套气泡**(用户要求「参考单帧模式那样」)。
|
|
1338
|
+
// 于是一左一右、一个有框一个没框,正好分得清谁说的。
|
|
1339
|
+
return '<div class="said">'
|
|
1340
|
+
+ (stopped ? '<div class="reply-bar">' + stopped + '</div>' : '')
|
|
1341
|
+
+ '<div class="md">' + mdToHtml(m.text) + '</div>'
|
|
1342
|
+
+ '<div class="reply-foot">' + copyBtn
|
|
1343
|
+
+ '<span class="time">' + timeLabel(m.timestamp) + '</span></div></div>';
|
|
1344
|
+
}).join('');
|
|
1345
|
+
// 「正在执行」不再追加一个气泡——它是常驻的 #work,排在整串消息下面。
|
|
1346
|
+
// 两种模式共用同一块,改的时候两边都走一遍。
|
|
1347
|
+
if (state.live) html += liveBlock(state.live);
|
|
1348
|
+
// 先问「在不在底部」,再改内容——顺序反了永远是「在」(见 atBottom 的注释)。
|
|
1349
|
+
var stick = atBottom();
|
|
1350
|
+
replyEl.innerHTML = html;
|
|
1351
|
+
if (stick) mainEl.scrollTop = mainEl.scrollHeight;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function render() {
|
|
1355
|
+
if (state.mode === 'chat') renderChat(); else renderMinimal();
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function applySnapshot(snap) {
|
|
1359
|
+
if (!snap) return;
|
|
1360
|
+
if ('latest' in snap) state.latest = snap.latest;
|
|
1361
|
+
// 正在流出来的那一段。服务端只在自己决定要显示之后才写它,所以这里是空字符串
|
|
1362
|
+
// 不代表模型没在写,只代表「还不到显示的时候」。
|
|
1363
|
+
if ('live' in snap) state.live = snap.live || '';
|
|
1364
|
+
if (snap.history) state.history = snap.history;
|
|
1365
|
+
if (snap.sessions) state.sessions = snap.sessions;
|
|
1366
|
+
if (Array.isArray(snap.queued)) state.queued = snap.queued;
|
|
1367
|
+
if ('boundSessionId' in snap) state.boundSessionId = snap.boundSessionId;
|
|
1368
|
+
// running 也在快照里,而且算的是**当前绑定的那个会话**。不接的话,
|
|
1369
|
+
// 在导航栏切到别的会话时,那个会话会继承上一个会话的「正在执行」。
|
|
1370
|
+
if ('running' in snap) setRunning(snap.running);
|
|
1371
|
+
paintNavCurrent();
|
|
1372
|
+
render();
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// ---------------- 左侧导航栏 ----------------
|
|
1376
|
+
// 数据分两层取:先列工作区(便宜),展开某个再去要它的会话(要读日志折标题,贵)。
|
|
1377
|
+
// 本机实测 35 个工作区、最多的一个有 452 个会话,一次全拉会卡住手机。
|
|
1378
|
+
var navList = null; // 工作区列表,开一次取一次
|
|
1379
|
+
var navSessions = {}; // workspaceId -> { sessions, total, truncated }
|
|
1380
|
+
var navExpanded = {}; // workspaceId -> 是否展开
|
|
1381
|
+
|
|
1382
|
+
function openNav() {
|
|
1383
|
+
$('nav').classList.add('open');
|
|
1384
|
+
loadWorkspaces(false);
|
|
1385
|
+
}
|
|
1386
|
+
function closeNav() { $('nav').classList.remove('open'); }
|
|
1387
|
+
|
|
1388
|
+
function loadWorkspaces(force) {
|
|
1389
|
+
if (navList && !force) { renderNav(); return; }
|
|
1390
|
+
$('navBody').innerHTML = '<div class="placeholder">正在读取…</div>';
|
|
1391
|
+
// 带 refresh=1 是给 ⟳ 用的:绕过服务端的短时缓存,真去读一遍。
|
|
1392
|
+
// 不带的话服务端会把最近一次的结果直接给你,导航栏就不再「每次都要重新读取」。
|
|
1393
|
+
api('/mini/api/workspaces' + (force ? '?refresh=1' : '')).then(function (body) {
|
|
1394
|
+
navList = body.workspaces || [];
|
|
1395
|
+
navSessions = {};
|
|
1396
|
+
renderNav();
|
|
1397
|
+
}).catch(function (err) {
|
|
1398
|
+
navList = null;
|
|
1399
|
+
$('navBody').innerHTML = '<div class="placeholder">读不到会话列表:'
|
|
1400
|
+
+ escapeHtml(err.message) + '</div>';
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function dayLabel(ts) {
|
|
1405
|
+
if (!ts) return '';
|
|
1406
|
+
var d = new Date(ts);
|
|
1407
|
+
var now = new Date();
|
|
1408
|
+
if (d.toDateString() === now.toDateString()) return timeLabel(ts);
|
|
1409
|
+
if (d.getFullYear() === now.getFullYear()) return (d.getMonth() + 1) + '/' + d.getDate();
|
|
1410
|
+
return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate();
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function renderNav() {
|
|
1414
|
+
var list = navList || [];
|
|
1415
|
+
if (!list.length) {
|
|
1416
|
+
$('navBody').innerHTML = '<div class="placeholder">电脑上还没有可以切换的会话。<br>'
|
|
1417
|
+
+ '在电脑上开一个,这里就会出现了。</div>';
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
$('navBody').innerHTML = list.map(function (ws) {
|
|
1421
|
+
var open = Boolean(navExpanded[ws.id]);
|
|
1422
|
+
return '<div class="ws' + (open ? ' open' : '') + '" data-ws="' + escapeHtml(ws.id) + '">'
|
|
1423
|
+
+ '<button class="ws-head" type="button">'
|
|
1424
|
+
+ '<span class="caret">▶</span>'
|
|
1425
|
+
+ '<span class="ws-title">' + escapeHtml(ws.title) + '</span>'
|
|
1426
|
+
+ (ws.running ? '<span class="s-dot"></span>' : '')
|
|
1427
|
+
+ '<span class="ws-count">' + ws.count + '</span>'
|
|
1428
|
+
+ '</button>'
|
|
1429
|
+
+ '<div class="ws-body">' + (open ? sessionsHtml(ws.id) : '') + '</div>'
|
|
1430
|
+
+ '</div>';
|
|
1431
|
+
}).join('');
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function sessionsHtml(wsId) {
|
|
1435
|
+
var got = navSessions[wsId];
|
|
1436
|
+
if (!got) return '<div class="placeholder">正在读取…</div>';
|
|
1437
|
+
if (!got.sessions.length) return '<div class="placeholder">这个工作区还没有会话。</div>';
|
|
1438
|
+
var cur = state.boundSessionId;
|
|
1439
|
+
var html = got.sessions.map(function (s) {
|
|
1440
|
+
// 没有标题就用日期——原来退到取 id 的前 8 位当名字,而本机真有一个会话的 id
|
|
1441
|
+
// 以「session-」开头,于是整列都显示成一样的「session-」,等于没有名字。
|
|
1442
|
+
return '<button class="sess' + (s.id === cur ? ' cur' : '') + '" type="button"'
|
|
1443
|
+
+ ' data-sess="' + escapeHtml(s.id) + '">'
|
|
1444
|
+
+ (s.running ? '<span class="s-dot"></span>' : '')
|
|
1445
|
+
+ '<span class="s-title">' + escapeHtml(s.title || '未命名会话') + '</span>'
|
|
1446
|
+
+ '<span class="s-meta">' + dayLabel(s.createdAt) + '</span>'
|
|
1447
|
+
+ '</button>';
|
|
1448
|
+
}).join('');
|
|
1449
|
+
if (got.truncated) {
|
|
1450
|
+
html += '<div class="nav-note">只列了最近 ' + got.sessions.length + ' 条,共 '
|
|
1451
|
+
+ got.total + ' 条。</div>';
|
|
1452
|
+
}
|
|
1453
|
+
return html;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function toggleWorkspace(wsId) {
|
|
1457
|
+
if (navExpanded[wsId]) {
|
|
1458
|
+
delete navExpanded[wsId];
|
|
1459
|
+
renderNav();
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
navExpanded[wsId] = true;
|
|
1463
|
+
renderNav();
|
|
1464
|
+
if (navSessions[wsId]) return;
|
|
1465
|
+
api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions')
|
|
1466
|
+
.then(function (body) {
|
|
1467
|
+
navSessions[wsId] = body;
|
|
1468
|
+
renderNav();
|
|
1469
|
+
})
|
|
1470
|
+
.catch(function (err) {
|
|
1471
|
+
navSessions[wsId] = { sessions: [], total: 0 };
|
|
1472
|
+
renderNav();
|
|
1473
|
+
toast(err.message);
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function bindSession(id) {
|
|
1478
|
+
if (!id) return;
|
|
1479
|
+
api('/mini/api/bind', { method: 'POST', body: JSON.stringify({ sessionId: id }) })
|
|
1480
|
+
.then(function (res) {
|
|
1481
|
+
// 接口把切换后的快照一起返回了,直接用它渲染——不用干等 SSE 那一趟。
|
|
1482
|
+
// 不这样的话,切过去的一瞬间屏幕上还留着上一个会话的内容。
|
|
1483
|
+
if (res && res.state) applySnapshot(res.state);
|
|
1484
|
+
else { state.boundSessionId = id; render(); }
|
|
1485
|
+
closeNav();
|
|
1486
|
+
toast('已切换会话');
|
|
1487
|
+
})
|
|
1488
|
+
.catch(function (err) { toast(err.message); });
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// 当前会话可能在电脑上被切走。这里只改高亮,不重绘整棵树——重绘会把
|
|
1492
|
+
// 用户正在划的滚动位置弹回顶部。
|
|
1493
|
+
function paintNavCurrent() {
|
|
1494
|
+
if (!$('nav').classList.contains('open')) return;
|
|
1495
|
+
var cur = state.boundSessionId;
|
|
1496
|
+
Array.prototype.forEach.call($('navBody').querySelectorAll('.sess'), function (el) {
|
|
1497
|
+
el.classList.toggle('cur', el.getAttribute('data-sess') === cur);
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
$('btnNav').addEventListener('click', openNav);
|
|
1502
|
+
$('btnNavClose').addEventListener('click', closeNav);
|
|
1503
|
+
$('btnNavRefresh').addEventListener('click', function () { loadWorkspaces(true); });
|
|
1504
|
+
$('nav').addEventListener('click', function (e) { if (e.target === $('nav')) closeNav(); });
|
|
1505
|
+
|
|
1506
|
+
$('navBody').addEventListener('click', function (e) {
|
|
1507
|
+
var t = e.target;
|
|
1508
|
+
var sess = t && t.closest ? t.closest('.sess') : null;
|
|
1509
|
+
if (sess) { bindSession(sess.getAttribute('data-sess')); return; }
|
|
1510
|
+
var head = t && t.closest ? t.closest('.ws-head') : null;
|
|
1511
|
+
if (!head) return;
|
|
1512
|
+
toggleWorkspace(head.parentNode.getAttribute('data-ws'));
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
// ---------------- SSE ----------------
|
|
1516
|
+
var es = null;
|
|
1517
|
+
function connect() {
|
|
1518
|
+
if (es) { try { es.close(); } catch (e) {} es = null; }
|
|
1519
|
+
if (!state.token) { showGate(); return; }
|
|
1520
|
+
setStatus('', '连接中…');
|
|
1521
|
+
es = new EventSource('/mini/api/stream?token=' + encodeURIComponent(state.token));
|
|
1522
|
+
|
|
1523
|
+
es.addEventListener('open', function () {
|
|
1524
|
+
state.connected = true;
|
|
1525
|
+
updateStatusUI();
|
|
1526
|
+
$('connHint').textContent = '已连接';
|
|
1527
|
+
});
|
|
1528
|
+
es.addEventListener('state', function (e) {
|
|
1529
|
+
state.connected = true;
|
|
1530
|
+
updateStatusUI();
|
|
1531
|
+
try { applySnapshot(JSON.parse(e.data)); } catch (err) {}
|
|
1532
|
+
});
|
|
1533
|
+
es.addEventListener('reply', function (e) {
|
|
1534
|
+
var payload = JSON.parse(e.data);
|
|
1535
|
+
// interrupted 必须带上:这条是「实时推过来的那一条」,走的是和刷新时
|
|
1536
|
+
// (/mini/api/state 的完整快照)不同的代码路径。漏了的话,被按停的那半句话
|
|
1537
|
+
// 在实时推送时不会标「已停止」,得刷新一下才出现——测试全绿但真机是坏的。
|
|
1538
|
+
var cut = payload.interrupted === true;
|
|
1539
|
+
state.latest = {
|
|
1540
|
+
text: payload.text, timestamp: payload.timestamp,
|
|
1541
|
+
sessionId: payload.sessionId, interrupted: cut,
|
|
1542
|
+
};
|
|
1543
|
+
if (state.mode === 'chat') {
|
|
1544
|
+
state.history = (state.history || []).concat([
|
|
1545
|
+
{
|
|
1546
|
+
role: 'assistant', text: payload.text, timestamp: payload.timestamp,
|
|
1547
|
+
sessionId: payload.sessionId, interrupted: cut,
|
|
1548
|
+
}
|
|
1549
|
+
]);
|
|
1550
|
+
}
|
|
1551
|
+
setRunning(false);
|
|
1552
|
+
render();
|
|
1553
|
+
if (!document.hidden) { playDing(); buzz(); }
|
|
1554
|
+
// 页面在后台时的系统通知已经去掉了,原因见下面「浏览器系统通知」那一段。
|
|
1555
|
+
// 需要「人不在也能收到」的,走电脑端的外部推送(Bark / ntfy)。
|
|
1556
|
+
});
|
|
1557
|
+
es.addEventListener('status', function (e) {
|
|
1558
|
+
try { setRunning(JSON.parse(e.data).running); } catch (err) {}
|
|
1559
|
+
});
|
|
1560
|
+
es.addEventListener('error', function () {
|
|
1561
|
+
state.connected = false;
|
|
1562
|
+
updateStatusUI();
|
|
1563
|
+
$('connHint').textContent = '已断开,正在自动重连…';
|
|
1564
|
+
// EventSource 自己会重连,这里不用手动 close。
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// ---------------- 发送 ----------------
|
|
1569
|
+
function autoGrow() {
|
|
1570
|
+
inputEl.style.height = 'auto';
|
|
1571
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// ---------------- 上传附件 ----------------
|
|
1575
|
+
/** 把字节数说成人话。 */
|
|
1576
|
+
function humanSize(n) {
|
|
1577
|
+
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(1).replace(/\.0$/, '') + ' MB';
|
|
1578
|
+
if (n >= 1024) return Math.round(n / 1024) + ' KB';
|
|
1579
|
+
return n + ' B';
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function setAttachBusy(busy) {
|
|
1583
|
+
var btn = $('btnAttach');
|
|
1584
|
+
if (!btn) return;
|
|
1585
|
+
btn.disabled = busy;
|
|
1586
|
+
btn.classList.toggle('busy', busy);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* 把选中的文件传到电脑上。
|
|
1591
|
+
*
|
|
1592
|
+
* 文件本身直接当请求体发(fetch 支持把 File 当 body),不走 JSON、不做 base64——
|
|
1593
|
+
* base64 会把体积撑大三分之一,而这条路可能是手机流量。电脑那边是边收边落盘,
|
|
1594
|
+
* 不在内存里攒整份。
|
|
1595
|
+
*
|
|
1596
|
+
* 传完只拿回一张小票(uploadId)。真正的附件已经在电脑上了,手机上不留副本。
|
|
1597
|
+
*/
|
|
1598
|
+
function uploadFile(file) {
|
|
1599
|
+
var name = file.name || '文件';
|
|
1600
|
+
// 本地上限和服务端是同一个数(由服务端注入),先拦一道省得白传一场。
|
|
1601
|
+
if (MAX_UPLOAD > 0 && file.size > MAX_UPLOAD) {
|
|
1602
|
+
toast('「' + name + '」有 ' + humanSize(file.size) + ',超过上限 ' + humanSize(MAX_UPLOAD));
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
setAttachBusy(true);
|
|
1606
|
+
api('/mini/api/upload?name=' + encodeURIComponent(name) + '&size=' + file.size,
|
|
1607
|
+
{ method: 'POST', body: file })
|
|
1608
|
+
.then(function (res) {
|
|
1609
|
+
state.uploads.push({ uploadId: res.uploadId, name: res.name, bytes: res.bytes });
|
|
1610
|
+
renderAttach();
|
|
1611
|
+
})
|
|
1612
|
+
.catch(function (err) {
|
|
1613
|
+
toast(err.message || '上传失败');
|
|
1614
|
+
})
|
|
1615
|
+
.then(function () { setAttachBusy(false); });
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/** 附件小条。有东西才出现,没有就整块收起来——不占地方。 */
|
|
1619
|
+
function renderAttach() {
|
|
1620
|
+
var bar = $('attachBar');
|
|
1621
|
+
if (!bar) return;
|
|
1622
|
+
var list = state.uploads || [];
|
|
1623
|
+
bar.hidden = !list.length;
|
|
1624
|
+
bar.innerHTML = list.map(function (f, i) {
|
|
1625
|
+
return '<span class="chip"><span class="nm">' + escapeHtml(f.name) + '</span>'
|
|
1626
|
+
+ '<span class="sz">' + humanSize(f.bytes) + '</span>'
|
|
1627
|
+
+ '<button class="x" type="button" data-drop="' + i + '" aria-label="去掉">×</button></span>';
|
|
1628
|
+
}).join('');
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function send() {
|
|
1632
|
+
var text = inputEl.value.trim();
|
|
1633
|
+
var files = state.uploads || [];
|
|
1634
|
+
// 跑着的时候**照样能发**。原来这里有个 `|| state.running` 直接 return,
|
|
1635
|
+
// 于是手机上「正在跑就发不了」,只能干等;而 DSH 本身在跑着的时候是收的——
|
|
1636
|
+
// 那条会排进收件箱,等当前这一轮结束再跑。现在只是把「不许发」改成「排队」。
|
|
1637
|
+
//
|
|
1638
|
+
// 只带文件、一个字不写也是合法的:「这份文件你看一下」本来就是常见用法。
|
|
1639
|
+
// 两样都没有才算空。
|
|
1640
|
+
if (!text && !files.length) return;
|
|
1641
|
+
unlockAudio();
|
|
1642
|
+
// **发指令这个动作,无条件拉回底部。**
|
|
1643
|
+
//
|
|
1644
|
+
// 用户 2026-09-22 实机提的:「用户发送指令时,如果在手机端页面比较上面的位置,
|
|
1645
|
+
// 应该马上下滚回底部。」
|
|
1646
|
+
//
|
|
1647
|
+
// 这跟 atBottom 那条规则不冲突,两者管的不是一件事:那条管的是**页面自己在动**
|
|
1648
|
+
// (鲸鱼娘在跳、流式在写),别把正在翻历史的用户拽回来;这条管的是**用户自己按了发送**,
|
|
1649
|
+
// 那是主动动作,他想看自己刚发出去的那条、以及后面的回答,所以不管当时翻到哪儿都回底部。
|
|
1650
|
+
//
|
|
1651
|
+
// 位置有讲究:必须放在 setRunning 之前。setRunning 会触发一次渲染,而渲染里的
|
|
1652
|
+
// atBottom() 是在改内容**之前**问的——先滚到底,那次渲染才会认为「本来就在底部」
|
|
1653
|
+
// 而继续跟着走;顺序反了,刚回到底部又会被那次渲染判成「用户翻上去了」而停在原地。
|
|
1654
|
+
mainEl.scrollTop = mainEl.scrollHeight;
|
|
1655
|
+
inputEl.value = '';
|
|
1656
|
+
autoGrow();
|
|
1657
|
+
|
|
1658
|
+
var uploadIds = files.map(function (f) { return f.uploadId; });
|
|
1659
|
+
// 手机上先把附件收起来:小票已经交出去了,留着会让用户以为还能再发一次。
|
|
1660
|
+
state.uploads = [];
|
|
1661
|
+
renderAttach();
|
|
1662
|
+
// 聊天模式要在本地回显一条。附件也得看得见——不然发完一条只有文件、没写文字的
|
|
1663
|
+
// 指令,气泡是空的,用户会以为没发出去。
|
|
1664
|
+
var shown = files.length
|
|
1665
|
+
? text + (text ? '\n' : '') + '📎 ' + files.map(function (f) { return f.name; }).join('、')
|
|
1666
|
+
: text;
|
|
1667
|
+
|
|
1668
|
+
if (state.mode === 'minimal') {
|
|
1669
|
+
// 上一条回答**留着**,由 renderMinimal 在它下面加一条「正在执行」。
|
|
1670
|
+
// 原来这里把 latest 清成 null,加上渲染顺序,结果是发完指令旧结果整块消失。
|
|
1671
|
+
// 旧的顾虑(怕用户把上一轮的结果当成这次任务的回复)现在由那条分隔线解决:
|
|
1672
|
+
// 它明确标出「下面是正在跑的,上面是上一步的」。
|
|
1673
|
+
render();
|
|
1674
|
+
}
|
|
1675
|
+
setRunning(true);
|
|
1676
|
+
|
|
1677
|
+
api('/mini/api/send', { method: 'POST', body: JSON.stringify({ text: text, uploadIds: uploadIds }) })
|
|
1678
|
+
.then(function (res) {
|
|
1679
|
+
// 服务端说这条排队了(发的时候正在跑),给一句话。
|
|
1680
|
+
// 队列那块也会跟着推过来,但那要等一个来回;先给个即时反馈,
|
|
1681
|
+
// 免得用户以为又石沉大海了。
|
|
1682
|
+
if (res && res.queued) {
|
|
1683
|
+
toast('已排队,跑完这轮就轮到它');
|
|
1684
|
+
// **排队不算发出去,所以这里不回显气泡。** 用户 2026-09-22 实机提的:
|
|
1685
|
+
// 排队的指令立刻长成一条指令气泡,看着就像已经发出去了,其实还在队列里。
|
|
1686
|
+
// 它已经在下面的排队列表里露过面了,那就是它此刻该待的地方;
|
|
1687
|
+
// 轮到它真跑起来时,服务端会把它记进 history,气泡那时才出现。
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
// 真发出去了才回显。放在这里而不是发之前,是因为**发之前还不知道会不会排队**;
|
|
1691
|
+
// 顺带也解决了发送失败时屏幕上留一条假气泡的问题。
|
|
1692
|
+
if (state.mode !== 'minimal') {
|
|
1693
|
+
state.history = (state.history || []).concat([
|
|
1694
|
+
{ role: 'user', text: shown, timestamp: Date.now() }
|
|
1695
|
+
]);
|
|
1696
|
+
render();
|
|
1697
|
+
}
|
|
1698
|
+
})
|
|
1699
|
+
.catch(function (err) {
|
|
1700
|
+
setRunning(false);
|
|
1701
|
+
if (state.mode === 'minimal') render();
|
|
1702
|
+
toast(err.message || '发送失败');
|
|
1703
|
+
inputEl.value = text;
|
|
1704
|
+
autoGrow();
|
|
1705
|
+
// 附件也还回来。不然网络抖一下,用户刚传上去的文件就白传了,得重新选一次——
|
|
1706
|
+
// 而重新选一次意味着重新传一遍。
|
|
1707
|
+
state.uploads = files;
|
|
1708
|
+
renderAttach();
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
$('btnAttach').addEventListener('click', function () { $('filePick').click(); });
|
|
1713
|
+
$('filePick').addEventListener('change', function (e) {
|
|
1714
|
+
var f = e.target.files && e.target.files[0];
|
|
1715
|
+
// 清掉 value:不清的话,连着选同一个文件第二次不会触发 change,
|
|
1716
|
+
// 用户会觉得「点了没反应」。
|
|
1717
|
+
e.target.value = '';
|
|
1718
|
+
if (f) uploadFile(f);
|
|
1719
|
+
});
|
|
1720
|
+
$('attachBar').addEventListener('click', function (e) {
|
|
1721
|
+
var btn = e.target.closest ? e.target.closest('[data-drop]') : null;
|
|
1722
|
+
if (!btn) return;
|
|
1723
|
+
state.uploads.splice(Number(btn.getAttribute('data-drop')), 1);
|
|
1724
|
+
renderAttach();
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
$('btnSend').addEventListener('click', send);
|
|
1728
|
+
inputEl.addEventListener('input', autoGrow);
|
|
1729
|
+
inputEl.addEventListener('keydown', function (e) {
|
|
1730
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
1731
|
+
e.preventDefault();
|
|
1732
|
+
send();
|
|
1733
|
+
}
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
// ---------------- 语音输入:已移除 ----------------
|
|
1737
|
+
//
|
|
1738
|
+
// 输入框旁边原来有个 🎤,走的是浏览器的 SpeechRecognition。它在手机上**用不了**:
|
|
1739
|
+
// 浏览器把语音识别和 getUserMedia 一样限制在安全上下文里,而本插件三条连接路径
|
|
1740
|
+
// (内网 / Tailscale / 隧道未开)全是明文 http://。依据见 W3C bug 30176:
|
|
1741
|
+
// https://www.w3.org/Bugs/Public/show_bug.cgi?id=30176
|
|
1742
|
+
//
|
|
1743
|
+
// 更糟的是浏览器把这种情况报成 `not-allowed`,和「用户拒绝了权限」同一个错误码,
|
|
1744
|
+
// 于是用户看到的是「麦克风权限被拒绝」,去浏览器设置里翻也找不到能改的东西。
|
|
1745
|
+
//
|
|
1746
|
+
// 2026-09-21 用户裁决:**拿掉这个图标**。留着按了没用,比没有更糟。
|
|
1747
|
+
// 想用说的,用手机键盘自带的 🎤 听写键——不挑连接、不要权限,设置里也写了这一句。
|
|
1748
|
+
// 别把它加回来,除非先有了一条受信任的 https 路径(比如开了证书的 tailscale serve)。
|
|
1749
|
+
|
|
1750
|
+
// ---------------- 设置 ----------------
|
|
1751
|
+
var sheet = $('settings');
|
|
1752
|
+
$('btnSettings').addEventListener('click', function () {
|
|
1753
|
+
sheet.classList.add('open');
|
|
1754
|
+
$('connHint').textContent = state.connected ? '已连接' : '未连接';
|
|
1755
|
+
});
|
|
1756
|
+
$('btnClose').addEventListener('click', function () { sheet.classList.remove('open'); });
|
|
1757
|
+
sheet.addEventListener('click', function (e) { if (e.target === sheet) sheet.classList.remove('open'); });
|
|
1758
|
+
|
|
1759
|
+
Array.prototype.forEach.call($('segMode').children, function (btn) {
|
|
1760
|
+
btn.addEventListener('click', function () {
|
|
1761
|
+
state.mode = btn.dataset.mode;
|
|
1762
|
+
localStorage.setItem('dshMiniMode', state.mode);
|
|
1763
|
+
syncSeg();
|
|
1764
|
+
render();
|
|
1765
|
+
});
|
|
1766
|
+
});
|
|
1767
|
+
Array.prototype.forEach.call($('segSound').children, function (btn) {
|
|
1768
|
+
btn.addEventListener('click', function () {
|
|
1769
|
+
state.sound = btn.dataset.sound === 'on';
|
|
1770
|
+
localStorage.setItem('dshMiniSound', state.sound ? 'on' : 'off');
|
|
1771
|
+
if (state.sound) unlockAudio();
|
|
1772
|
+
syncSeg();
|
|
1773
|
+
});
|
|
1774
|
+
});
|
|
1775
|
+
function syncSeg() {
|
|
1776
|
+
Array.prototype.forEach.call($('segMode').children, function (b) {
|
|
1777
|
+
b.classList.toggle('active', b.dataset.mode === state.mode);
|
|
1778
|
+
});
|
|
1779
|
+
Array.prototype.forEach.call($('segSound').children, function (b) {
|
|
1780
|
+
b.classList.toggle('active', (b.dataset.sound === 'on') === state.sound);
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// 会话切换挪到左侧导航栏了(原来这里是设置里的下拉框)。
|
|
1785
|
+
|
|
1786
|
+
// 浏览器系统通知这条路,在手机上走不通,三层原因叠在一起:
|
|
1787
|
+
// 1. 通知权限只能在「安全上下文」(HTTPS 或 localhost)里申请。手机连的是
|
|
1788
|
+
// 明文 HTTP——局域网和 Tailscale 都是——浏览器连这个 API 都不给;
|
|
1789
|
+
// 2. 就算拿到权限,new Notification() 在大多数移动浏览器上会抛 TypeError,
|
|
1790
|
+
// 移动端正确的做法是 service worker 的 showNotification;
|
|
1791
|
+
// 3. service worker 同样要求 HTTPS。
|
|
1792
|
+
//
|
|
1793
|
+
// 2026-09-21 用户裁决:**设置里那个「开启」按钮先隐藏掉**(和输入框旁边的 🎤 一样,
|
|
1794
|
+
// 留着一个按了没用的按钮比没有更让人困惑)。设置里留一行说明,指向真正管用的那条路:
|
|
1795
|
+
// 电脑端的外部推送(Bark / ntfy),它是从服务器发出去的,不受浏览器这条规矩限制。
|
|
1796
|
+
//
|
|
1797
|
+
// 别把这个按钮加回来,除非先有了一条受信任的 https 路径。
|
|
1798
|
+
|
|
1799
|
+
$('btnReconnect').addEventListener('click', function () {
|
|
1800
|
+
connect();
|
|
1801
|
+
toast('正在重连…');
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
$('btnForget').addEventListener('click', function () {
|
|
1805
|
+
localStorage.removeItem('dshMiniToken');
|
|
1806
|
+
state.token = '';
|
|
1807
|
+
if (es) { try { es.close(); } catch (e) {} es = null; }
|
|
1808
|
+
sheet.classList.remove('open');
|
|
1809
|
+
showGate();
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
// ---------------- token 闸门 ----------------
|
|
1813
|
+
function showGate() {
|
|
1814
|
+
$('gate').classList.add('open');
|
|
1815
|
+
$('gateErr').textContent = '';
|
|
1816
|
+
setTimeout(function () { $('gateInput').focus(); }, 120);
|
|
1817
|
+
}
|
|
1818
|
+
$('gateBtn').addEventListener('click', submitGate);
|
|
1819
|
+
$('gateInput').addEventListener('keydown', function (e) {
|
|
1820
|
+
if (e.key === 'Enter') submitGate();
|
|
1821
|
+
});
|
|
1822
|
+
function submitGate() {
|
|
1823
|
+
var value = $('gateInput').value.trim();
|
|
1824
|
+
if (!value) { $('gateErr').textContent = '请先粘贴 token'; return; }
|
|
1825
|
+
$('gateErr').textContent = '校验中…';
|
|
1826
|
+
fetch('/mini/api/state', { headers: { 'X-Mini-Token': value } })
|
|
1827
|
+
.then(function (r) {
|
|
1828
|
+
if (!r.ok) throw new Error('token 不正确');
|
|
1829
|
+
localStorage.setItem('dshMiniToken', value);
|
|
1830
|
+
state.token = value;
|
|
1831
|
+
$('gate').classList.remove('open');
|
|
1832
|
+
// 手输 token 进来的人也得有立绘——立绘地址是拿 state.token 拼的,
|
|
1833
|
+
// 不在这里补一次,这一路进来的用户会看到空白的立绘位。
|
|
1834
|
+
preloadPoses();
|
|
1835
|
+
connect();
|
|
1836
|
+
})
|
|
1837
|
+
.catch(function () { $('gateErr').textContent = 'token 不正确,请核对后重试'; });
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// ---------------- presence 与心跳 ----------------
|
|
1841
|
+
document.addEventListener('visibilitychange', function () {
|
|
1842
|
+
var hidden = document.hidden;
|
|
1843
|
+
if (state.token) {
|
|
1844
|
+
api('/mini/api/presence', {
|
|
1845
|
+
method: 'POST',
|
|
1846
|
+
body: JSON.stringify({ state: hidden ? 'background' : 'foreground' })
|
|
1847
|
+
}).catch(function () {});
|
|
1848
|
+
}
|
|
1849
|
+
if (!hidden) { render(); connectIfDead(); }
|
|
1850
|
+
});
|
|
1851
|
+
setInterval(function () {
|
|
1852
|
+
if (state.token) api('/mini/api/heartbeat', { method: 'POST', body: '{}' }).catch(function () {});
|
|
1853
|
+
}, 30000);
|
|
1854
|
+
|
|
1855
|
+
function connectIfDead() {
|
|
1856
|
+
// 切回前台时 EventSource 可能已经悄悄死了,探一下。
|
|
1857
|
+
if (!state.token) return;
|
|
1858
|
+
if (es && es.readyState === EventSource.OPEN) return;
|
|
1859
|
+
connect();
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// 复制按钮:整篇、单条气泡、代码块共用一套。
|
|
1863
|
+
// 用事件委托——回复区每次都是整块重绘的,逐个绑会漏。
|
|
1864
|
+
// (原来这里是「点回复区任意位置复制全文」,它直接用了 navigator.clipboard,
|
|
1865
|
+
// 在明文 HTTP 下那个接口是 undefined,点了什么都不会发生;而且它会和
|
|
1866
|
+
// 新加的按钮打架,所以换成显式按钮。)
|
|
1867
|
+
replyEl.addEventListener('click', function (e) {
|
|
1868
|
+
var btn = e.target && e.target.closest ? e.target.closest('.copy') : null;
|
|
1869
|
+
if (!btn) return;
|
|
1870
|
+
e.preventDefault();
|
|
1871
|
+
copyText(btn.getAttribute('data-copy') || '', btn);
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
// ---------------- 启动 ----------------
|
|
1875
|
+
syncSeg();
|
|
1876
|
+
autoGrow();
|
|
1877
|
+
if (NEEDS_TOKEN && !state.token) {
|
|
1878
|
+
showGate();
|
|
1879
|
+
} else if (!state.token) {
|
|
1880
|
+
showGate();
|
|
1881
|
+
} else {
|
|
1882
|
+
preloadPoses();
|
|
1883
|
+
connect();
|
|
1884
|
+
}
|
|
1885
|
+
// 任何一次触摸都顺手解锁音频,避免用户第一次收到回复时没声音。
|
|
1886
|
+
document.addEventListener('touchstart', unlockAudio, { once: true });
|
|
1887
|
+
document.addEventListener('click', unlockAudio, { once: true });
|
|
1888
|
+
})();
|
|
1889
|
+
</script>
|
|
1890
|
+
</body>
|
|
1891
|
+
</html>
|