@sjhmars/happy-bridge 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@
21
21
  - 新建:只能选已经在网页登记过的工作区。不会在磁盘上 mkdir。App 里「new worktree」会失败(故意不暴露 bash)。
22
22
  - 发文字:已注册 `/命令` 走命令通道;否则当聊天。`/skill名` 仍当普通消息,由 harness 注入。
23
23
  - 发图片:相册/拍照/粘贴后,会随下一条文字(也可以只发图)进同一场电脑对话,模型能看见图。`.java` / `.txt` 等普通文件会先下载到工作区 `happy-inbox/`,再走电脑原来的 `read` 工具读内容(和你在仓库里打开一个文件一样)。PDF、Word 这类不是纯文本的文件,电脑的 `read` 会拒绝,插件也不会自己拆文档。远程档至少「能聊」。输入框左边应有图片按钮;发出后电脑网页会出现图片,手机会转圈等回复。电脑网页贴的图也会按同一套加密上传推到手机。
24
- - 工具批准、计划审阅、选择题:和网页黄框/提问框赛跑,谁先点谁算。手机点「始终允许」只在这次网页进程里记住。选择题走 Happy 表单通道:选项照列,每题下面还有自由输入框,提交后卡片上会一直显示你选的答案(旧版 App 没有这条通道,见 `questionChannel`)。提问还在等的时候,用输入框打字等于自己填;审阅计划时打字会关掉审阅,这句话当普通聊天发出去。批完/拒完的卡片会停在「已批准/已拒绝」状态,按钮失效。远程档至少「能批」才能从手机点。
24
+ - 工具批准、计划审阅、选择题:和网页黄框/提问框赛跑,谁先点谁算。手机点「始终允许」只在这次网页进程里记住。选择题走 Happy 表单通道,每道题末尾多一个「✏️ 自定义…」选项:点它之后在输入框写出你的想法直接发送,那条文本就是这道题的答案(其他题已选的项保留);答完后卡片会显示所选内容和自定义文本。旧版 App 没有这条通道,见 `questionChannel`。提问还在等的时候,直接用输入框打字也等于自己填;审阅计划时打字会关掉审阅,这句话当普通聊天发出去。批完/拒完的卡片会停在「已批准/已拒绝」状态,按钮失效。远程档至少「能批」才能从手机点。
25
25
  - 电脑正在生成时,手机可以点停止(Happy App 的 Stop)。远程档至少「能聊」。只看档点了不会停。
26
26
  - 助手回复按整段推到手机(生成中会转圈)。思考收成一行 **Think · 摘要** 的 Note 卡片,点进去看全文。工具用 Happy 认识的名字(Grep / Read / Bash),描述跟网页那一行摘要一样。Happy 没有网页同款图标。
27
27
 
@@ -74,7 +74,7 @@ pnpm dsh web --port 3080
74
74
 
75
75
  `remoteGrant`:`watch` | `chat` | `approve` | `full`。设置页下拉框会写入这份配置。会话里也可以 `/remote approve`(只改当前进程,不写设置)。档位「完整」时,手机换模型和思考强度走电脑同一条 `session.selectModel`,网页模型栏和推理等级会跟着变。电脑自己换模型和思考强度同样会写回手机。网页加了新模型后,已镜像的手机会话会重新收到完整名单。
76
76
 
77
- `questionChannel`:`communications`(默认)| `permission`。选择题走的 Happy 通道。`communications` 是 App 的表单通道:每道题带自由输入框,提交后卡片常驻显示你选的答案,会话列表也会显示「等回答」;需要较新版 Happy App。旧版 App 没有这条通道时配成 `permission`:题目选项仍可见可答,但没有自由输入框(此时可在输入框打字作答),答完卡片也不回显。
77
+ `questionChannel`:`communications`(默认)| `permission`。选择题走的 Happy 通道。`communications` 是 App 的表单通道:答完卡片常驻显示你选的答案,会话列表也会显示「等回答」;需要较新版 Happy App。`permission` 是旧通道:题目选项仍可见可答,答完卡片不回显。两条通道都没有内联输入框——自由回答都靠每题末尾的「✏️ 自定义…」选项 + 输入框打字(`communications` 下,零选项的题目会弹 App 全屏表单,那里有真正的输入框)。
78
78
 
79
79
  ## 已知限制
80
80
 
