dsh-mini-remote 0.1.3 → 0.1.4
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 +19 -2
- package/README.en.md +10 -4
- package/README.md +10 -4
- package/client/client.js +5 -1
- package/lib/art/work-9-talking.webp +0 -0
- package/lib/events.js +52 -0
- package/lib/index.js +161 -11
- package/lib/page.html +217 -18
- package/lib/pairing.js +11 -4
- package/lib/serve.js +10 -3
- package/lib/server.js +37 -1
- package/lib/store.js +57 -6
- package/package.json +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本改了什么。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [0.1.4] - 2026-09-27
|
|
6
|
+
|
|
7
|
+
### 新加的
|
|
8
|
+
|
|
9
|
+
- **模型执行过程中的自言自语,现在由鲸鱼娘念出来。** 她在干活时会开口说一句——念的是模型写给人看的那句旁白(「我先看一下这个文件」)。**句子多长就显示多长**:气泡跟着长高,整页给它让位(只有几屏那种病态长文才会让气泡内部滚动)。**没有旁白就不说话**(气泡留着上一句),**它自己想的那些不上手机**,不分中英文。说的时长跟着句子长短走(2026-09-27 起:短句 15 秒起步、每字再加 0.15 秒、最多 30 秒;之前一律 10 秒);这期间轮播**停住**,说完了回到被打断的那一格接着走,不是从第一张重来。同一句不会重复念。
|
|
10
|
+
- **回答区一个字都不动。** 这句话只进鲸鱼娘的说话气泡,不进「最新回复」,不进聊天记录,不冒充「正在写」。2026-09-22 你说过过程的文字别出现在手机上——那次说的是它**落进回答区**(刷屏、把真正的回复挤走);这次它落在气泡里,不在回答区。这条界线写在 `工程说明.md` 的不变式 20 里。
|
|
11
|
+
- **配套一张新的立绘**(说话的动作,两帧),风格和配色跟已有的八张一致。
|
|
12
|
+
- **思考不上气泡**(2026-09-27 你定的口径):模型想事情的习惯是英文、也常是长段落,念出来既看不懂也不是给你的话。气泡里出现的每一句,都是它特意写给人看的那句。所以她有时会安静一会儿——那不是坏了。
|
|
13
|
+
- **句子不被截断**(2026-09-27 你报的):以前电脑侧掐到 80 字、手机侧又限三行,两句长话凑起来就是"话说了一半"——第四行只剩上半截。现在两处截断都撤了,话完整显示;气泡长高时整页跟着让位。
|
|
14
|
+
- **气泡里的话会渲染 markdown**(2026-09-27 你报的):以前气泡是纯文本,`**重启把连接掐断**` 连星号一起显示。现在跟回答区共用同一套渲染——粗体、斜体、行内代码、链接都变成样式。模型原话里的尖括号仍然只是字面文字,不会变成标签。列表、标题、围栏代码块这类块级语法不认(旁白是一句话,用不到),真遇到就原样显示。
|
|
15
|
+
- **她说话的时间变长了**(2026-09-27 你问的):以前固定 10 秒,长句子还没念完她就回去干别的了。现在短句 15 秒起步,句子越长留得越久(每分钟约 400 字的默读速度),最多 30 秒封顶——封顶是为了防"某一步她再没说话,画面却一直卡在说话那张"。
|
|
16
|
+
- **设置接上了 DSH 的标准配置通道**(2026-09-27):插件现在把设置清单完整声明出来(`Config`),其中"默认模式"和"外部推送"标成**改了不用重载**——改这两样时 DSH 会把新值就地交给正在运行的插件,插件当场按新值办事,不会被重建。其余几样(端口、绑定地址、隧道、口令、历史条数)改完确实得重新监听或重建,就照旧重建插件,不假装能热改。顺带:访问口令和 Telegram 机器人令牌标成敏感值,不会回显在表单里。**更老的 DSH(0.1.5 那一代)上照样装得上、功能照旧**,只是没有"改完立即生效"这一条——这条声明不会让插件在旧版本上起不来。
|
|
17
|
+
- 子代理(它派出去干活的小分身)的自言自语不会推到你手机上,别的会话的也不会。
|
|
18
|
+
- **文案更正:不再拿"通知和麦克风"当加密地址的理由**(2026-09-27 你指出的):Tailscale 加密地址那段原来写着「手机浏览器只在加密连接上才肯给用通知和麦克风」。问题是这两样我们**并没有做**——系统通知的入口从 2026-09-25 起一直藏着(锁屏会冻结后台页面,那条路走不通),麦克风图标也早已移除。拿不存在的能力当卖点,等于把你往一个没有的东西上引。现在只说成立的那一条:**这条连接是加密的**(地址栏上会出现 https 那把锁)。DSH 设置卡片、配对页那两条提示、README 中英两版都改了;相关测试也从"要提到这两样"改成"不许再出现",并反向验证过它们真的会红。
|
|
19
|
+
- **声明了对 DSH 的版本要求**(2026-09-27):DSH 0.1.7 起,装插件前会先拿插件声明的版本去比对运行中的 DSH。我们原来把要求写在 `engines.dsh` 里,而这道检查**只看 `peerDependencies`**——等于它对我们完全不设限。现在两处都写了(`engines.dsh` 保留给插件市场做适配展示)。效果:以后 DSH 大版本变动、我们确实不兼容时,DSH 会在加载我们之前把我们这一行禁用并打一句诊断,**整个 DSH 照常启动**(而不是默默加载失败);要给某个具体版本开绿灯,有 `dsh plugin allow-version` 这条通道。
|
|
20
|
+
|
|
5
21
|
## [0.1.3] - 2026-09-25
|
|
6
22
|
|
|
7
23
|
### 新加的
|
|
@@ -49,7 +65,7 @@
|
|
|
49
65
|
- **权限档位能在手机上切。** 仅可查看 / 工作区内修改 / 完全权限,不用再回电脑改,档位显示中文名。
|
|
50
66
|
- **每个会话的模型和思考强度能在手机上换。** 顶栏那一行就是当前用的模型,点它就能改。会话没在跑(睡在硬盘上)的时候也读得出它上次用的是什么,不用先跑一轮。
|
|
51
67
|
- **手机上能新建工作区和会话。** 浏览电脑上的目录,挑一个登记成工作区,再在里面开会话。
|
|
52
|
-
- **Tailscale 那条路上多了一个加密地址**(形如 `https://你的电脑名.你的网络名.ts.net
|
|
68
|
+
- **Tailscale 那条路上多了一个加密地址**(形如 `https://你的电脑名.你的网络名.ts.net/`)。它和原来那条是同一台电脑、同一条通道,区别只在连接加不加密。加密之后浏览器才肯给用系统通知和麦克风——要那两样,前提是「加密连接」。(**2026-09-27 更正**:当时这么写是错的——系统通知和麦克风这两样一直没有真正落地,界面上从来没有过可用的入口,详见本版顶部那条「文案更正」。)
|
|
53
69
|
|
|
54
70
|
### 修好的
|
|
55
71
|
|
|
@@ -60,7 +76,7 @@
|
|
|
60
76
|
|
|
61
77
|
### 已知的取舍
|
|
62
78
|
|
|
63
|
-
- 只有 Tailscale 的加密地址和公网隧道是 `https
|
|
79
|
+
- 只有 Tailscale 的加密地址和公网隧道是 `https://`。明文那条路上,浏览器不给用系统通知和麦克风。(**2026-09-27 更正**:这两样如今在界面上都没有,也不需要靠加密连接去换——想要提醒请用电脑端的外部推送,详见顶部那条「文案更正」。)
|
|
64
80
|
- 手机上答的是 AI 主动提给你的选择题。**系统层面的确认弹窗(比如危险命令的审批)手机上点不了**,还得回电脑。
|
|
65
81
|
- 公网隧道的地址每次重启都会换。
|
|
66
82
|
|
|
@@ -90,3 +106,4 @@
|
|
|
90
106
|
[0.1.1]: https://github.com/xingzhen199186/dsh-mini-remote/releases/tag/v0.1.1
|
|
91
107
|
[0.1.2]: https://github.com/xingzhen199186/dsh-mini-remote/releases/tag/v0.1.2
|
|
92
108
|
[0.1.3]: https://github.com/xingzhen199186/dsh-mini-remote/releases/tag/v0.1.3
|
|
109
|
+
[0.1.4]: https://github.com/xingzhen199186/dsh-mini-remote/releases/tag/v0.1.4
|
package/README.en.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-mini-remote
|
|
2
2
|
|
|
3
|
-
> A [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness) (DSH) plugin that puts a **minimal remote control** on your mobile device — only the instruction you send
|
|
3
|
+
> A [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness) (DSH) plugin that puts a **minimal remote control** on your mobile device — only the instruction you send, the AI's final conclusion, and the one line it mutters between steps (shown in the whale girl's speech bubble) reach it. Tool calls, file reads and writes, sub-agent dispatch, and the body of the reasoning trace all stay on the computer.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|