package/lib/client.js CHANGED
@@ -17,21 +17,21 @@ window.__ModuleLoader__.load({
17
17
  document.head.appendChild(tag);
18
18
  }
19
19
  var HappyBridgeCard_module_css_default = {
20
- "body": "l9rBEq_body",
20
+ "checkbox": "l9rBEq_checkbox",
21
+ "label": "l9rBEq_label",
22
+ "status": "l9rBEq_status",
23
+ "headText": "l9rBEq_headText",
24
+ "button": "l9rBEq_button",
21
25
  "row": "l9rBEq_row",
22
- "description": "l9rBEq_description",
26
+ "qr": "l9rBEq_qr",
23
27
  "name": "l9rBEq_name",
24
- "headText": "l9rBEq_headText",
25
28
  "chevron": "l9rBEq_chevron",
26
- "qr": "l9rBEq_qr",
27
- "card": "l9rBEq_card",
28
29
  "error": "l9rBEq_error",
29
- "button": "l9rBEq_button",
30
- "status": "l9rBEq_status",
31
- "checkbox": "l9rBEq_checkbox",
32
- "label": "l9rBEq_label",
30
+ "header": "l9rBEq_header",
31
+ "body": "l9rBEq_body",
33
32
  "select": "l9rBEq_select",
34
- "header": "l9rBEq_header"
33
+ "card": "l9rBEq_card",
34
+ "description": "l9rBEq_description"
35
35
  };
36
36
  //#endregion
37
37
  //#region src/client/HappyBridgeCard.tsx
package/lib/index.js CHANGED
@@ -1699,19 +1699,6 @@ function answersFromHappy(answers, questions) {
1699
1699
  });
1700
1700
  }
1701
1701
  /**
1702
- * Fill every still-open question with the typed chat line as `custom`.
1703
- * @param questions - harness questions in order.
1704
- * @param text - the App composer line.
1705
- * @returns one answer row per question.
1706
- */
1707
- function customAnswersFromText(questions, text) {
1708
- return questions.map((question) => ({
1709
- id: question.id,
1710
- selected: [],
1711
- custom: text
1712
- }));
1713
- }
1714
- /**
1715
1702
  * The option label that declines a plan-review question.
1716
1703
  * @param question - first question of a plan-review `ask`.
1717
1704
  * @returns the non-approve option label, or Keep planning.
@@ -1744,6 +1731,41 @@ function parsePermissionRpc(params) {
1744
1731
  return result;
1745
1732
  }
1746
1733
  /**
1734
+ * Synthetic last option the bridge appends to every question: the App's inline
1735
+ * form has no free-text input, so "custom answer" is a two-tap flow — pick
1736
+ * this option, then type the real answer in the composer.
1737
+ */
1738
+ const CUSTOM_ANSWER_LABEL = "✏️ 自定义…";
1739
+ /**
1740
+ * Merge composer text with a deferred (marker-picked) submission.
1741
+ * - No deferred selections: the text becomes `custom` for every question
1742
+ * (the original "type while waiting" behavior).
1743
+ * - With deferred selections: real options are kept per question; questions
1744
+ * that picked the marker — and questions never answered — take the text
1745
+ * as `custom`.
1746
+ * @param questions - harness questions in order.
1747
+ * @param text - the composer line.
1748
+ * @param deferred - per-question submitted options, keyed by question id.
1749
+ * @returns one answer row per question.
1750
+ */
1751
+ function mergeCustomAnswers(questions, text, deferred) {
1752
+ return questions.map((question) => {
1753
+ const selected = deferred?.[question.id];
1754
+ if (selected === void 0) return {
1755
+ id: question.id,
1756
+ selected: [],
1757
+ custom: text
1758
+ };
1759
+ const real = selected.filter((option) => option !== CUSTOM_ANSWER_LABEL);
1760
+ const marked = real.length !== selected.length;
1761
+ return {
1762
+ id: question.id,
1763
+ selected: real,
1764
+ ...!marked && real.length > 0 ? {} : { custom: text }
1765
+ };
1766
+ });
1767
+ }
1768
+ /**
1747
1769
  * Translate Happy communications answers (`{ [question id]: { options, custom } }`)
1748
1770
  * into harness answer rows. Options become `selected`; free text becomes `custom`.
1749
1771
  * @param answers - communication RPC answers keyed by question id.
@@ -3714,12 +3736,12 @@ var HappyBridge = class {
3714
3736
  const files = await this.drainPhoneFiles(link);
3715
3737
  const pending = link.pendingHuman;
3716
3738
  if (pending?.kind === "ask") {
3717
- const answers = customAnswersFromText(pending.questions, message.text);
3739
+ const answers = mergeCustomAnswers(pending.questions, message.text, pending.deferred);
3718
3740
  pending.resolve({ answers });
3719
3741
  delete link.pendingHuman;
3720
3742
  if (pending.communication !== void 0) this.completeCommunication(link, pending.id, pending.communication, "answered", Object.fromEntries(answers.map((row) => [row.id, {
3721
- options: [],
3722
- custom: row.custom
3743
+ options: row.selected,
3744
+ ...row.custom === void 0 ? {} : { custom: row.custom }
3723
3745
  }])));
3724
3746
  else this.clearRequest(link, pending.id, "canceled", pending.request);
3725
3747
  link.socket.sendToolEnd(pending.id);
@@ -3765,7 +3787,10 @@ var HappyBridge = class {
3765
3787
  const model = messageModelCode(meta);
3766
3788
  const effort = messageEffort(meta);
3767
3789
  const permissionMode = meta.permissionMode;
3768
- if (model !== void 0) {
3790
+ const publishedModel = this.lastPublished.get(agent.id)?.model;
3791
+ const modelIsEcho = model !== void 0 && publishedModel !== void 0 && sameCatalogPick({ model: publishedModel }, { model });
3792
+ if (model !== void 0) this.log(`手机消息 meta.model=${model}${modelIsEcho ? "(自己发布的回声,忽略)" : "(用户选择,准备应用)"}`);
3793
+ if (modelIsEcho) {} else if (model !== void 0) {
3769
3794
  if (!grantAtLeast(this.config.remoteGrant, "full")) link.socket.sendText("service", "改模型需要远程档「完整」,这条消息仍会发出。");
3770
3795
  else this.applyModel(agent, model, typeof effort === "string" ? effort : effort === null ? null : void 0);
3771
3796
  } else if (effort === null || typeof effort === "string") {
@@ -3786,6 +3811,7 @@ var HappyBridge = class {
3786
3811
  if (pick === void 0) return;
3787
3812
  const published = this.lastPublished.get(dshId);
3788
3813
  if (published === void 0 || sameCatalogPick(published, pick)) return;
3814
+ this.log(`手机模型选择 ${pick.model ?? ""}${pick.effort ?? ""}(上次发布 ${published.model ?? "无"}),准备应用`);
3789
3815
  const link = this.links.get(dshId);
3790
3816
  const agent = link === void 0 ? void 0 : this.requireAgent(link);
3791
3817
  if (agent === void 0) return;
@@ -3863,6 +3889,7 @@ var HappyBridge = class {
3863
3889
  */
3864
3890
  onHostModelSelected(sessionId, selection) {
3865
3891
  if (this.hostSelectFromPhone) return;
3892
+ this.log(`网页选模型 ${selection.provider}/${selection.model}${selection.reasoningEffort ?? ""},同步到手机`);
3866
3893
  const next = {
3867
3894
  provider: selection.provider,
3868
3895
  model: selection.model,
@@ -4149,10 +4176,13 @@ var HappyBridge = class {
4149
4176
  id: question.id,
4150
4177
  header: question.header ?? question.question.slice(0, 24),
4151
4178
  question: question.question,
4152
- options: (question.options ?? []).map((option) => ({
4179
+ options: [...(question.options ?? []).map((option) => ({
4153
4180
  label: option.label,
4154
4181
  ...option.description === void 0 ? {} : { description: option.description }
4155
- })),
4182
+ })), ...(question.options ?? []).length > 0 ? [{
4183
+ label: CUSTOM_ANSWER_LABEL,
4184
+ description: "选中并提交后,到下方聊天输入框写出你的回答"
4185
+ }] : []],
4156
4186
  multiSelect: question.multiSelect === true,
4157
4187
  allowCustom: true,
4158
4188
  required: true
@@ -4184,10 +4214,13 @@ var HappyBridge = class {
4184
4214
  const arguments_ = { questions: request.questions.map((question) => ({
4185
4215
  question: question.question,
4186
4216
  header: question.header ?? question.question.slice(0, 24),
4187
- options: (question.options ?? []).map((option) => ({
4217
+ options: [...(question.options ?? []).map((option) => ({
4188
4218
  label: option.label,
4189
4219
  description: option.description ?? ""
4190
- })),
4220
+ })), {
4221
+ label: CUSTOM_ANSWER_LABEL,
4222
+ description: "选中并提交后,到下方聊天输入框写出你的回答"
4223
+ }],
4191
4224
  multiSelect: question.multiSelect === true
4192
4225
  })) };
4193
4226
  link.pendingHuman = {
@@ -4273,6 +4306,17 @@ var HappyBridge = class {
4273
4306
  return;
4274
4307
  }
4275
4308
  const mapped = answersFromHappy(rpc.updatedInput?.answers, pending.questions);
4309
+ const deferred = {};
4310
+ let wantsCustom = false;
4311
+ for (const row of mapped) {
4312
+ deferred[row.id] = row.selected;
4313
+ if (row.selected.includes("✏️ 自定义…")) wantsCustom = true;
4314
+ }
4315
+ if (wantsCustom) {
4316
+ pending.deferred = deferred;
4317
+ link.socket.sendText("service", "已选自定义回答:请在输入框输入你的想法,直接发送即可。");
4318
+ return;
4319
+ }
4276
4320
  pending.resolve({ answers: mapped.map((row) => ({
4277
4321
  id: row.id,
4278
4322
  selected: row.selected
@@ -4291,10 +4335,21 @@ var HappyBridge = class {
4291
4335
  if (link === void 0 || pending === void 0 || pending.kind !== "ask") return;
4292
4336
  if (pending.id !== rpc.id || pending.communication === void 0) return;
4293
4337
  if (rpc.status !== "answered" || rpc.answers === void 0) {
4294
- pending.reject(new UserQuestionError("the user cancelled ask_user_question", "ASK_CANCELLED"));
4295
- delete link.pendingHuman;
4338
+ pending.deferred ??= {};
4296
4339
  this.completeCommunication(link, rpc.id, pending.communication, "cancelled");
4297
- link.socket.sendToolEnd(rpc.id);
4340
+ return;
4341
+ }
4342
+ const deferred = {};
4343
+ let wantsCustom = false;
4344
+ for (const [questionId, answer] of Object.entries(rpc.answers)) {
4345
+ deferred[questionId] = answer.options;
4346
+ if (answer.options.includes("✏️ 自定义…")) wantsCustom = true;
4347
+ }
4348
+ if (wantsCustom) {
4349
+ pending.deferred = deferred;
4350
+ this.completeCommunication(link, rpc.id, pending.communication, "answered", rpc.answers);
4351
+ link.socket.sendToolStart(rpc.id, "request_user_input", pending.communication.form, "需要你回答", "已选自定义:请在下方输入框输入你的回答后发送");
4352
+ link.socket.sendText("service", "已选自定义回答:请在输入框输入你的想法,直接发送即可。");
4298
4353
  return;
4299
4354
  }
4300
4355
  pending.resolve({ answers: answersFromCommunication(rpc.answers, pending.questions) });
@@ -4359,8 +4414,10 @@ var HappyBridge = class {
4359
4414
  }, this.credentials.machineId, sessionLabel(events), link.agent === void 0 ? void 0 : this.currentModel(link.agent), this.config.remoteGrant);
4360
4415
  link.socket.updateMetadata(metadata);
4361
4416
  const pick = catalogModelPick(metadata);
4417
+ const previousPick = this.lastPublished.get(link.dshId);
4362
4418
  if (pick === void 0) this.lastPublished.delete(link.dshId);
4363
4419
  else this.lastPublished.set(link.dshId, pick);
4420
+ if (pick === void 0 || sameCatalogPick(previousPick, pick) === false) this.log(`发布目录:模型 ${pick?.model ?? "(无)"} 思考 ${String(pick?.effort ?? "(无)")}`);
4364
4421
  }
4365
4422
  pushAllMetadata() {
4366
4423
  for (const link of this.links.values()) if (!link.parked) this.pushMetadata(link);
@@ -85,6 +85,31 @@ export declare function planReviewDeclineLabel(question: {
85
85
  * @returns id, approved flag, optional Always-allow decision, and answers.
86
86
  */
87
87
  export declare function parsePermissionRpc(params: unknown): PermissionRpc;
88
+ /**
89
+ * Synthetic last option the bridge appends to every question: the App's inline
90
+ * form has no free-text input, so "custom answer" is a two-tap flow — pick
91
+ * this option, then type the real answer in the composer.
92
+ */
93
+ export declare const CUSTOM_ANSWER_LABEL = "\u270F\uFE0F \u81EA\u5B9A\u4E49\u2026";
94
+ /**
95
+ * Merge composer text with a deferred (marker-picked) submission.
96
+ * - No deferred selections: the text becomes `custom` for every question
97
+ * (the original "type while waiting" behavior).
98
+ * - With deferred selections: real options are kept per question; questions
99
+ * that picked the marker — and questions never answered — take the text
100
+ * as `custom`.
101
+ * @param questions - harness questions in order.
102
+ * @param text - the composer line.
103
+ * @param deferred - per-question submitted options, keyed by question id.
104
+ * @returns one answer row per question.
105
+ */
106
+ export declare function mergeCustomAnswers(questions: readonly {
107
+ id: string;
108
+ }[], text: string, deferred?: Record<string, string[]>): {
109
+ id: string;
110
+ selected: string[];
111
+ custom?: string;
112
+ }[];
88
113
  /**
89
114
  * Translate Happy communications answers (`{ [question id]: { options, custom } }`)
90
115
  * into harness answer rows. Options become `selected`; free text becomes `custom`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjhmars/happy-bridge",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "Remote-control bridge: pair a running dsh web/desktop client with the Happy mobile app by QR code, then remote-control the same harness sessions from the phone (send messages, read replies, approve tool calls, switch models)",
5
5
  "keywords": [
6
6
  "deepseek-harness",