|
|
@@ -49,6 +49,8 @@ dsh plugin --profile web add <the folder you put the source in>
|
|
|
49
49
|
|
|
50
50
|
**Which DSH versions it fits.** This plugin was developed on DSH **0.1.5-rc.2** and re-checked item by item on **0.1.7-rc.2** (every interface it uses was unchanged); **versions below 0.1.5 have not been tried**. `rc` is the tag the project puts on preview builds, and DSH as a whole is still a developer preview — minor versions may break things, so versions newer than 0.1.7 are not guaranteed either. If it ever does hit an incompatibility, the usual symptom is one entry point going missing (the model line at the top, say) while everything else keeps working: the plugin is written so that a missing service switches off that one feature rather than the whole thing.
|
|
51
51
|
|
|
52
|
+
Settings follow the same rule: the settings are declared the standard DSH way, and on the 0.1.5-era library that ability does not exist yet — the plugin **still installs and works exactly as before**, it just loses the "applies immediately" behaviour mentioned below. On the **0.1.7 generation** that ability is fully there.
|
|
53
|
+
|
|
52
54
|
---
|
|
53
55
|
|
|
54
56
|
## Configuration: three routes, pick what you need
|
|
@@ -57,6 +59,8 @@ All three connection methods **exist at the same time**. Pick one, or leave seve
|
|
|
57
59
|
|
|
58
60
|
Open DSH settings (bottom of the sidebar) → "Phone Remote" in the left column. Every route on that page has a QR code and a link, and the small text under each QR code says **when to use that route**. That's all you need to read.
|
|
59
61
|
|
|
62
|
+
The plugin's own settings (default mode, external notifications, port, password and so on) also have a standard declaration, so writing them in DSH's standard configuration works too. If the same item is set in both places, **the standard configuration wins**. One difference worth knowing: **"default mode" and "external notifications" apply immediately, with no plugin restart**; port, bind address, tunnel, password and history length make the plugin restart and the phone reconnect once — those genuinely need to re-bind or rebuild, and the plugin says so instead of pretending they can be changed live.
|
|
63
|
+
|
|
60
64
|
### 1. LAN: at home only
|
|
61
65
|
|
|
62
66
|
When the phone and the computer are on the same Wi-Fi, scan the "LAN" code and you're in. **Nothing extra to install** — the least fuss of the three.
|
|
@@ -71,7 +75,7 @@ The cost is an app on each side. If you don't mind installing it, **this is the
|
|
|
71
75
|
|
|
72
76
|
You can take this route one step further and get an encrypted address as well (shaped like `https://your-machine.your-tailnet.ts.net/`). It is **the same computer over the same tunnel** as the entry above; the only difference is that the connection is encrypted. It is not a fourth route — the plain `http://` entry keeps working and keeps showing.
|
|
73
77
|
|
|
74
|
-
|
|
78
|
+
What encryption buys is a private connection: the address bar shows the https padlock, and what travels the wire is encrypted. **That does not depend on which route you pick** — the public tunnel in route 3 below also gives you an `https://` address, equally encrypted.
|
|
75
79
|
|
|
76
80
|
The two encrypted routes have different prerequisites, so it's worth being precise. This one uses a name shaped like `your-machine.your-tailnet.ts.net`, and **that name only resolves inside your Tailscale network**, via MagicDNS on the phone's client. **But some clients don't have that capability** — MeshArc, the third-party client for HarmonyOS, states in its own README that MagicDNS is not enabled and you should use Tailscale IP addresses; on such a phone this encrypted route simply won't work, and it isn't a matter of a setting being switched off. The tunnel route uses a Cloudflare name that resolves for anyone and doesn't care about the phone; its cost is that the address changes on every restart.
|
|
77
81
|
|
|
@@ -79,7 +83,7 @@ There's a switch on the pairing page — just turn it on. The first time, you ha
|
|
|
79
83
|
|
|
80
84
|
### 3. Public access: nothing to install
|
|
81
85
|
|
|
82
|
-
There's a switch at the bottom of the pairing page. Turn it on and Cloudflare hands you a public URL (shaped like `https://random-words.trycloudflare.com/`). The phone can reach it on any network, **with nothing installed**. It is an **encrypted connection**,
|
|
86
|
+
There's a switch at the bottom of the pairing page. Turn it on and Cloudflare hands you a public URL (shaped like `https://random-words.trycloudflare.com/`). The phone can reach it on any network, **with nothing installed**. It is an **encrypted connection**, just like the encrypted Tailscale route above — if your phone can't resolve your tailnet's name, this is how you still get encryption.
|
|
83
87
|
|
|
84
88
|
Two costs, worth knowing before you decide:
|
|
85
89
|
|
|
@@ -101,6 +105,8 @@ Open the link and that's the whole interface: one input box, and the latest repl
|
|
|
101
105
|
|
|
102
106
|
**Wait for the conclusion.** While it runs, the page shows "running…". You can keep sending during that time — instructions queue up. When the task ends, the conclusion appears on the page, and the phone chimes and buzzes.
|
|
103
107
|
|
|
108
|
+
**She speaks up while she works.** On the computer, the model often mutters a line between steps ("let me look at this file first"). That line is now spoken by the whale girl through her speech bubble — the talking pose is a newly drawn sprite, the rotation pauses for it, and afterwards picks up from where it stopped. It stays **in the bubble** and never bleeds into the answer area below. Only the line it deliberately writes *for a human reader* is spoken; its own working thoughts — often English, often a whole paragraph — never reach the phone at all. Should that one narration line itself be in English, it is shown as-is, untranslated (translating would mean putting words in its mouth).
|
|
109
|
+
|
|
104
110
|
**Two display modes.** Tap the ⚙ in the top right to switch. "Single frame" (the default) keeps only the newest reply on screen — good for "I just want to see how this one turned out". "Chat" is a back-and-forth bubble list — good for several rounds of follow-up questions. Use it for a while and you'll know which you prefer.
|
|
105
111
|
|
|
106
112
|
**Open a session and you see what was said in it before.** Switch to a session that has existed for a while and Chat mode lists every earlier round (the instructions you sent, the conclusion of each round); Single frame shows only the last conclusion. **Very large sessions show only the most recent stretch**: reading one of those in full — tens of thousands of events — would drag the DSH process on your computer down, so the plugin reads just the tail and says so at the top ("this is not everything, only the most recent stretch"). That line is not boilerplate; it is the truth.
|
|
@@ -111,7 +117,7 @@ Open the link and that's the whole interface: one input box, and the latest repl
|
|
|
111
117
|
|
|
112
118
|
**Three things you can change in passing.** Tap the line at the top to switch the current session's model and reasoning effort; switch the permission preset (View Only / Workspace Write / Full Access); browse the computer's folders to register a new workspace and start a session in it.
|
|
113
119
|
|
|
114
|
-
**You're reading conclusions, not the process.** There's no tool-call chain and no file diff on the page. **System-level confirmation dialogs cannot be answered from the phone** (approving a dangerous command, for instance) — those still need the computer. What the phone can answer is the multiple-choice question the AI puts to you; the two are not the same thing. It's a remote control: the TV still has to be on for the remote to be any use.
|
|
120
|
+
**You're reading conclusions, not the process.** There's no tool-call chain and no file diff on the page. The one exception is the line the whale girl says out loud — it goes into her bubble, never into the answer area (see above). **System-level confirmation dialogs cannot be answered from the phone** (approving a dangerous command, for instance) — those still need the computer. What the phone can answer is the multiple-choice question the AI puts to you; the two are not the same thing. It's a remote control: the TV still has to be on for the remote to be any use.
|
|
115
121
|
|
|
116
122
|
---
|
|
117
123
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-mini-remote
|
|
2
2
|
|
|
3
|
-
> 一个 [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness)(DSH
|
|
3
|
+
> 一个 [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness)(DSH)插件,把一部手机变成**极简遥控器**——移动端只收到你发出去的指令、AI 最后的结论,以及执行中它自言自语的那一句(只进鲸鱼娘的气泡);工具调用、文件读写、子 Agent 调度、思考过程的正文全部留在电脑上。
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|

|
|
@@ -49,6 +49,8 @@ dsh plugin --profile web add <你放源码的目录>
|
|
|
49
49
|
|
|
50
50
|
**它适配哪些 DSH 版本。** 这个插件在 DSH **0.1.5-rc.2** 上开发,在 **0.1.7-rc.2** 上重新逐项核对过(插件用到的那些接口,一个都没变),**低于 0.1.5 的版本没试过**。`rc` 是官方给预览版编的号,DSH 目前整体还是开发者预览版,跨小版本可能有破坏性改动,所以比 0.1.7 更新的版本同样不保证。真遇上不兼容,症状一般是某一块入口不见了(比如顶栏那行模型),其余部分照常——插件是按「少了哪个服务就关掉哪块功能」写的,不会整个起不来。
|
|
51
51
|
|
|
52
|
+
设置也是这样处理的:设置清单按 DSH 的标准方式声明,而在 0.1.5 那一代的库还没有相应能力,插件**照样装得上、功能照旧**,只是少了下面提到的「改完立即生效」。**0.1.7 这一代**上这条能力是齐的。
|
|
53
|
+
|
|
52
54
|
---
|
|
53
55
|
|
|
54
56
|
## 配置:三条路,按需要选
|
|
@@ -57,6 +59,8 @@ dsh plugin --profile web add <你放源码的目录>
|
|
|
57
59
|
|
|
58
60
|
打开 DSH 设置(侧边栏最下面)→ 左栏「手机遥控」。这一页上每条路都有一个二维码和一条链接,二维码底下那行小字写着**什么时候用这条**,看它就够了。
|
|
59
61
|
|
|
62
|
+
插件的设置(默认模式、外部推送、端口、口令这些)也有一份标准声明,写在 DSH 的标准配置里同样认得。两处都写了同一项时,**以标准配置为准**。另有一条区别:**「默认模式」和「外部推送」改完立即生效、插件不重启**;端口、绑定地址、隧道、口令、历史条数这几样改完会让你重连一次——它们本来就得重新监听或重建,插件如实声明,不假装能热改。
|
|
63
|
+
|
|
60
64
|
### 一、内网:只在家里用
|
|
61
65
|
|
|
62
66
|
手机和电脑连同一个 Wi-Fi 时,扫「内网」那个码就能用。**不需要任何额外安装**,最省事的一条。
|
|
@@ -71,7 +75,7 @@ dsh plugin --profile web add <你放源码的目录>
|
|
|
71
75
|
|
|
72
76
|
这条路上还能再开一步,多拿一个加密地址(形如 `https://你的电脑名.你的网络名.ts.net/`)。它和上面那条是**同一台电脑、同一条通道**,区别只在连接加不加密——不是第四条路,那条明文的照旧能用、也照旧显示。
|
|
73
77
|
|
|
74
|
-
|
|
78
|
+
加密的好处是这条连接本身是密的:地址栏上会出现 https 那把锁,路上传的东西是加密的。**这一点不挑路**——下面第三条的公网隧道给的也是 `https://` 地址,一样加密。
|
|
75
79
|
|
|
76
80
|
两条加密路各有各的前提,说清楚免得你走错:这一条用的是形如 `你的电脑名.你的网络名.ts.net` 的域名,**它只在你的 Tailscale 网络内部解析得出**,靠的是手机客户端上的 MagicDNS。**但有些客户端没有这个能力**——HarmonyOS 上那个第三方客户端 MeshArc,它自己的说明里就写着「请用 Tailscale IP 地址」,那类手机上这条加密路走不通,不是设置没开的问题。隧道那条用的是 Cloudflare 的域名,谁都能解析,不挑手机,代价是地址每次重启都会换。
|
|
77
81
|
|
|
@@ -79,7 +83,7 @@ dsh plugin --profile web add <你放源码的目录>
|
|
|
79
83
|
|
|
80
84
|
### 三、公网访问:什么都不用装
|
|
81
85
|
|
|
82
|
-
配对页最底下有个开关,打开它,Cloudflare 会给你一个公网网址(形如 `https://随机词.trycloudflare.com
|
|
86
|
+
配对页最底下有个开关,打开它,Cloudflare 会给你一个公网网址(形如 `https://随机词.trycloudflare.com/`)。手机在任何网络下都能连,**不用装任何东西**。它和上面那条加密路一样是**加密连接**——手机上的 Tailscale 解析不了你那个网络的域名的话,想要加密就走这条。
|
|
83
87
|
|
|
84
88
|
两个代价,先知道再决定:
|
|
85
89
|
|
|
@@ -101,6 +105,8 @@ dsh plugin --profile web add <你放源码的目录>
|
|
|
101
105
|
|
|
102
106
|
**等结论。** 跑的时候上面显示「运行中…」。这期间你可以接着发,指令会排队。任务结束,结论直接出现在页面上,同时响一声、震一下。
|
|
103
107
|
|
|
108
|
+
**她干活的时候会开口。** 电脑上的模型在步骤之间常会自言自语一句(「我先看一下这个文件」这类),这一句现在由鲸鱼娘念出来——说话的动作是新画的一张立绘,轮播会为它停一下,说完接着从停下的地方往下走。它**只在那只气泡里**,不会混进下面的回答区。气泡里只念**它特意写给人看的那一句**;它自己心里想的那些(常是英文、也常成段)一个字都不上手机。万一那句旁白本身就是英文,就照原样显示、不翻译——翻译等于替它编一句它没说过的话。
|
|
109
|
+
|
|
104
110
|
**两种显示模式。** 点右上角的 ⚙ 切换。「单帧」(默认)整屏只留最新一条回复,适合「我就想看这次的结果」;「聊天」是一来一回的气泡列表,适合连着追问好几轮。用一阵子你会知道自己偏哪个。
|
|
105
111
|
|
|
106
112
|
**点开一个会话,能看到它之前聊过什么。** 在左边切到一个早就存在的会话,聊天模式会把之前的每一轮都列出来(你发过的指令、每一轮的结论),单帧模式只显示最后一条结论。**特别大的会话只给最近一段**:那种几万个事件的会话,整份读下来会把电脑上的 DSH 拖垮,所以只读末尾那一小段,并在最上面写明「这不是全部,只显示了最近一段」——你看到的那句话不是客套话,是实情。
|
|
@@ -111,7 +117,7 @@ dsh plugin --profile web add <你放源码的目录>
|
|
|
111
117
|
|
|
112
118
|
**顺手能改的三样。** 点顶栏那一行,换当前会话的模型和思考强度;切权限档位(仅可查看 / 工作区内修改 / 完全权限);浏览电脑上的目录,登记新的工作区和会话。
|
|
113
119
|
|
|
114
|
-
**看的是结论,不是过程。** 页面上没有工具调用链、没有文件 diff
|
|
120
|
+
**看的是结论,不是过程。** 页面上没有工具调用链、没有文件 diff。唯一的例外是鲸鱼娘嘴里那一句自言自语——它只进气泡,不进回答区(见上面那一段)。**系统层面的确认弹窗手机上点不了**(比如危险命令的审批),那类还得回电脑处理——手机上能答的是 AI 主动提给你的选择题,这两件事不一样。它的定位是一根遥控器:电视还得在那儿开着,遥控器才有用。
|
|
115
121
|
|
|
116
122
|
---
|
|
117
123
|
|
package/client/client.js
CHANGED
|
@@ -325,7 +325,11 @@ window.__ModuleLoader__.load({
|
|
|
325
325
|
// 不能含糊地说「没开」:用户会以为是自己这台电脑不支持。
|
|
326
326
|
status = 'Tailscale 的 serve 配着,但它指的是别的端口,不是这个插件。想给手机用的话,把下面这个开关打开。';
|
|
327
327
|
} else {
|
|
328
|
-
|
|
328
|
+
// 2026-09-27 改口径:这段原来写的是「手机浏览器只在加密连接上才肯给用通知和麦克风」,
|
|
329
|
+
// 那两样我们**都没有实现**(系统通知 2026-09-25 起入口就是藏着的,麦克风图标也已移除),
|
|
330
|
+
// 拿它们当理由等于替自己许一个不存在的功能。加密本身是实打实的好处,就只说加密。
|
|
331
|
+
status = '没开。开了之后 Tailscale 那条路上会多一个 https:// 地址——同一台电脑、同一个服务,'
|
|
332
|
+
+ '区别是这条连接本身是加密的(浏览器地址栏上会出现 https 那把锁)。'
|
|
329
333
|
+ '第一次开要在浏览器里确认一下(Tailscale 后台的一个开关),插件代不了你点。';
|
|
330
334
|
}
|
|
331
335
|
|
|
Binary file
|
package/lib/events.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* 1. 用户真正发出的指令(不是插件注入的合成上下文)
|
|
4
4
|
* 2. Agent 一轮任务的最终回复(不是中间的工具调用和思考)
|
|
5
5
|
*
|
|
6
|
+
* 另有一个**只给鲸鱼娘气泡用**的出口 stepSelfTalk():中间步骤的旁白一句话
|
|
7
|
+
* (思考不上手机,见那段注释)。它和上面两样严格分开——不记历史、不顶替回答。
|
|
8
|
+
*
|
|
6
9
|
* 依据 DSH 0.1.5-rc.2 实测的事件契约:
|
|
7
10
|
* SessionEvent = { type, seq, time, data }
|
|
8
11
|
* - turn/start data: { turn }
|
|
@@ -59,6 +62,55 @@ export function looksLikeToolMarkup(text) {
|
|
|
59
62
|
return typeof text === 'string' && TOOL_MARKUP.test(text)
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
/**
|
|
66
|
+
* 中间步骤的「自言自语」。
|
|
67
|
+
*
|
|
68
|
+
* 2026-09-26 用户要的新功能:让手机上的鲸鱼娘把模型执行步骤时的那句自说自话
|
|
69
|
+
* 说出来。**它不进回答区**——那是 2026-09-22 用户亲自否掉的做法(过程的文字跑进
|
|
70
|
+
* 回答区、来了又撤,他说「就像泄露出来的一样」)。这里取出来的东西只走鲸鱼娘的
|
|
71
|
+
* 气泡,和 latest/history 一个字都不碰。
|
|
72
|
+
*
|
|
73
|
+
* **只念旁白**(2026-09-27 用户两次收紧后定下来的口径):
|
|
74
|
+
* 1. 中间步骤的 text 块(「我先看看这个文件」)——它本来就是一句说给人听的话,
|
|
75
|
+
* 中位 45 字,天生适合气泡。
|
|
76
|
+
* 2. **思维链一个字都不上气泡。** 先是我把「退而取思考首行」那道兜底限定成
|
|
77
|
+
* 「整句没有汉字就不念」(英文的思考摆在中文界面里是噪音),用户随后把话
|
|
78
|
+
* 说到底:**思考首行整个不要展示**——不分中英文。这一条比兜底更干净:气泡里
|
|
79
|
+
* 出现的每一句,都是我特意写给人看的;我的草稿(无论什么语言)只留在电脑上。
|
|
80
|
+
*
|
|
81
|
+
* 没旁白就不念(气泡留着上一句,而不是闪空)。
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/** 折叠内部空白:一段话里的换行和缩进不必原样带进气泡。 */
|
|
85
|
+
function tidySelfTalk(text) {
|
|
86
|
+
return String(text).replace(/\s+/g, ' ').trim()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 从一条助手消息的内容里取出「自言自语」。
|
|
91
|
+
*
|
|
92
|
+
* **只有中间步骤才有**:不含工具调用的那一步是最终回答,它归回答区,不归气泡。
|
|
93
|
+
* **只认旁白**(2026-09-27 用户定的):思维链一个字都不取,无论中英文——所以那一步
|
|
94
|
+
* 只想了没写旁白时,返回 null,气泡留着上一句,而不是端一句草稿出来。
|
|
95
|
+
*
|
|
96
|
+
* **不截断**(2026-09-27 用户报的:长句在手机上被切掉半行)。这里原来掐到 80 字、
|
|
97
|
+
* 末尾补省略号;用户要的是「语句完全展现」,于是断句这件事整个交回给手机页面——
|
|
98
|
+
* 那边不限行数、让气泡长高,只留一道 max-height 兜病态长文。两处都掐等于两处都不清。
|
|
99
|
+
*
|
|
100
|
+
* @returns {{kind:'narration', text:string}|null}
|
|
101
|
+
*/
|
|
102
|
+
export function stepSelfTalk(content) {
|
|
103
|
+
if (!isIntermediateStep(content)) return null
|
|
104
|
+
|
|
105
|
+
const narration = visibleText(content)
|
|
106
|
+
if (narration && !looksLikeToolMarkup(narration)) {
|
|
107
|
+
const text = tidySelfTalk(narration)
|
|
108
|
+
if (text) return { kind: 'narration', text }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
62
114
|
/**
|
|
63
115
|
* 一轮任务的累计器。
|
|
64
116
|
*
|
package/lib/index.js
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
* - 不代理 DSH 桌面界面(那是 dsh-pocket 的做法),手机只拿两样东西:
|
|
6
6
|
* 用户发出的指令 + Agent 的最终回复。
|
|
7
7
|
* - 不监听 waterfall 事件。只订阅纯 emit 事件,避免误吞 agent 的默认行为。
|
|
8
|
-
* - 不 import 任何 @deepseek-ai/*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* - 不 import 任何 @deepseek-ai/* 的运行时值,只有一个例外:@deepseek-ai/schemastery。
|
|
9
|
+
* 它只用来声明 Config(见下面 export const Config)——DSH 0.1.7 起,只有把字段标成
|
|
10
|
+
* volatile,改它才不需要重载插件,而声明 schema 绕不开 schemastery。它由宿主提供
|
|
11
|
+
* (写进 peerDependencies、不进 dependencies,免得出现第二份副本)。
|
|
12
|
+
* 其余照旧:事件契约已经用 .d.ts 核实,消息对象自己造(MessageId 的 brand 在运行时
|
|
13
|
+
* 是恒等函数、不做校验),插件加载期几乎没有外部依赖,也没有 cordis 双副本的坑。
|
|
11
14
|
*
|
|
12
15
|
* 事件契约(DSH 0.1.5-rc.2 实测):
|
|
13
16
|
* session/event(session, event) event = { type, seq, time, data }
|
|
@@ -22,8 +25,10 @@ import { homedir } from 'node:os'
|
|
|
22
25
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
23
26
|
import { randomBytes, randomUUID } from 'node:crypto'
|
|
24
27
|
|
|
28
|
+
import z from '@deepseek-ai/schemastery'
|
|
29
|
+
|
|
25
30
|
import { createStore } from './store.js'
|
|
26
|
-
import { createTurnTracker, visibleText } from './events.js'
|
|
31
|
+
import { createTurnTracker, visibleText, stepSelfTalk } from './events.js'
|
|
27
32
|
import { replayHistory, lastReply } from './history.js'
|
|
28
33
|
import { readTailEvents } from './log-tail.js'
|
|
29
34
|
import {
|
|
@@ -108,6 +113,60 @@ const DEFAULTS = {
|
|
|
108
113
|
},
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
/**
|
|
117
|
+
* 给 schema 标上 volatile("改了不用重载")。
|
|
118
|
+
*
|
|
119
|
+
* 为什么要绕这一层判断:`.volatile()` 是 schemastery **3.18.3** 才加的方法,3.18.1/3.18.2
|
|
120
|
+
* 都没有。DSH 0.1.5 声明的依赖范围是 `^3.18.2`,是可以落到没有它的那一版上的——那种环境下
|
|
121
|
+
* 直接写 `.volatile()` 会在**加载插件的那一刻**抛异常,整个插件起不来,手机上什么都没有。
|
|
122
|
+
* 这个插件一贯的做法是"少了哪样就关掉哪一块,不整个倒下",所以这里降级处理:没有这个方法
|
|
123
|
+
* 就原样返回,插件照常可用,只是少了"改配置不用重载"这一条(`liveConfig` 那边也会因此
|
|
124
|
+
* 认不出 volatile 字段,读到的就是普通值,行为与从前一致)。
|
|
125
|
+
*/
|
|
126
|
+
export function markVolatile(schema) {
|
|
127
|
+
return typeof schema?.volatile === 'function' ? schema.volatile() : schema
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 插件设置的标准声明(DSH 0.1.7 起的做法)。宿主拿它校验 profile 补丁给我们的 config,
|
|
132
|
+
* 设置页那条编辑通道也按它生成表单——**只有标了 volatile 的字段才会出现在那张表单里**。
|
|
133
|
+
*
|
|
134
|
+
* **为什么一个 .default() 都没有**:schema 里的默认值会被 Loader 落进 config,而我们的
|
|
135
|
+
* 合并顺序是 `DEFAULTS < settings.json < config`——那样一来 schema 默认值就会盖住用户存在
|
|
136
|
+
* settings.json 里的值。留空即 undefined,deepMerge 会跳过 undefined,内置默认值照旧只在
|
|
137
|
+
* 下面 DEFAULTS 写一处。
|
|
138
|
+
*
|
|
139
|
+
* **为什么只有两个字段标 .volatile()**:标了就是承诺「改它不用重载插件」,而承诺得在代码里
|
|
140
|
+
* 兑现——这类字段必须用的时候现读(见 liveConfig)。端口和绑定地址要重新监听、隧道要重建、
|
|
141
|
+
* 口令的真相在 token 文件和手机页面的改密入口,这些改完确实要重建插件,就不标:DSH 会替我们
|
|
142
|
+
* 重载,那才是实话。宁可少标,不能乱标。
|
|
143
|
+
*/
|
|
144
|
+
export const Config = z.object({
|
|
145
|
+
port: z.number().description('手机上打开的那个端口。改了要重载插件(得重新监听)。'),
|
|
146
|
+
bindAddress: z.string().description("监听哪个地址:'auto'、'127.0.0.1' 或具体某个 IP。改了要重载插件。"),
|
|
147
|
+
tunnel: z.object({
|
|
148
|
+
enabled: z.boolean(),
|
|
149
|
+
cloudflaredPath: z.string(),
|
|
150
|
+
}).description('公网隧道(cloudflared)。改了要重载插件。'),
|
|
151
|
+
token: z.string().role('secret')
|
|
152
|
+
.description('访问口令。留空 = 首次启动自动生成;改口令用手机页面上的改密入口,改完要重载插件。'),
|
|
153
|
+
maxHistory: z.number().description('手机端每个会话保留多少条历史。改了要重载插件。'),
|
|
154
|
+
defaultMode: markVolatile(z.string())
|
|
155
|
+
.description("手机打开时默认进哪个模式:'minimal' 或 'chat'。改了立即生效,不用重载。"),
|
|
156
|
+
notify: markVolatile(z.object({
|
|
157
|
+
enabled: z.boolean(),
|
|
158
|
+
channel: z.string(),
|
|
159
|
+
barkUrl: z.string(),
|
|
160
|
+
ntfyUrl: z.string(),
|
|
161
|
+
ntfyTopic: z.string(),
|
|
162
|
+
telegramBotToken: z.string().role('secret'),
|
|
163
|
+
telegramChatId: z.string(),
|
|
164
|
+
onlyWhenBackground: z.boolean(),
|
|
165
|
+
maxLength: z.number(),
|
|
166
|
+
sound: z.string(),
|
|
167
|
+
})).description('外部推送(Bark / ntfy / Telegram)。改了立即生效,不用重载。'),
|
|
168
|
+
})
|
|
169
|
+
|
|
111
170
|
function deepMerge(base, ...overrides) {
|
|
112
171
|
const out = { ...base }
|
|
113
172
|
for (const patch of overrides) {
|
|
@@ -178,13 +237,43 @@ function userMessage(text, files = []) {
|
|
|
178
237
|
})
|
|
179
238
|
}
|
|
180
239
|
|
|
240
|
+
/**
|
|
241
|
+
* 把标了 volatile 的配置字段接到 settings 上:读的时候现取,不在加载时抄一份。
|
|
242
|
+
*
|
|
243
|
+
* 为什么必须现取:DSH 对 volatile 字段的处理是「就地提交、不重建插件」——它只把新值写进那个
|
|
244
|
+
* 引用里,再 emit 一次 loader/volatile-update。要是我们加载时抄了一份,就地更新就永远看不见,
|
|
245
|
+
* 那这个声明比不声明更糟:DSH 不再重载我们,而我们手里还是旧值。
|
|
246
|
+
*
|
|
247
|
+
* 取值仍沿用原来的合并顺序:config 里显式写了就用 config(undefined 算没写),没写就用
|
|
248
|
+
* settings.json / 内置默认值那一路(`localBase` 那一份,不能拿合并了 config 的那份,
|
|
249
|
+
* 否则会把 volatile 的引用对象自己也当成"本地值")。
|
|
250
|
+
*/
|
|
251
|
+
function liveConfig(settings, config, localBase) {
|
|
252
|
+
for (const [key, ref] of Object.entries(config ?? {})) {
|
|
253
|
+
if (!Config.dict?.[key]?.meta?.volatile) continue
|
|
254
|
+
if (!ref || typeof ref.get !== 'function') continue
|
|
255
|
+
let local = localBase?.[key]
|
|
256
|
+
Object.defineProperty(settings, key, {
|
|
257
|
+
enumerable: true,
|
|
258
|
+
configurable: true,
|
|
259
|
+
get: () => (ref.get() === undefined ? local : ref.get()),
|
|
260
|
+
set: (value) => { local = value },
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
181
265
|
export function apply(ctx, config = {}) {
|
|
182
266
|
const dir = dataDir()
|
|
183
267
|
mkdirSync(dir, { recursive: true })
|
|
184
268
|
|
|
185
269
|
const settingsFile = join(dir, 'settings.json')
|
|
186
270
|
const stored = readJson(settingsFile)
|
|
187
|
-
|
|
271
|
+
// 两份来源分清楚:`local` 是"我们这份"(内置默认 + settings.json),`settings` 再叠上
|
|
272
|
+
// profile 补丁给的 config。volatile 字段要拿 `local` 当兜底,所以基线单独留一份。
|
|
273
|
+
const local = deepMerge(DEFAULTS, stored ?? {})
|
|
274
|
+
const settings = deepMerge(local, config)
|
|
275
|
+
// 标了 volatile 的字段改成"读的时候现取"(见 liveConfig):这类字段改了不用重载插件。
|
|
276
|
+
liveConfig(settings, config, local)
|
|
188
277
|
|
|
189
278
|
// 首次启动把默认配置落一份出来,用户才有东西可以改。
|
|
190
279
|
if (stored === null) {
|
|
@@ -797,8 +886,41 @@ export function apply(ctx, config = {}) {
|
|
|
797
886
|
return
|
|
798
887
|
}
|
|
799
888
|
|
|
889
|
+
/**
|
|
890
|
+
* 一轮跑完,台词就作废。
|
|
891
|
+
*
|
|
892
|
+
* 不广播也安全:这一轮结束必然跟着一条回复或一条状态推送,手机自己会重画;
|
|
893
|
+
* 而页面那边还认着「这句我念过了」(比 at),所以旧的残留不会诈尸。
|
|
894
|
+
*/
|
|
895
|
+
if (event.type === 'turn/end' && sessionId === targetSessionId()) {
|
|
896
|
+
store.setThought(sessionId, null)
|
|
897
|
+
}
|
|
898
|
+
|
|
800
899
|
const action = tracker.feed(sessionId, event)
|
|
801
|
-
if (!action)
|
|
900
|
+
if (!action) {
|
|
901
|
+
/**
|
|
902
|
+
* 到这里还不是「指令」也不是「回答」,但可能是别的会话都不管的第三种东西:
|
|
903
|
+
* 中间步骤的自言自语(鲸鱼娘的台词)。
|
|
904
|
+
*
|
|
905
|
+
* **必须在这儿接,不能等到下面。** 下面第一件事就是 `if (sessionId !==
|
|
906
|
+
* targetSessionId()) return`——那是给「会震动手机的内容」定的规矩,而台词也一样:
|
|
907
|
+
* 别的会话在想什么,跟手机正遥控的这个会话无关。
|
|
908
|
+
*
|
|
909
|
+
* 它取的是中间步骤的旁白或思考首行,**不落盘、不进历史**,所以回答区的口径
|
|
910
|
+
* (latest/history)一个字都不会因此改变——2026-09-22 用户否掉的是「过程的文字
|
|
911
|
+
* 跑进回答区」,不是「她说了句话」。
|
|
912
|
+
*/
|
|
913
|
+
if (event.type === 'assistant/message') {
|
|
914
|
+
if (sessionId !== targetSessionId()) return
|
|
915
|
+
const talk = stepSelfTalk(event.data?.message?.content)
|
|
916
|
+
// 没话可说时**什么都不做**:留着上一句,比让气泡闪一下空要好。
|
|
917
|
+
if (!talk) return
|
|
918
|
+
store.setThought(sessionId, { text: talk.text, at: event.time ?? Date.now() })
|
|
919
|
+
store.touchSession(sessionId)
|
|
920
|
+
server?.broadcast('state', store.snapshot())
|
|
921
|
+
}
|
|
922
|
+
return
|
|
923
|
+
}
|
|
802
924
|
store.touchSession(sessionId)
|
|
803
925
|
|
|
804
926
|
// 手机还没绑会话时,谁先有动静就绑谁。
|
|
@@ -823,7 +945,9 @@ export function apply(ctx, config = {}) {
|
|
|
823
945
|
}
|
|
824
946
|
// 电脑上自己敲的指令也记一笔,聊天模式才完整。
|
|
825
947
|
// 带上 id:那条也可能是**排队**排进去的,手机上要靠它对上号、标出「排队中」。
|
|
826
|
-
|
|
948
|
+
// 时间戳用事件自己的 `time`:硬盘重放出来的那份也是这个 `time`,两边同钟,
|
|
949
|
+
// 合成时才认得出「这条记录里已经有了」,不会接成两条(见 store.pushUser)。
|
|
950
|
+
store.pushUser({ text: action.text, sessionId, id: action.id, timestamp: event.time })
|
|
827
951
|
server?.broadcast('state', store.snapshot())
|
|
828
952
|
return
|
|
829
953
|
}
|
|
@@ -831,6 +955,8 @@ export function apply(ctx, config = {}) {
|
|
|
831
955
|
// action.kind === 'reply'
|
|
832
956
|
store.pushReply({
|
|
833
957
|
text: action.text, sessionId, reason: action.reason, interrupted: action.interrupted,
|
|
958
|
+
// 同上:和硬盘那份用同一个钟(事件的 `time`),否则同一条回复会显示两遍。
|
|
959
|
+
timestamp: event.time,
|
|
834
960
|
})
|
|
835
961
|
server?.broadcast('reply', {
|
|
836
962
|
text: action.text,
|
|
@@ -1198,13 +1324,37 @@ export function apply(ctx, config = {}) {
|
|
|
1198
1324
|
// 能力从 `miniControl` 这个固定取用点拿(服务启动时主动放进去的),
|
|
1199
1325
|
// **不再经过 createMiniServer 的回执**——实测那个回执兑现出来是 undefined。
|
|
1200
1326
|
const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1327
|
+
/**
|
|
1328
|
+
* **让路的那三种情况,每一种都留一行。**
|
|
1329
|
+
*
|
|
1330
|
+
* 2026-09-27 用户报「电脑上弹出了选项,手机上什么都没有」。当时这条链上只有
|
|
1331
|
+
* 「答好了」和「出错」两句日志——正好把**让路**这半边全留白了,于是只能靠猜:
|
|
1332
|
+
* 是手机没连、还是问的不是手机上绑的那个会话,日志里看不出来。
|
|
1333
|
+
* 现在每次被叫到都记一行「问的是谁、手机绑的是谁、题数、手机在不在」。
|
|
1334
|
+
* **不记题目正文**——那是用户自己的话。
|
|
1335
|
+
*/
|
|
1336
|
+
if (!agentId || agentId !== bound || questions.length === 0) {
|
|
1337
|
+
console.log(`[dsh-mini-remote] 提问让路:问的=${agentId ?? '无'},手机绑的=${bound ?? '无'},题目数=${questions.length}`)
|
|
1338
|
+
return next()
|
|
1339
|
+
}
|
|
1340
|
+
if (!phoneReady) {
|
|
1341
|
+
console.log('[dsh-mini-remote] 提问让路:手机上此刻没有连接(页面没开、锁屏或断网),交给电脑')
|
|
1342
|
+
return next()
|
|
1343
|
+
}
|
|
1344
|
+
if (typeof miniControl.askPhone !== 'function') {
|
|
1345
|
+
console.log('[dsh-mini-remote] 提问让路:拿不到 askPhone(手机服务还没起来),交给电脑')
|
|
1346
|
+
return next()
|
|
1347
|
+
}
|
|
1204
1348
|
// ——— 同步交出"将来会有答案"的东西;下面的等待都在这里,不再有同步判断 ———
|
|
1349
|
+
console.log(`[dsh-mini-remote] 提问推给手机:${questions.length} 题,等它答`)
|
|
1205
1350
|
return miniControl.askPhone(questions, agentId).then((answer) => {
|
|
1206
1351
|
// 手机中途没了:把问题还给电脑,那边照常弹窗——不会两头都答不上。
|
|
1207
|
-
|
|
1352
|
+
// 这一句日志是「手机上答了、电脑上没动静」那类现象唯一能落下来的痕迹:
|
|
1353
|
+
// 有它才知道手机那份答案没接上(断线、或者答晚了一步),而不是插件没在跑。
|
|
1354
|
+
if (!answer) {
|
|
1355
|
+
console.log('[dsh-mini-remote] 手机上没答成(连接断了、或者答晚了),这题还给电脑')
|
|
1356
|
+
return next()
|
|
1357
|
+
}
|
|
1208
1358
|
console.log('[dsh-mini-remote] 手机上答好了,交给电脑')
|
|
1209
1359
|
return answer
|
|
1210
1360
|
}).catch((err) => {
|
package/lib/page.html
CHANGED
|
@@ -221,10 +221,15 @@
|
|
|
221
221
|
那个 171 = 10 个全角字 × 14.5px,加左右内边距 24、边框 2。
|
|
222
222
|
用 max-content 而不是写死 width:将来真加了更长的词条,这行会自己撑开——
|
|
223
223
|
宁可立绘挪一点,也不能让字顶出屏幕。
|
|
224
|
-
min() 是窄屏退路:屏幕比 299 还窄时,这行跟着屏幕收,别溢出。
|
|
224
|
+
min() 是窄屏退路:屏幕比 299 还窄时,这行跟着屏幕收,别溢出。
|
|
225
|
+
**max-width 那一行是把上面那句话真正落实**(2026-09-27 补):光有
|
|
226
|
+
`max-content` + `min-width` 只有下限没有上限,而气泡的 `max-width:
|
|
227
|
+
calc(100% - 128px)` 里的那个 100% 又是拿这一行自己算的——两头都靠对方,
|
|
228
|
+
谁也没被真正管住。结果是一句长话(尤其我那几句英文思考)把整行撑到屏幕外,
|
|
229
|
+
手机上得横着划才能看见(用户截图报的就是这个)。钉死在这一行的宽度上才算数。 */
|
|
225
230
|
.work-top {
|
|
226
|
-
display: flex; align-items:
|
|
227
|
-
width: max-content; min-width: min(299px, 100%);
|
|
231
|
+
display: flex; align-items: flex-start; gap: 6px;
|
|
232
|
+
width: max-content; min-width: min(299px, 100%); max-width: 100%;
|
|
228
233
|
margin: 0 auto;
|
|
229
234
|
}
|
|
230
235
|
|
|
@@ -262,7 +267,12 @@
|
|
|
262
267
|
它推不动立绘——立绘的位置由上面 .work-top 的固定行宽管着,跟气泡宽窄无关。
|
|
263
268
|
所以「词条每 3.6 秒换一次」这件事不会让立绘左右晃。
|
|
264
269
|
max-width 是窄屏退路:立绘 122 + 间隙 6,剩下的都给气泡,宁可折行也别顶出屏幕。
|
|
265
|
-
|
|
270
|
+
|
|
271
|
+
**竖着不限行数**(2026-09-27 用户报的:一句长话被切掉半行)。原来是
|
|
272
|
+
`-webkit-line-clamp: 3` + `overflow: hidden`——第四行连省略号都没有地截断,
|
|
273
|
+
看着像她话说了一半。现在让气泡自己长高,整页跟着让位(main 本来就能上下滚)。
|
|
274
|
+
留一道 `max-height` + 内部滚动只为兜病态长文(几屏):正常一两句话够不到它。
|
|
275
|
+
`align-self: flex-start` 让气泡顶到行首、和她的头对齐。 */
|
|
266
276
|
.work-bubble {
|
|
267
277
|
position: relative; flex: 0 1 auto;
|
|
268
278
|
max-width: calc(100% - 128px);
|
|
@@ -270,13 +280,31 @@
|
|
|
270
280
|
background: var(--panel-2); border: 1px solid var(--line);
|
|
271
281
|
border-radius: 12px; padding: 9px 12px;
|
|
272
282
|
font-size: 14.5px; line-height: 1.5; color: var(--fg);
|
|
273
|
-
|
|
283
|
+
max-height: 50vh; overflow-y: auto;
|
|
284
|
+
/* 长英文串、URL 不认换行,会把气泡顶宽。中文本来就能任意断行,这条是给它们兜的。 */
|
|
285
|
+
overflow-wrap: anywhere;
|
|
286
|
+
}
|
|
287
|
+
/* hidden 只是 UA 样式表里的一条 display:none,元素自己没写 display 时它才管用——
|
|
288
|
+
原来这里有个 `display: -webkit-box` 把它盖掉了,于是「词条是空串就把气泡收起来」
|
|
289
|
+
根本没生效,跑动那张旁边一直挂着一个没字的空药丸(2026-09-27 用户截图报的)。 */
|
|
290
|
+
.work-bubble[hidden] { display: none; }
|
|
291
|
+
/* 尖角固定在气泡**上部**(对准她的头),不跟着气泡高度跑。原来钉在 50%,气泡一长高,
|
|
292
|
+
尖角就滑到她身子下边去了——长句和短句都得指着头,所以要一个绝对值。 */
|
|
274
293
|
.work-bubble::before, .work-bubble::after {
|
|
275
|
-
content: ''; position: absolute; top:
|
|
294
|
+
content: ''; position: absolute; top: 24px; width: 0; height: 0;
|
|
276
295
|
border: 6px solid transparent; margin-top: -6px;
|
|
277
296
|
}
|
|
278
297
|
.work-bubble::before { left: -12px; border-right-color: var(--line); }
|
|
279
298
|
.work-bubble::after { left: -11px; border-right-color: var(--panel-2); }
|
|
299
|
+
/* 气泡里的话走和回答区同一套行内渲染(`mdInline`):`**加粗**`、`` `代码` ``、链接会变成样式。
|
|
300
|
+
代码的底色特意不用 --panel-2——那是气泡自己的底色,套上去等于看不见。 */
|
|
301
|
+
.work-bubble strong { font-weight: 650; }
|
|
302
|
+
.work-bubble a { color: var(--accent); word-break: break-all; }
|
|
303
|
+
.work-bubble code {
|
|
304
|
+
background: rgba(127, 127, 127, .18); border: 1px solid var(--line); border-radius: 5px;
|
|
305
|
+
padding: .1em .35em; font-size: .88em;
|
|
306
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
307
|
+
}
|
|
280
308
|
|
|
281
309
|
/* 进度条故意做成**不确定进度**的流动条,不是百分比。
|
|
282
310
|
插件按设计只收「指令 + 最终回复」,拿不到 Agent 此刻跑到哪一步,
|
|
@@ -869,6 +897,14 @@
|
|
|
869
897
|
uploads: [],
|
|
870
898
|
// 正在流出来的那一段。空字符串 = 这会儿没有东西要显示。
|
|
871
899
|
live: '',
|
|
900
|
+
/**
|
|
901
|
+
* 模型此刻的自言自语(鲸鱼娘的台词):{ text, at } 或 null。
|
|
902
|
+
*
|
|
903
|
+
* **和 live 严格分开**:live 非空时整块 #work 会让位(那是「回答正在往外冒」的
|
|
904
|
+
* 规矩),台词要是塞进 live,会出现「该说话的时候鲸鱼娘不见了」。
|
|
905
|
+
* 它也不进 #reply——2026-09-22 用户否掉的就是「过程的文字跑进回答区」。
|
|
906
|
+
*/
|
|
907
|
+
thought: null,
|
|
872
908
|
// 服务端正去读这个会话的完整记录(点开一个插件没看过的会话时会有这一下)。
|
|
873
909
|
historyLoading: false,
|
|
874
910
|
// 「这份历史只到最近一段」(服务端读历史有上限时会给这一句)。
|
|
@@ -1115,7 +1151,13 @@
|
|
|
1115
1151
|
opts.headers = Object.assign(authHeaders(!raw), opts.headers || {});
|
|
1116
1152
|
return fetch(path, opts).then(function (r) {
|
|
1117
1153
|
return r.json().catch(function () { return {}; }).then(function (body) {
|
|
1118
|
-
if (!r.ok)
|
|
1154
|
+
if (!r.ok) {
|
|
1155
|
+
var err = new Error(body.error || ('HTTP ' + r.status));
|
|
1156
|
+
// 状态码挂在错上带出去:调用方要区分「服务端说这题已经结束了」(409)和
|
|
1157
|
+
// 「压根没连上」(fetch 直接抛)——靠中文字面去猜太脆。
|
|
1158
|
+
err.status = r.status;
|
|
1159
|
+
throw err;
|
|
1160
|
+
}
|
|
1119
1161
|
return body;
|
|
1120
1162
|
});
|
|
1121
1163
|
});
|
|
@@ -1212,10 +1254,50 @@
|
|
|
1212
1254
|
var GAP_MS = 3600;
|
|
1213
1255
|
var SLOW_MS = 60000;
|
|
1214
1256
|
|
|
1257
|
+
/**
|
|
1258
|
+
* 说话那张立绘。**它不进 POSES。**
|
|
1259
|
+
*
|
|
1260
|
+
* POSES 是轮播的节目单,进去就要占一格(还要连带改 posePool 那套按位置认「还在忙」
|
|
1261
|
+
* 的写法)。这张是**按需插播**的:只在宿主侧推来台词时出现,说完就回原来的顺序。
|
|
1262
|
+
* 文件名里的 9 同样只是个名字,跟顺序无关。
|
|
1263
|
+
*/
|
|
1264
|
+
var SPEAK_POSE = { file: 'work-9-talking', flip: 1.1 };
|
|
1265
|
+
/**
|
|
1266
|
+
* 一句台词占多久:**按字数算**,短的有起步时长,长的封顶。
|
|
1267
|
+
*
|
|
1268
|
+
* 用户 2026-09-26 选的是「一直说到它结束(最多 10 秒)」;2026-09-27 他觉得太短,
|
|
1269
|
+
* 问能不能再久一点。原来是一律 10 秒——短句够用,可长句在 2026-09-27 起会**完整显示**
|
|
1270
|
+
* (不再截断),一句 200 字的话念 10 秒根本念不完,她就已经回去干别的了。
|
|
1271
|
+
*
|
|
1272
|
+
* 所以:起步 15 秒,每多一个字多留 0.15 秒(≈ 一分钟 400 字,手机上舒服的默读速度),
|
|
1273
|
+
* 30 秒封顶——封顶是为了防「某一轮她再没说话、画面却一直卡在说话那张」。
|
|
1274
|
+
* 宿主侧只拿得到**整段**(一步一步来的,不是逐字),所以「结束」得自己收尾:
|
|
1275
|
+
* 下一句台词来了就把表往后推,一直没有新的或这一轮结束了,就到点放回轮播。
|
|
1276
|
+
*/
|
|
1277
|
+
var SPEAK_MIN_MS = 15000;
|
|
1278
|
+
var SPEAK_PER_CHAR_MS = 150;
|
|
1279
|
+
var SPEAK_MAX_MS = 30000;
|
|
1280
|
+
|
|
1281
|
+
/** 这句话该占多久(见上面三条)。 */
|
|
1282
|
+
function speakDuration(text) {
|
|
1283
|
+
var n = String(text || '').length;
|
|
1284
|
+
return Math.min(SPEAK_MAX_MS, Math.max(SPEAK_MIN_MS, n * SPEAK_PER_CHAR_MS));
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1215
1287
|
var reducedMotion = false;
|
|
1216
1288
|
try { reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
|
|
1217
1289
|
|
|
1218
|
-
var work = {
|
|
1290
|
+
var work = {
|
|
1291
|
+
startedAt: 0, pose: 0, poseTimer: null, tickTimer: null, layers: [],
|
|
1292
|
+
// 说话那张立绘单独一层(不在 POSES 里,见 SPEAK_POSE)。
|
|
1293
|
+
speakLayer: null,
|
|
1294
|
+
// 正在说的那句台词,以及它最晚说到什么时候。
|
|
1295
|
+
speakText: '',
|
|
1296
|
+
speakUntil: 0,
|
|
1297
|
+
// 已经念过的那句话的身份(宿主侧的 at)。快照来得很密,不认这个的话
|
|
1298
|
+
// 同一句话会被反复「重新开始说」,动画一直从头播。
|
|
1299
|
+
speakAt: null,
|
|
1300
|
+
};
|
|
1219
1301
|
|
|
1220
1302
|
/** 现在该用哪几条姿态:跑够一分钟才把「还在忙」放进来。 */
|
|
1221
1303
|
function posePool() {
|
|
@@ -1254,16 +1336,65 @@
|
|
|
1254
1336
|
+ '&v=' + encodeURIComponent(BUILD);
|
|
1255
1337
|
}
|
|
1256
1338
|
|
|
1339
|
+
/**
|
|
1340
|
+
* 把一句话写进气泡。
|
|
1341
|
+
*
|
|
1342
|
+
* 走的是和回答区同一个行内渲染(`mdInline`,见上面那段 Markdown 渲染)。它的顺序是
|
|
1343
|
+
* **先转义、再上标签**:模型原话里的尖括号仍然只是字面上的尖括号,不会被当成标签;
|
|
1344
|
+
* 只有它自己写的 `**粗体**`、`` `代码` ``、链接才会变成样式。留一个口子给"以后不加"。
|
|
1345
|
+
*
|
|
1346
|
+
* 之前这里是 `textContent`(一句纯文本),于是 `**重启把连接掐断**` 连星号一起端到了
|
|
1347
|
+
* 用户面前(2026-09-27 用户报的)。词条那几种是我们自己写的、不含 markdown,也一起走
|
|
1348
|
+
* 这个入口——两条路各写各的,早晚会分叉。
|
|
1349
|
+
*/
|
|
1350
|
+
function paintBubble(line) {
|
|
1351
|
+
var text = line || '';
|
|
1352
|
+
$('workBubble').hidden = !text;
|
|
1353
|
+
$('workBubble').innerHTML = mdInline(text);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1257
1356
|
function paintPose(index) {
|
|
1258
1357
|
for (var i = 0; i < work.layers.length; i += 1) {
|
|
1259
1358
|
work.layers[i].classList.toggle('on', i === index);
|
|
1260
1359
|
}
|
|
1360
|
+
// 轮播回来时把说话那张收掉——它在 POSES 之外,不归上面那个循环管。
|
|
1361
|
+
if (work.speakLayer) work.speakLayer.classList.toggle('on', false);
|
|
1261
1362
|
// 有的姿势不带词条(比如那张冲刺):词条是空串就把整个气泡收起来。
|
|
1262
1363
|
// 留一个空泡泡在那儿比不放气泡更怪——那个尖角还指着她的头,像话没说出来。
|
|
1263
1364
|
// 收起气泡不会让立绘挪位:.work-top 有 min-width 兜着,行宽不变。
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1365
|
+
paintBubble(POSES[index].line);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* 换成说话那张,把台词写进气泡。
|
|
1370
|
+
*
|
|
1371
|
+
* 台词是**模型的原话**(宿主机只从旁白里取,模型自己想的那些不上手机),
|
|
1372
|
+
* 所以这里只做一件事:原样写出来,不加不减——渲染的那一步交给 `paintBubble`。
|
|
1373
|
+
*/
|
|
1374
|
+
function paintSpeak(text) {
|
|
1375
|
+
paintBubble(text);
|
|
1376
|
+
// 说话那张没加载出来时(预热那次报错翻过这个开关):台词照说,**但不动图层**——
|
|
1377
|
+
// 把正常那几张全关掉、换上一张空的,她会当场从屏幕上消失(2026-09-27 立的闸)。
|
|
1378
|
+
if (work.speakReady === false) return;
|
|
1379
|
+
for (var i = 0; i < work.layers.length; i += 1) work.layers[i].classList.toggle('on', false);
|
|
1380
|
+
if (work.speakLayer) work.speakLayer.classList.toggle('on', true);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* 宿主侧送来的新台词要不要现在就说。
|
|
1385
|
+
*
|
|
1386
|
+
* 判据是 at(那句话的身份):**同一句只说一次**。快照广播来得很密,每来一条就重说
|
|
1387
|
+
* 一遍的话,说话这张立绘会一直从头播,看着像卡住不动。
|
|
1388
|
+
*/
|
|
1389
|
+
function maybeSpeak() {
|
|
1390
|
+
var t = state.thought;
|
|
1391
|
+
if (!t || !t.text) return;
|
|
1392
|
+
if (t.at === work.speakAt) return;
|
|
1393
|
+
work.speakAt = t.at;
|
|
1394
|
+
work.speakText = t.text;
|
|
1395
|
+
// 每来一句新的就从这一刻重新算:时长按这句话有多长来(见 speakDuration)。
|
|
1396
|
+
work.speakUntil = Date.now() + speakDuration(t.text);
|
|
1397
|
+
paintSpeak(t.text);
|
|
1267
1398
|
}
|
|
1268
1399
|
|
|
1269
1400
|
function elapsedLabel(ms) {
|
|
@@ -1293,6 +1424,19 @@
|
|
|
1293
1424
|
var warm = document.createElement('img');
|
|
1294
1425
|
warm.src = artUrl(POSES[i].file);
|
|
1295
1426
|
}
|
|
1427
|
+
// 说话那张一起建好、一起预热:等台词来了再新建图层,会说第一个字之前先闪一下空白。
|
|
1428
|
+
var speak = document.createElement('div');
|
|
1429
|
+
speak.className = 'pose';
|
|
1430
|
+
speak.style.backgroundImage = 'url("' + artUrl(SPEAK_POSE.file) + '")';
|
|
1431
|
+
speak.style.animationDuration = SPEAK_POSE.flip + 's';
|
|
1432
|
+
stage.appendChild(speak);
|
|
1433
|
+
work.speakLayer = speak;
|
|
1434
|
+
var warmSpeak = document.createElement('img');
|
|
1435
|
+
// 说话那张要是加载不出来(图还没画好、或者网络半路断了):**别把舞台清空**。
|
|
1436
|
+
// 默认按「图在」处理,只有真报错才翻成 false——正常路径一个字都不变;
|
|
1437
|
+
// 翻成 false 之后就退成「台词照说、人照轮播」,而不是她整个消失(见 paintSpeak)。
|
|
1438
|
+
warmSpeak.onerror = function () { work.speakReady = false; };
|
|
1439
|
+
warmSpeak.src = artUrl(SPEAK_POSE.file);
|
|
1296
1440
|
paintPose(0);
|
|
1297
1441
|
}
|
|
1298
1442
|
|
|
@@ -1304,6 +1448,20 @@
|
|
|
1304
1448
|
* 停止时 stopWork 会把待排的那一次清掉,不会漏跑。
|
|
1305
1449
|
*/
|
|
1306
1450
|
function nextPose() {
|
|
1451
|
+
// 正在说话:这一格让给她,轮播停在这儿等(work.pose 不动,所以恢复时接着的那一格
|
|
1452
|
+
// 就是被打断的那一格,而不是从头来)。到点或来了新台词都由 maybeSpeak 管。
|
|
1453
|
+
if (work.speakUntil) {
|
|
1454
|
+
var left = work.speakUntil - Date.now();
|
|
1455
|
+
if (left > 0) {
|
|
1456
|
+
paintSpeak(work.speakText);
|
|
1457
|
+
// 睡到「该收尾的那一刻」为止,不多不少:按 3.6 秒一格睡的话,
|
|
1458
|
+
// 这个上限会被拖到整格之后,最后那句台词要多挂将近一秒。
|
|
1459
|
+
work.poseTimer = setTimeout(nextPose, Math.min(POSE_MS, left));
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
work.speakUntil = 0;
|
|
1463
|
+
work.speakText = '';
|
|
1464
|
+
}
|
|
1307
1465
|
var seq = poseSequence();
|
|
1308
1466
|
work.pose = (work.pose + 1) % seq.length;
|
|
1309
1467
|
var index = seq[work.pose];
|
|
@@ -1328,6 +1486,11 @@
|
|
|
1328
1486
|
|
|
1329
1487
|
function stopWork() {
|
|
1330
1488
|
work.startedAt = 0;
|
|
1489
|
+
// 说话状态跟着收掉:这一轮的话已经说完了,下一轮不该接着上一句。
|
|
1490
|
+
// **但 speakAt 留着**——那是对「哪句话已经念过」的记忆,清掉的话,万一宿主侧
|
|
1491
|
+
// 还留着上一轮的台词(广播没到),新一轮一开场它就会被当成新话说一遍。
|
|
1492
|
+
work.speakUntil = 0;
|
|
1493
|
+
work.speakText = '';
|
|
1331
1494
|
// 轮播现在是自己排下一次的 setTimeout(见 nextPose),用 clearInterval 清
|
|
1332
1495
|
// setTimeout 在浏览器里也行,但写 clearTimeout 更不容易让人误会。
|
|
1333
1496
|
if (work.poseTimer) { clearTimeout(work.poseTimer); work.poseTimer = null; }
|
|
@@ -1373,6 +1536,30 @@
|
|
|
1373
1536
|
|
|
1374
1537
|
$('btnStop').addEventListener('click', requestStop);
|
|
1375
1538
|
|
|
1539
|
+
/**
|
|
1540
|
+
* 把手机上答的题交回电脑,回一句实话。
|
|
1541
|
+
*
|
|
1542
|
+
* 回的是三种结果,不是一个「成没成」——因为两种失败要说不同的话:
|
|
1543
|
+
* 'ok' 电脑收下了;
|
|
1544
|
+
* 'late' 这题已经交回电脑了(插件回 409),得让用户去电脑上答;
|
|
1545
|
+
* 'failed' 压根没发出去(连不上),卡片留着,手一按还能再试。
|
|
1546
|
+
*
|
|
1547
|
+
* 原来那一段是按下就先把卡片收掉、发失败也咽着不说(当时的理由是「电脑那边等不到
|
|
1548
|
+
* 会自己还给浏览器」)。可电脑那边还在等着,手机上却静悄悄——用户看到的只有
|
|
1549
|
+
* 「我点了,没反应」(2026-09-27 用户报的正是这一幕)。所以这里只做一件事:
|
|
1550
|
+
* 把实情如实报出来,怎么显示由上面那张卡片决定。
|
|
1551
|
+
*/
|
|
1552
|
+
function sendAnswer(id, answers) {
|
|
1553
|
+
return api('/mini/api/answer', {
|
|
1554
|
+
method: 'POST',
|
|
1555
|
+
body: JSON.stringify({ id: id, answers: answers }),
|
|
1556
|
+
})
|
|
1557
|
+
.then(function () { return 'ok'; })
|
|
1558
|
+
.catch(function (err) {
|
|
1559
|
+
return err && err.status === 409 ? 'late' : 'failed';
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1376
1563
|
// ---------------- 排队中的指令 ----------------
|
|
1377
1564
|
//
|
|
1378
1565
|
// 跑着任务时接着发的那几条不会丢,而是排进 agent 的收件箱(`followup()` 的语义
|
|
@@ -1431,7 +1618,12 @@
|
|
|
1431
1618
|
// (比如正在调工具)时,鲸鱼娘还是要留着,那是它唯一该出现的时候。
|
|
1432
1619
|
var busy = (state.running || queued > 0) && !state.live;
|
|
1433
1620
|
el.hidden = !busy;
|
|
1434
|
-
if (busy)
|
|
1621
|
+
if (busy) {
|
|
1622
|
+
startWork();
|
|
1623
|
+
// 台词每次随快照重算一遍。**必须放在 startWork 之后**:startWork 会先画第 0 张,
|
|
1624
|
+
// 反过来的话它会把这句台词盖掉,用户看到的是「收到,这就去办」而不是她的自说自话。
|
|
1625
|
+
maybeSpeak();
|
|
1626
|
+
} else stopWork();
|
|
1435
1627
|
paintQueue();
|
|
1436
1628
|
}
|
|
1437
1629
|
|
|
@@ -1788,6 +1980,8 @@
|
|
|
1788
1980
|
// 正在流出来的那一段。服务端只在自己决定要显示之后才写它,所以这里是空字符串
|
|
1789
1981
|
// 不代表模型没在写,只代表「还不到显示的时候」。
|
|
1790
1982
|
if ('live' in snap) state.live = snap.live || '';
|
|
1983
|
+
// 模型此刻的自言自语(鲸鱼娘的台词)。null = 这会儿没话说,气泡回到该姿势自己的词条。
|
|
1984
|
+
if ('thought' in snap) state.thought = snap.thought || null;
|
|
1791
1985
|
if (snap.history) state.history = snap.history;
|
|
1792
1986
|
// 「服务端正去读这个会话的完整记录」。点开一个没看过的会话时,那一串要读一会儿
|
|
1793
1987
|
// 才补上来(最大的那条日志 20 MB、约半秒),这期间不能装作「这个会话是空的」。
|
|
@@ -2366,12 +2560,17 @@
|
|
|
2366
2560
|
if ((one.custom || '').trim()) answer.custom = one.custom.trim()
|
|
2367
2561
|
return answer
|
|
2368
2562
|
})
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2563
|
+
// 三种结果说三句不同的话。**没成事就不收卡片**:收掉了就再也没地方说这句话,
|
|
2564
|
+
// 用户看到的只会是「点了没反应」。
|
|
2565
|
+
sendAnswer(ask.id, answers).then(function (verdict) {
|
|
2566
|
+
if (verdict === 'failed') {
|
|
2567
|
+
toast('没送出去,看一眼和电脑的连接,再点一次')
|
|
2568
|
+
return
|
|
2569
|
+
}
|
|
2570
|
+
ask = null
|
|
2571
|
+
$('askCard').classList.remove('on')
|
|
2572
|
+
if (verdict === 'late') toast('这道题已经交回电脑了,去电脑上答')
|
|
2573
|
+
})
|
|
2375
2574
|
})
|
|
2376
2575
|
es.addEventListener('question', function (e) {
|
|
2377
2576
|
let payload = null
|
package/lib/pairing.js
CHANGED
|
@@ -16,8 +16,13 @@ const HINTS = {
|
|
|
16
16
|
// 有两条来源,只有 Tailscale 那条依赖手机上的 MagicDNS**——公网隧道用的是
|
|
17
17
|
// Cloudflare 的域名,谁都能解析。所以手机上 Tailscale 的 DNS 设置没开的用户,
|
|
18
18
|
// 想要加密连接应该走这一条,而原来的说明里完全没告诉他这一点。
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
//
|
|
20
|
+
// 2026-09-27 改口径:原来这里写的是「浏览器肯不肯给用通知和麦克风看的就是这个」,
|
|
21
|
+
// 那两样我们**都没有实现**(系统通知 2026-09-25 起入口就是藏着的,麦克风图标也已
|
|
22
|
+
// 移除,两条都有测试钉着"别再被加回来")。拿没实现的功能当理由,是把用户往一个
|
|
23
|
+
// 不存在的东西上引。加密本身成立,就只说加密——**别再改回去**。
|
|
24
|
+
public: '不用装任何东西,出门在外也能用,而且它也是加密的(地址栏上会出现 https 那把锁)。'
|
|
25
|
+
+ '刚打开时等半分钟再扫;地址每次重启 DSH 会换',
|
|
21
26
|
// 这条和上面那条 Tailscale 是**同一台电脑、同一个服务**,区别只在连接加不加密。
|
|
22
27
|
// 说清楚「加密能换来什么」——不然用户看不出两条码为什么要并存。
|
|
23
28
|
//
|
|
@@ -38,8 +43,10 @@ const HINTS = {
|
|
|
38
43
|
// 2026-09-25 用户实测走通了:他手机上装的正是 MeshArc,**换成官方客户端之后
|
|
39
44
|
// 那条加密地址就打开了**。所以这一句要给出真正的解法——换客户端,而不是绕开。
|
|
40
45
|
// 原来只写了「用不了就换别的路」,那是在绕,不是在解决。
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
// 2026-09-27 改口径(原因同上一条 public):不再拿"手机浏览器只在加密连接上才肯给用
|
|
47
|
+
// 通知和麦克风"当理由——那两样都没实现。加密本身是实打实的好处,就只说加密。
|
|
48
|
+
'tailscale-https': '出门在外也能用,而且连接是加密的(地址栏上会出现 https 那把锁)。'
|
|
49
|
+
+ '手机上的 Tailscale 得能解析你这个网络的域名:HarmonyOS 上的第三方'
|
|
43
50
|
+ '客户端(MeshArc)不支持这一点,换成官方客户端就能用。实在换不了,就退到上面那条'
|
|
44
51
|
+ '明文的(它用 IP,不需要解析),或者开公网隧道那条(它也是加密的,不挑手机)',
|
|
45
52
|
}
|
package/lib/serve.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 用 Tailscale 自带的 HTTPS 地址,把本机服务暴露给 tailnet。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 为什么值得做:浏览器只有在安全上下文(https)下才肯把完整能力交出来,明文 http
|
|
5
|
+
* 一律打折——这正是当初做这条路的理由。Tailscale 的 serve 给的是 Let's Encrypt 签的
|
|
6
|
+
* 真证书,浏览器认。
|
|
7
|
+
*
|
|
8
|
+
* **2026-09-27 修正这段自述**:原来这里写的是"三样东西被明文 http 挡着——完成后提醒
|
|
9
|
+
* (系统通知)、语音输入(麦克风)、剪贴板的完整能力"。后来其中两样**都没有真正落地**:
|
|
10
|
+
* 系统通知 2026-09-25 起入口一直藏着(锁屏会冻结后台页面,试到底的结论是这条路走不通),
|
|
11
|
+
* 麦克风图标也已移除,两条都有测试钉着"别再被加回来"。如今真正成立的只有"连接加密"
|
|
12
|
+
* 这一条(剪贴板另有 execCommand 退路,明文下也能用,只是不那么顺)。所以对用户说话时
|
|
13
|
+
* 只讲加密,**别再拿通知和麦克风当理由**(见 lib/pairing.js 里那两条提示的注释)。
|
|
7
14
|
*
|
|
8
15
|
* **这不是新的一条路,是 Tailscale 那条路上的一个升级。** 原来的
|
|
9
16
|
* `http://100.92.105.99:3090` 照旧能用,这里多给一个
|
package/lib/server.js
CHANGED
|
@@ -237,7 +237,8 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
237
237
|
if (watch) clearInterval(watch)
|
|
238
238
|
resolve(value)
|
|
239
239
|
}
|
|
240
|
-
|
|
240
|
+
// 题目本身也存进来(不只是 settle):手机中途重连上来时要能补发给它,见 replayQuestions。
|
|
241
|
+
waitingQuestions.set(id, { settle, agentId, questions })
|
|
241
242
|
broadcast('question', { id, agentId, questions })
|
|
242
243
|
// 用户选的是「一直等,不还回电脑」——但那不等于「手机都没了还等」。
|
|
243
244
|
// 手机一旦全断开(锁屏、关了页面、网断),立刻收摊还给电脑,否则会永远卡住。
|
|
@@ -247,6 +248,27 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
247
248
|
})
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
/**
|
|
252
|
+
* 把正在等的题补发给**刚连上来的那一个**连接。
|
|
253
|
+
*
|
|
254
|
+
* 题目只在上面推一次,没有第二次。可手机这一端并不总是接着:
|
|
255
|
+
* 页面正在重连、锁屏醒来刚接上、切了个网络——那一下就错过了,而电脑那边还在等,
|
|
256
|
+
* 手机上却什么都看不见(2026-09-27 用户报的「电脑弹了选项、手机上什么都没有」,
|
|
257
|
+
* 有这一类情形在里面)。所以新连接一进来就把还在等的题发给它。
|
|
258
|
+
*
|
|
259
|
+
* **只发这一个新连接,不重播给所有人**:已经在看的那个手机可能已经勾好了选项,
|
|
260
|
+
* 再推一次会把它的勾清掉。
|
|
261
|
+
*/
|
|
262
|
+
function replayQuestions(res) {
|
|
263
|
+
for (const [id, entry] of waitingQuestions) {
|
|
264
|
+
try {
|
|
265
|
+
res.write(`event: question\ndata: ${JSON.stringify({ id, agentId: entry.agentId, questions: entry.questions })}\n\n`)
|
|
266
|
+
} catch {
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
250
272
|
// 把这两个能力放到插件够得着的固定位置(见文件上方 `miniControl` 的说明)。
|
|
251
273
|
// 放在这里是因为它俩都已定义(函数声明本来也会提升),且不受下面那堆路由逻辑影响。
|
|
252
274
|
miniControl.hasPhone = hasPhone
|
|
@@ -427,6 +449,9 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
427
449
|
})
|
|
428
450
|
res.write(': connected\n\n')
|
|
429
451
|
clients.add(res)
|
|
452
|
+
// 留痕:手机连没连上、一共有几台,就看这一句。2026-09-27 测自言自语的这天
|
|
453
|
+
// 正是卡在这儿——「她怎么没说话」和「手机压根没连上」当时根本分不开。
|
|
454
|
+
console.log(`[dsh-mini-remote] 手机连上了,现在连着 ${clients.size} 台`)
|
|
430
455
|
|
|
431
456
|
const beat = setInterval(() => {
|
|
432
457
|
try {
|
|
@@ -441,13 +466,21 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
441
466
|
// 连上就先补一次全量状态,手机刷新后不用再单独拉一次。
|
|
442
467
|
res.write(`event: state\ndata: ${JSON.stringify(store.snapshot())}\n\n`)
|
|
443
468
|
|
|
469
|
+
// 接着把还在等手机回答的题补给它——重连上来的那一下最容易错过(见 replayQuestions)。
|
|
470
|
+
replayQuestions(res)
|
|
471
|
+
|
|
444
472
|
// 顺带把这个会话的完整历史准备好:手机上「刷新一下就只剩重启之后的事」
|
|
445
473
|
// 就是这么来的(手里那份只有插件在场时看见的)。读完会再广播一份快照。
|
|
446
474
|
onSessionOpened?.(store.snapshot().boundSessionId)
|
|
447
475
|
|
|
476
|
+
// 断开只记一次:req 和 res 各会 close 一次,不挡一下就会打两遍(含义却相反)。
|
|
477
|
+
let said = false
|
|
448
478
|
const cleanup = () => {
|
|
479
|
+
if (said) return
|
|
480
|
+
said = true
|
|
449
481
|
clearInterval(beat)
|
|
450
482
|
clients.delete(res)
|
|
483
|
+
console.log(`[dsh-mini-remote] 手机断开了,还剩 ${clients.size} 台`)
|
|
451
484
|
}
|
|
452
485
|
req.on('close', cleanup)
|
|
453
486
|
res.on('close', cleanup)
|
|
@@ -628,6 +661,9 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
628
661
|
const waiting = waitingQuestions.get(body?.id)
|
|
629
662
|
if (!waiting) {
|
|
630
663
|
// 409 而不是 200:多半是这个提问早就结束了,如实说,别装作收下了。
|
|
664
|
+
// 落一句痕:这是「手机答了、电脑没动静」唯一能查到的线索——手机那边原来
|
|
665
|
+
// 把失败咽掉了,插件这边再不留痕,下次还是只能靠猜(2026-09-27)。
|
|
666
|
+
console.log('[dsh-mini-remote] 手机上答晚了:这道题已经交回电脑(' + String(body?.id) + '),没接')
|
|
631
667
|
sendJson(res, 409, { ok: false, error: '这个提问已经结束了。' })
|
|
632
668
|
return
|
|
633
669
|
}
|
package/lib/store.js
CHANGED
|
@@ -33,6 +33,14 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
33
33
|
* 任何一条广播都能把它补上。
|
|
34
34
|
*/
|
|
35
35
|
liveBySession: {},
|
|
36
|
+
/**
|
|
37
|
+
* 模型此刻的「自言自语」:sessionId -> { text, at }。**同样故意不落盘**。
|
|
38
|
+
*
|
|
39
|
+
* 只给手机上的鲸鱼娘当台词用,和 latest/history 完全分开——它既不是回答,
|
|
40
|
+
* 也不是历史的一部分(历史有 50 条上限,思考会把它挤爆)。一轮结束就清掉,
|
|
41
|
+
* 所以刷新页面看到的是「当前这一句」,而不是上一次的残留。
|
|
42
|
+
*/
|
|
43
|
+
thoughtBySession: {},
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
/** 已知会话的元信息:sessionId -> { id, title, running, lastActivity, seq } */
|
|
@@ -170,6 +178,18 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
170
178
|
return sessionId ? (state.liveBySession[sessionId] ?? '') : ''
|
|
171
179
|
}
|
|
172
180
|
|
|
181
|
+
/**
|
|
182
|
+
* 模型此刻的自言自语(鲸鱼娘的台词)。
|
|
183
|
+
*
|
|
184
|
+
* 和 live 分开存,**不是为了整齐**:页面上的鲸鱼娘只要发现 live 非空就会整块让位
|
|
185
|
+
* (那是「正在流出来的回答」的规矩),把思考塞进 live 会让该出现台词的时候鲸鱼娘
|
|
186
|
+
* 直接消失。
|
|
187
|
+
*/
|
|
188
|
+
function thoughtOf(sessionId) {
|
|
189
|
+
const t = sessionId ? state.thoughtBySession[sessionId] : null
|
|
190
|
+
return t ? { text: t.text, at: t.at } : null
|
|
191
|
+
}
|
|
192
|
+
|
|
173
193
|
/**
|
|
174
194
|
* 只保留最近碰过的若干会话的数据,免得这个 JSON 文件无限长大。
|
|
175
195
|
* 被丢掉的会话切过去会显示「还没有回复」——**这比显示别人的回答强**。
|
|
@@ -182,6 +202,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
182
202
|
delete state.historyBySession[id]
|
|
183
203
|
delete state.latestBySession[id]
|
|
184
204
|
delete state.liveBySession[id]
|
|
205
|
+
delete state.thoughtBySession[id]
|
|
185
206
|
}
|
|
186
207
|
}
|
|
187
208
|
|
|
@@ -297,24 +318,33 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
297
318
|
* interrupted 是「这一轮被中止了,下面只是已经吐出来的那半句」。
|
|
298
319
|
* 手机要把它标出来——不标的话,按了停止之后冒出来的半句话看着像模型答崩了。
|
|
299
320
|
*/
|
|
300
|
-
pushReply({ text, sessionId, reason, interrupted }) {
|
|
301
|
-
|
|
321
|
+
pushReply({ text, sessionId, reason, interrupted, timestamp }) {
|
|
322
|
+
// 同上:事件带过来的 `time` 优先。合成时两边的钟不一样,同一条回复就会
|
|
323
|
+
// 既从硬盘来一遍、又从手里这份来一遍,手机上显示两遍。
|
|
324
|
+
const at = timestamp ?? Date.now()
|
|
302
325
|
const session = sessions.get(sessionId)
|
|
303
326
|
state.latestBySession[sessionId] = {
|
|
304
|
-
text, sessionId, title: session?.title ?? '', timestamp, reason,
|
|
327
|
+
text, sessionId, title: session?.title ?? '', timestamp: at, reason,
|
|
305
328
|
interrupted: interrupted === true,
|
|
306
329
|
}
|
|
307
330
|
pushHistory(sessionId, {
|
|
308
|
-
role: 'assistant', text, timestamp, sessionId, interrupted: interrupted === true,
|
|
331
|
+
role: 'assistant', text, timestamp: at, sessionId, interrupted: interrupted === true,
|
|
309
332
|
})
|
|
310
333
|
persist()
|
|
311
334
|
},
|
|
312
335
|
|
|
313
336
|
/** 用户(手机上或电脑上)发出了一条指令。 */
|
|
314
|
-
pushUser({ text, sessionId, id }) {
|
|
337
|
+
pushUser({ text, sessionId, id, timestamp }) {
|
|
315
338
|
// id 要留着:排队中的指令靠它和 agent 收件箱里的那一条对上号,
|
|
316
339
|
// 界面上才能标出「这条还在排队」、也才能撤掉它。
|
|
317
|
-
|
|
340
|
+
//
|
|
341
|
+
// 时间戳要是有事件带过来的那个 `time`,就用它,别用 Date.now()——理由和
|
|
342
|
+
// pushCommand 那段一模一样:这一条要和硬盘重放出来的那份按时间戳合成,
|
|
343
|
+
// **两边得是同一个钟**。差一毫秒就够它被当成「新的一条」再接一遍,手机上
|
|
344
|
+
// 同一条指令显示两遍(2026-09-27 用户实机报的,实测两份差 1 毫秒)。
|
|
345
|
+
pushHistory(sessionId, {
|
|
346
|
+
role: 'user', text, timestamp: timestamp ?? Date.now(), sessionId, id,
|
|
347
|
+
})
|
|
318
348
|
persist()
|
|
319
349
|
},
|
|
320
350
|
|
|
@@ -389,6 +419,20 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
389
419
|
else delete state.liveBySession[sessionId]
|
|
390
420
|
},
|
|
391
421
|
|
|
422
|
+
/**
|
|
423
|
+
* 记下模型此刻的自言自语,给鲸鱼娘当台词。传 null 就是「没话说了」,清掉。
|
|
424
|
+
*
|
|
425
|
+
* 同样不 persist():它活不过一轮,写盘只会留下一句没头没尾的旧台词。
|
|
426
|
+
*/
|
|
427
|
+
setThought(sessionId, thought) {
|
|
428
|
+
if (!sessionId) return
|
|
429
|
+
if (thought && thought.text) {
|
|
430
|
+
state.thoughtBySession[sessionId] = { text: thought.text, at: thought.at ?? Date.now() }
|
|
431
|
+
} else {
|
|
432
|
+
delete state.thoughtBySession[sessionId]
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
|
|
392
436
|
/** 手机是否真的在看着——决定要不要额外发系统推送。 */
|
|
393
437
|
isForeground() {
|
|
394
438
|
return state.presence === 'foreground' && Date.now() - state.lastSeen < 60_000
|
|
@@ -436,6 +480,13 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
436
480
|
* 当成答案——真正的答案还是要等不含工具调用的那一步落定(见 events.js)。
|
|
437
481
|
*/
|
|
438
482
|
live: liveOf(target),
|
|
483
|
+
/**
|
|
484
|
+
* 模型此刻的自言自语(鲸鱼娘的气泡台词),没有就是 null。
|
|
485
|
+
*
|
|
486
|
+
* **第三次重申它的边界**:这不是回答、不进历史、不参与「有没有新回复」的
|
|
487
|
+
* 判断。哪怕它错乱到显示了上一句,代价也只是气泡里一句话旧了。
|
|
488
|
+
*/
|
|
489
|
+
thought: thoughtOf(target),
|
|
439
490
|
boundSessionId: state.boundSessionId,
|
|
440
491
|
/**
|
|
441
492
|
* 「正在执行」必须跟着会话走。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mini-remote",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "DSH 极简移动端遥控器:只把「你的指令」和「AI 的最终回复」送到手机,屏蔽全部中间过程。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -36,13 +36,21 @@
|
|
|
36
36
|
"url": "https://github.com/xingzhen199186/dsh-mini-remote/issues"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@deepseek-ai/cordis": "^4.0.2"
|
|
39
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
40
|
+
"@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0",
|
|
41
|
+
"@deepseek-ai/schemastery": "^3.18.3"
|
|
40
42
|
},
|
|
41
43
|
"peerDependenciesMeta": {
|
|
42
44
|
"@deepseek-ai/cordis": {
|
|
43
45
|
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@deepseek-ai/dsh": {
|
|
48
|
+
"optional": true
|
|
44
49
|
}
|
|
45
50
|
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@deepseek-ai/schemastery": "^3.18.3"
|
|
53
|
+
},
|
|
46
54
|
"keywords": [
|
|
47
55
|
"deepseek-harness",
|
|
48
56
|
"dsh",
|