Ver código fonte

初始提交: dsh-desktop (Electron 桌面端) + dsh-harmony (HarmonyOS 端) + 资源与图标

Evan 4 dias atrás
commit
34af81ed22
100 arquivos alterados com 6777 adições e 0 exclusões
  1. 35 0
      .gitignore
  2. 62 0
      assets/logo/README.md
  3. BIN
      assets/logo/appicon/appicon-transparent-1024.png
  4. BIN
      assets/logo/appicon/appicon-white-1024.png
  5. BIN
      assets/logo/appicon/appicon-white-1024.webp
  6. BIN
      assets/logo/appicon/appicon-white-216.png
  7. BIN
      assets/logo/appicon/appicon-white-216.webp
  8. 31 0
      assets/logo/concept-a-orbit-spark.svg
  9. 27 0
      assets/logo/concept-b-bridge-h.svg
  10. 37 0
      assets/logo/concept-c-gear-spark.svg
  11. 39 0
      assets/logo/dsh-a-minimal.svg
  12. 34 0
      assets/logo/dsh-b-strap.svg
  13. 34 0
      assets/logo/dsh-c-light.svg
  14. 39 0
      assets/logo/dsh-icon-final.svg
  15. BIN
      assets/logo/exports/icon-1024.png
  16. BIN
      assets/logo/exports/icon-1024.webp
  17. BIN
      assets/logo/exports/icon-216.png
  18. BIN
      assets/logo/exports/icon-216.webp
  19. BIN
      assets/logo/kailin-logo/icon-mark.png
  20. 16 0
      assets/logo/kailin-logo/index.js
  21. 24 0
      assets/logo/kailin-logo/logo-Gtl3ke7j.png
  22. 24 0
      assets/logo/kailin-logo/logo-Gtl3ke7j.svg
  23. BIN
      assets/logo/kailin-logo/logo-full.png
  24. BIN
      assets/logo/kailin-logo/logo.ico
  25. BIN
      assets/logo/kailin-logo/logo.png
  26. 24 0
      assets/logo/kailin-logo/logo.svg
  27. 21 0
      assets/store/MIT-LICENSE.txt
  28. 28 0
      assets/store/about-page-copy.md
  29. 82 0
      assets/store/appgallery-listing.md
  30. 30 0
      assets/store/check-pending.sh
  31. 57 0
      assets/store/launch-checklist.zh.md
  32. 250 0
      assets/store/monetization-design.zh.md
  33. 49 0
      assets/store/poll-until-done.sh
  34. 107 0
      assets/store/privacy-policy.zh.md
  35. 55 0
      assets/store/promo-copy.zh.md
  36. BIN
      assets/store/screenshots/01-main-view.png
  37. BIN
      assets/store/screenshots/02-settings.png
  38. BIN
      assets/store/screenshots/03-conversation.png
  39. 103 0
      assets/store/user-agreement.zh.md
  40. BIN
      dotouchai_logo/DoTouchAI_icon_A_1024.png
  41. BIN
      dotouchai_logo/DoTouchAI_icon_A_1024.webp
  42. BIN
      dotouchai_logo/DoTouchAI_icon_A_216.png
  43. BIN
      dotouchai_logo/DoTouchAI_icon_B_1024.png
  44. BIN
      dotouchai_logo/DoTouchAI_icon_B_1024.webp
  45. BIN
      dotouchai_logo/DoTouchAI_icon_B_216.png
  46. BIN
      dotouchai_logo/DoTouchAI_icon_C_1024.png
  47. BIN
      dotouchai_logo/DoTouchAI_icon_C_1024.webp
  48. BIN
      dotouchai_logo/DoTouchAI_icon_C_216.png
  49. 6 0
      dsh-desktop/.gitignore
  50. 54 0
      dsh-desktop/CHANGELOG.md
  51. 160 0
      dsh-desktop/PRODUCT-PLAN.zh.md
  52. 98 0
      dsh-desktop/README.md
  53. 0 0
      dsh-desktop/app/assets-src/whale-path.txt
  54. BIN
      dsh-desktop/app/assets/icon.icns
  55. BIN
      dsh-desktop/app/assets/trayTemplate.png
  56. BIN
      dsh-desktop/app/assets/trayTemplate@2x.png
  57. 80 0
      dsh-desktop/app/env.js
  58. 347 0
      dsh-desktop/app/main.js
  59. 175 0
      dsh-desktop/app/package-lock.json
  60. 18 0
      dsh-desktop/app/package.json
  61. 123 0
      dsh-desktop/app/plugins.js
  62. 18 0
      dsh-desktop/app/plugins/dsh-voice-client/CHANGELOG.md
  63. 964 0
      dsh-desktop/app/plugins/dsh-voice-client/lib/client.js
  64. 3 0
      dsh-desktop/app/plugins/dsh-voice-client/lib/index.js
  65. 19 0
      dsh-desktop/app/plugins/dsh-voice-client/package.json
  66. 272 0
      dsh-desktop/app/plugins/dsh-voice-server/lib/index-v6.js
  67. 272 0
      dsh-desktop/app/plugins/dsh-voice-server/lib/index.js
  68. 18 0
      dsh-desktop/app/plugins/dsh-voice-server/package.json
  69. 94 0
      dsh-desktop/app/plugins/dsh-voice-server/pnpm-lock.yaml
  70. 19 0
      dsh-desktop/app/plugins/manifest.json
  71. 3 0
      dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/README.md
  72. 1 0
      dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/index.js
  73. BIN
      dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/libonnxruntime.dylib
  74. BIN
      dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/libsherpa-onnx-c-api.dylib
  75. BIN
      dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/libsherpa-onnx-cxx-api.dylib
  76. 65 0
      dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/package.json
  77. BIN
      dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/sherpa-onnx.node
  78. 11 0
      dsh-desktop/app/plugins/sherpa-onnx-node/README.md
  79. 70 0
      dsh-desktop/app/plugins/sherpa-onnx-node/addon-static-import.js
  80. 98 0
      dsh-desktop/app/plugins/sherpa-onnx-node/addon.js
  81. 45 0
      dsh-desktop/app/plugins/sherpa-onnx-node/audio-tagg.js
  82. 68 0
      dsh-desktop/app/plugins/sherpa-onnx-node/keyword-spotter.js
  83. 158 0
      dsh-desktop/app/plugins/sherpa-onnx-node/non-streaming-asr.js
  84. 38 0
      dsh-desktop/app/plugins/sherpa-onnx-node/non-streaming-speaker-diarization.js
  85. 32 0
      dsh-desktop/app/plugins/sherpa-onnx-node/non-streaming-speech-denoiser.js
  86. 116 0
      dsh-desktop/app/plugins/sherpa-onnx-node/non-streaming-tts.js
  87. 46 0
      dsh-desktop/app/plugins/sherpa-onnx-node/online-speech-denoiser.js
  88. 67 0
      dsh-desktop/app/plugins/sherpa-onnx-node/package.json
  89. 43 0
      dsh-desktop/app/plugins/sherpa-onnx-node/punctuation.js
  90. 80 0
      dsh-desktop/app/plugins/sherpa-onnx-node/resampler.js
  91. 49 0
      dsh-desktop/app/plugins/sherpa-onnx-node/sherpa-onnx.js
  92. 152 0
      dsh-desktop/app/plugins/sherpa-onnx-node/speaker-identification.js
  93. 38 0
      dsh-desktop/app/plugins/sherpa-onnx-node/spoken-language-identification.js
  94. 130 0
      dsh-desktop/app/plugins/sherpa-onnx-node/streaming-asr.js
  95. 787 0
      dsh-desktop/app/plugins/sherpa-onnx-node/types.js
  96. 134 0
      dsh-desktop/app/plugins/sherpa-onnx-node/vad.js
  97. 20 0
      dsh-desktop/app/plugins/ws/LICENSE
  98. 548 0
      dsh-desktop/app/plugins/ws/README.md
  99. 8 0
      dsh-desktop/app/plugins/ws/browser.js
  100. 22 0
      dsh-desktop/app/plugins/ws/index.js

+ 35 - 0
.gitignore

@@ -0,0 +1,35 @@
+# ---------- macOS ----------
+.DS_Store
+.DS_Store?
+._*
+.dmg-test2/
+
+# ---------- Node / Electron (dsh-desktop) ----------
+node_modules/
+dist/
+.electron-cache/
+.npm-cache/
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+package-lock.json.bak
+
+# ---------- generic build outputs ----------
+build/
+out/
+*.app
+*.dmg
+
+# ---------- HarmonyOS (dsh-harmony) ----------
+oh_modules/
+.hvigor/
+
+# ---------- IDE / editor ----------
+.vscode/
+.idea/
+*.iml
+
+# ---------- env / secrets ----------
+.env
+.env.local

+ 62 - 0
assets/logo/README.md

@@ -0,0 +1,62 @@
+# Logo 概念稿(原创,规避第三方商标)
+
+本目录为「HarmonyOS 版 DeepSeek Harness 桌面端」的原创 Logo 概念稿。
+
+## 概念说明
+
+| 文件 | 概念 | 寓意 |
+| --- | --- | --- |
+| `concept-a-orbit-spark.svg` | 引力轨道 + 星火 | 轨道 = Harness(驾驭/连接),星火 = AI 核心 |
+| `concept-b-bridge-h.svg` | H 桥接 | 字母 H(Harness),缎带横杆 = 连接两端 |
+| `concept-c-gear-spark.svg` | 齿轮 + 星火 | 齿轮 = 工具/驾驭,星火 = 被驱动的智能 |
+
+配色刻意避开 DeepSeek 的品牌蓝与鲸鱼图形,也避开华为/HarmonyOS 商标元素,
+降低商标混淆与著作权风险。所有图形均为原创几何构成,可自由修改。
+
+## 第二轮:DSH 字母标(手绘几何路径,无字体依赖)
+
+| 文件 | 概念 | 说明 |
+| --- | --- | --- |
+| `dsh-a-minimal.svg` | 极简单色 ✅ 已选定 | 深色底 + 中性灰白单线字母;顶部「HarmonyOS」、底部「桌面端」浅蓝胶囊标签(思源黑体,SIL OFL 免费可商用) |
+| `dsh-b-strap.svg` | 渐变字母 + 缰绳横带 | 紫粉渐变字母,底部琥珀色横带呼应 Harness 寓意 |
+| `dsh-c-light.svg` | 浅色版 | 浅底 + 深灰字母、渐变 S、右上小星火,适合浅色主题 |
+
+字母全部由贝塞尔路径绘制,不引用任何字体文件,无字体版权问题。
+
+> **注意**:`DSH` 是 DeepSeek Harness 官方项目(npm `@deepseek-ai/dsh`)使用的缩写。
+> 个人/内部使用风险很低;若 App 公开发布或商用,作为名称/Logo 仍可能与官方项目
+> 产生关联混淆,建议加「非官方客户端」标识或改用自有品牌名。
+
+## 侵权规避清单(发布前务必检查)
+
+1. **不要使用 DeepSeek 鲸鱼 Logo 或其变体**,也不要使用与其近似的蓝色鲸鱼/海洋主题组合。
+2. **不要把 "DeepSeek" 用作 App 名称或 Logo 文字**。应用描述里可以如实写
+   「支持 DeepSeek API」「兼容 DeepSeek Harness」这类**指示性使用(nominative use)**,
+   但建议加一句「本应用为第三方客户端,非 DeepSeek 官方产品」。
+3. **不要使用 HarmonyOS / 华为的任何 Logo**;文字表述用「适配 HarmonyOS」
+   (Powered by / Compatible with 类描述)而不是放官方标识。
+4. 不要整体模仿 DeepSeek / 华为的配色+图形组合,避免「混淆可能性」。
+5. 发布前在 [中国商标网](https://sbj.cnipa.gov.cn/) 检索你的 App 名称与图形,
+   确认无近似在先商标;如长期运营,建议注册自己的商标。
+6. 图标中若使用文字,确保字体可商用(如思源黑体/ HarmonyOS Sans 按其许可条款)。
+
+## 导出与接入 HarmonyOS
+
+```bash
+# macOS 快速导出 1024x1024 PNG(预览用)
+qlmanage -t -s 1024 -o . concept-a-orbit-spark.svg
+```
+
+正式接入(HarmonyOS NEXT 工程):
+
+1. 将选定的 SVG 导出为 1024×1024 PNG;
+2. 替换 `AppScope/resources/base/media/app_icon.png`(应用图标);
+3. 模块内入口图标在 `entry/src/main/module.json5` 的 `abilities[].icon`
+   与 `AppScope/app.json5` 的 `app.icon` 中引用 `$media:app_icon`;
+4. 如需分层图标(前景/背景分离),把底板单独导出为背景层、主体图形导出为前景层。
+
+## 迭代方向(告诉我即可继续改)
+
+- 选定某个概念后:调整配色、圆角、笔画粗细;出单色/线稿版本;
+- 增加文字标(请提供你的 App 品牌名,避免使用 "DeepSeek" 字样);
+- 输出启动页、关于页、托盘图标等衍生尺寸。

BIN
assets/logo/appicon/appicon-transparent-1024.png


BIN
assets/logo/appicon/appicon-white-1024.png


BIN
assets/logo/appicon/appicon-white-1024.webp


BIN
assets/logo/appicon/appicon-white-216.png


BIN
assets/logo/appicon/appicon-white-216.webp


+ 31 - 0
assets/logo/concept-a-orbit-spark.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
+  <defs>
+    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#26184F"/>
+      <stop offset="1" stop-color="#0E0C1F"/>
+    </linearGradient>
+    <linearGradient id="ring" x1="0" y1="0" x2="1" y2="0">
+      <stop offset="0" stop-color="#8B5CF6"/>
+      <stop offset="1" stop-color="#EC4899"/>
+    </linearGradient>
+    <linearGradient id="spark" x1="0" y1="0" x2="0" y2="1">
+      <stop offset="0" stop-color="#FFD9A0"/>
+      <stop offset="1" stop-color="#FF9A3D"/>
+    </linearGradient>
+  </defs>
+
+  <!-- 圆角方形底板(紫黑深空) -->
+  <rect x="24" y="24" width="976" height="976" rx="240" fill="url(#bg)"/>
+
+  <!-- 倾斜轨道环:寓意“驾驭/连接 AI”的 Harness -->
+  <ellipse cx="512" cy="512" rx="330" ry="150" transform="rotate(-28 512 512)"
+           fill="none" stroke="url(#ring)" stroke-width="44" stroke-linecap="round"/>
+
+  <!-- 轨道卫星点 -->
+  <circle cx="792" cy="459" r="34" fill="#EC4899"/>
+
+  <!-- 中心四芒星火:AI 智能核心 -->
+  <path d="M512 352 C528 456 568 496 672 512 C568 528 528 568 512 672
+           C496 568 456 528 352 512 C456 496 496 456 512 352 Z"
+        fill="url(#spark)"/>
+</svg>

+ 27 - 0
assets/logo/concept-b-bridge-h.svg

@@ -0,0 +1,27 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
+  <defs>
+    <linearGradient id="bgB" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#1A2233"/>
+      <stop offset="1" stop-color="#0B0F1A"/>
+    </linearGradient>
+    <linearGradient id="ribbonB" x1="0" y1="0" x2="1" y2="0">
+      <stop offset="0" stop-color="#FB7185"/>
+      <stop offset="1" stop-color="#F59E0B"/>
+    </linearGradient>
+  </defs>
+
+  <!-- 深色底板 -->
+  <rect x="24" y="24" width="976" height="976" rx="240" fill="url(#bgB)"/>
+
+  <!-- 两根立柱:构成字母 H(Harness) -->
+  <rect x="318" y="300" width="88" height="424" rx="44" fill="#CBD5E1"/>
+  <rect x="618" y="300" width="88" height="424" rx="44" fill="#CBD5E1"/>
+
+  <!-- 流动缎带横杆:像桥/缰绳一样把两端连接起来 -->
+  <path d="M330 470 C450 570 574 570 694 470"
+        fill="none" stroke="url(#ribbonB)" stroke-width="64" stroke-linecap="round"/>
+
+  <!-- 缎带两端节点,暗示连接/数据流动 -->
+  <circle cx="330" cy="470" r="30" fill="#FB7185"/>
+  <circle cx="694" cy="470" r="30" fill="#F59E0B"/>
+</svg>

+ 37 - 0
assets/logo/concept-c-gear-spark.svg

@@ -0,0 +1,37 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
+  <defs>
+    <linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#1E293B"/>
+      <stop offset="1" stop-color="#0B1120"/>
+    </linearGradient>
+    <linearGradient id="gearC" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#A78BFA"/>
+      <stop offset="1" stop-color="#7C3AED"/>
+    </linearGradient>
+    <linearGradient id="sparkC" x1="0" y1="0" x2="0" y2="1">
+      <stop offset="0" stop-color="#FDE68A"/>
+      <stop offset="1" stop-color="#F59E0B"/>
+    </linearGradient>
+  </defs>
+
+  <!-- 深色底板 -->
+  <rect x="24" y="24" width="976" height="976" rx="240" fill="url(#bgC)"/>
+
+  <!-- 八齿齿轮:工具/驾驭(Harness)的隐喻 -->
+  <g fill="url(#gearC)">
+    <g transform="rotate(0 512 512)"><rect x="464" y="168" width="96" height="140" rx="28"/></g>
+    <g transform="rotate(45 512 512)"><rect x="464" y="168" width="96" height="140" rx="28"/></g>
+    <g transform="rotate(90 512 512)"><rect x="464" y="168" width="96" height="140" rx="28"/></g>
+    <g transform="rotate(135 512 512)"><rect x="464" y="168" width="96" height="140" rx="28"/></g>
+    <g transform="rotate(180 512 512)"><rect x="464" y="168" width="96" height="140" rx="28"/></g>
+    <g transform="rotate(225 512 512)"><rect x="464" y="168" width="96" height="140" rx="28"/></g>
+    <g transform="rotate(270 512 512)"><rect x="464" y="168" width="96" height="140" rx="28"/></g>
+    <g transform="rotate(315 512 512)"><rect x="464" y="168" width="96" height="140" rx="28"/></g>
+    <circle cx="512" cy="512" r="200" fill="none" stroke="url(#gearC)" stroke-width="64"/>
+  </g>
+
+  <!-- 齿轮中心的星火:被驾驭的 AI -->
+  <path d="M512 402 C524 476 548 500 622 512 C548 524 524 548 512 622
+           C500 548 476 524 402 512 C476 500 500 476 512 402 Z"
+        fill="url(#sparkC)"/>
+</svg>

+ 39 - 0
assets/logo/dsh-a-minimal.svg

@@ -0,0 +1,39 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
+  <defs>
+    <linearGradient id="bgA" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#171430"/>
+      <stop offset="1" stop-color="#0C0A18"/>
+    </linearGradient>
+  </defs>
+
+  <!-- 深空紫黑底板 -->
+  <rect x="24" y="24" width="976" height="976" rx="240" fill="url(#bgA)"/>
+
+  <!-- DSH 等宽单线字母:极简中性款 -->
+  <g fill="none" stroke="#E8EAF2" stroke-width="56" stroke-linecap="round" stroke-linejoin="round">
+    <!-- D -->
+    <path d="M247 408 L247 616 M247 408 C367 408 367 616 247 616"/>
+    <!-- S -->
+    <path d="M553 448 C521 408 441 408 441 456 C441 504 553 520 553 572 C553 616 461 620 433 580"/>
+    <!-- H -->
+    <path d="M657 408 L657 616 M777 408 L777 616 M657 512 L777 512"/>
+  </g>
+
+  <!-- 顶部胶囊标签:HarmonyOS(浅蓝底 + 深蓝字,思源黑体 SIL OFL 免费可商用) -->
+  <g>
+    <rect x="262" y="150" width="500" height="124" rx="62" fill="#BAE6FD"/>
+    <text x="512" y="237" text-anchor="middle"
+          font-family="Source Han Sans SC, Noto Sans SC, Noto Sans CJK SC, PingFang SC, sans-serif"
+          font-size="72" font-weight="600" letter-spacing="2"
+          fill="#0C4A6E">HarmonyOS</text>
+  </g>
+
+  <!-- 底部胶囊标签:桌面端(同款浅蓝底) -->
+  <g>
+    <rect x="332" y="712" width="360" height="124" rx="62" fill="#BAE6FD"/>
+    <text x="512" y="799" text-anchor="middle"
+          font-family="Source Han Sans SC, Noto Sans SC, Noto Sans CJK SC, PingFang SC, sans-serif"
+          font-size="72" font-weight="600" letter-spacing="8"
+          fill="#0C4A6E">桌面端</text>
+  </g>
+</svg>

+ 34 - 0
assets/logo/dsh-b-strap.svg

@@ -0,0 +1,34 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
+  <defs>
+    <linearGradient id="bgB" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#140F2E"/>
+      <stop offset="1" stop-color="#0B0818"/>
+    </linearGradient>
+    <linearGradient id="gradB" x1="0" y1="0" x2="1" y2="0">
+      <stop offset="0" stop-color="#8B5CF6"/>
+      <stop offset="1" stop-color="#EC4899"/>
+    </linearGradient>
+    <linearGradient id="strapB" x1="0" y1="0" x2="1" y2="0">
+      <stop offset="0" stop-color="#FBBF24"/>
+      <stop offset="1" stop-color="#F97316"/>
+    </linearGradient>
+  </defs>
+
+  <!-- 深色底板 -->
+  <rect x="24" y="24" width="976" height="976" rx="240" fill="url(#bgB)"/>
+
+  <!-- DSH 渐变字母(紫→品红) -->
+  <g fill="none" stroke="url(#gradB)" stroke-width="56" stroke-linecap="round" stroke-linejoin="round">
+    <!-- D -->
+    <path d="M247 392 L247 600 M247 392 C367 392 367 600 247 600"/>
+    <!-- S -->
+    <path d="M553 432 C521 392 441 392 441 440 C441 488 553 504 553 556 C553 600 461 604 433 564"/>
+    <!-- H -->
+    <path d="M657 392 L657 600 M777 392 L777 600 M657 496 L777 496"/>
+  </g>
+
+  <!-- 底部"缰绳"横带 + 两端节点:Harness 的隐喻 -->
+  <path d="M300 724 L724 724" fill="none" stroke="url(#strapB)" stroke-width="40" stroke-linecap="round"/>
+  <circle cx="300" cy="724" r="26" fill="#FBBF24"/>
+  <circle cx="724" cy="724" r="26" fill="#F97316"/>
+</svg>

+ 34 - 0
assets/logo/dsh-c-light.svg

@@ -0,0 +1,34 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
+  <defs>
+    <linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#FFFFFF"/>
+      <stop offset="1" stop-color="#DFE5F0"/>
+    </linearGradient>
+    <linearGradient id="gradS" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#7C3AED"/>
+      <stop offset="1" stop-color="#EC4899"/>
+    </linearGradient>
+    <linearGradient id="sparkC" x1="0" y1="0" x2="0" y2="1">
+      <stop offset="0" stop-color="#FDE68A"/>
+      <stop offset="1" stop-color="#F59E0B"/>
+    </linearGradient>
+  </defs>
+
+  <!-- 浅色底板(适合浅色主题 / 启动页) -->
+  <rect x="24" y="24" width="976" height="976" rx="240" fill="url(#bgC)"/>
+
+  <!-- D 与 H:深灰蓝 -->
+  <g fill="none" stroke="#1E293B" stroke-width="56" stroke-linecap="round" stroke-linejoin="round">
+    <path d="M247 408 L247 616 M247 408 C367 408 367 616 247 616"/>
+    <path d="M657 408 L657 616 M777 408 L777 616 M657 512 L777 512"/>
+  </g>
+
+  <!-- S:紫粉渐变,视觉焦点 -->
+  <path d="M553 448 C521 408 441 408 441 456 C441 504 553 520 553 572 C553 616 461 620 433 580"
+        fill="none" stroke="url(#gradS)" stroke-width="56" stroke-linecap="round" stroke-linejoin="round"/>
+
+  <!-- H 右上方的小星火:AI 点缀 -->
+  <path d="M836 306 C840 328 848 336 870 340 C848 344 840 352 836 374
+           C832 352 824 344 802 340 C824 336 832 328 836 306 Z"
+        fill="url(#sparkC)"/>
+</svg>

+ 39 - 0
assets/logo/dsh-icon-final.svg

@@ -0,0 +1,39 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
+  <defs>
+    <linearGradient id="bgA" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#171430"/>
+      <stop offset="1" stop-color="#0C0A18"/>
+    </linearGradient>
+  </defs>
+
+  <!-- 深空紫黑底板:满幅 1024x1024(商店图标满幅出图,系统侧再做圆角遮罩) -->
+  <rect x="0" y="0" width="1024" height="1024" rx="0" fill="url(#bgA)"/>
+
+  <!-- DSH 等宽单线字母:极简中性款 -->
+  <g fill="none" stroke="#E8EAF2" stroke-width="56" stroke-linecap="round" stroke-linejoin="round">
+    <!-- D -->
+    <path d="M247 408 L247 616 M247 408 C367 408 367 616 247 616"/>
+    <!-- S -->
+    <path d="M553 448 C521 408 441 408 441 456 C441 504 553 520 553 572 C553 616 461 620 433 580"/>
+    <!-- H -->
+    <path d="M657 408 L657 616 M777 408 L777 616 M657 512 L777 512"/>
+  </g>
+
+  <!-- 顶部胶囊标签:HarmonyOS(加大浅蓝底 + 深蓝字,思源黑体 SIL OFL 免费可商用) -->
+  <g>
+    <rect x="222" y="128" width="580" height="152" rx="76" fill="#BAE6FD"/>
+    <text x="512" y="229" text-anchor="middle"
+          font-family="Source Han Sans SC, Noto Sans SC, Noto Sans CJK SC, PingFang SC, sans-serif"
+          font-size="72" font-weight="600" letter-spacing="2"
+          fill="#0C4A6E">HarmonyOS</text>
+  </g>
+
+  <!-- 底部胶囊标签:桌面端(加大浅蓝底) -->
+  <g>
+    <rect x="292" y="700" width="440" height="152" rx="76" fill="#BAE6FD"/>
+    <text x="512" y="801" text-anchor="middle"
+          font-family="Source Han Sans SC, Noto Sans SC, Noto Sans CJK SC, PingFang SC, sans-serif"
+          font-size="72" font-weight="600" letter-spacing="8"
+          fill="#0C4A6E">桌面端</text>
+  </g>
+</svg>

BIN
assets/logo/exports/icon-1024.png


BIN
assets/logo/exports/icon-1024.webp


BIN
assets/logo/exports/icon-216.png


BIN
assets/logo/exports/icon-216.webp


BIN
assets/logo/kailin-logo/icon-mark.png


Diferenças do arquivo suprimidas por serem muito extensas
+ 16 - 0
assets/logo/kailin-logo/index.js


+ 24 - 0
assets/logo/kailin-logo/logo-Gtl3ke7j.png

@@ -0,0 +1,24 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="description" content="一站式企微生态SCRM私域直播。" />
+    <meta
+      name="keywords"
+      content="开邻智数,企微生态,企业微信SCRM,私域直播,私域营销一体化,自建商城,私域电商,私域流量转化,整合营销,私域直播间,低延迟直播,SCRM精细化运营,渠道码,活码工具,客户标签管理,客户全生命周期,数据报表,互动雷达,营销场景工具,企微社群管理,快捷回复,用户行为数据打通,知识库智能工单,AIGC直播,数字人克隆,无代码页面搭建,多角色权限管理,优惠券拼团会员营销,社区资讯UGC,教培行业方案,医药行业方案,私域SaaS,数字化转型,获取方案"
+    />
+    <meta property="og:title" content="开邻智数" />
+    <meta property="og:description" content="一站式企微生态SCRM私域直播。" />
+    <meta name="twitter:card" content="summary" />
+    <meta name="twitter:title" content="开邻智数" />
+    <meta name="twitter:description" content="一站式企微生态SCRM私域直播。" />
+    <link rel="icon" type="image/x-icon" href="/assets/logo-Gtl3ke7j.ico" />
+    <title>开邻智数-私域营销一体化解决方案</title>
+    <script type="module" crossorigin src="/assets/index-CArZgPOj.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Bj3z759M.css">
+  </head>
+  <body>
+    <div id="root"></div>
+  </body>
+</html>

+ 24 - 0
assets/logo/kailin-logo/logo-Gtl3ke7j.svg

@@ -0,0 +1,24 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="description" content="一站式企微生态SCRM私域直播。" />
+    <meta
+      name="keywords"
+      content="开邻智数,企微生态,企业微信SCRM,私域直播,私域营销一体化,自建商城,私域电商,私域流量转化,整合营销,私域直播间,低延迟直播,SCRM精细化运营,渠道码,活码工具,客户标签管理,客户全生命周期,数据报表,互动雷达,营销场景工具,企微社群管理,快捷回复,用户行为数据打通,知识库智能工单,AIGC直播,数字人克隆,无代码页面搭建,多角色权限管理,优惠券拼团会员营销,社区资讯UGC,教培行业方案,医药行业方案,私域SaaS,数字化转型,获取方案"
+    />
+    <meta property="og:title" content="开邻智数" />
+    <meta property="og:description" content="一站式企微生态SCRM私域直播。" />
+    <meta name="twitter:card" content="summary" />
+    <meta name="twitter:title" content="开邻智数" />
+    <meta name="twitter:description" content="一站式企微生态SCRM私域直播。" />
+    <link rel="icon" type="image/x-icon" href="/assets/logo-Gtl3ke7j.ico" />
+    <title>开邻智数-私域营销一体化解决方案</title>
+    <script type="module" crossorigin src="/assets/index-CArZgPOj.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Bj3z759M.css">
+  </head>
+  <body>
+    <div id="root"></div>
+  </body>
+</html>

BIN
assets/logo/kailin-logo/logo-full.png


BIN
assets/logo/kailin-logo/logo.ico


BIN
assets/logo/kailin-logo/logo.png


+ 24 - 0
assets/logo/kailin-logo/logo.svg

@@ -0,0 +1,24 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="description" content="一站式企微生态SCRM私域直播。" />
+    <meta
+      name="keywords"
+      content="开邻智数,企微生态,企业微信SCRM,私域直播,私域营销一体化,自建商城,私域电商,私域流量转化,整合营销,私域直播间,低延迟直播,SCRM精细化运营,渠道码,活码工具,客户标签管理,客户全生命周期,数据报表,互动雷达,营销场景工具,企微社群管理,快捷回复,用户行为数据打通,知识库智能工单,AIGC直播,数字人克隆,无代码页面搭建,多角色权限管理,优惠券拼团会员营销,社区资讯UGC,教培行业方案,医药行业方案,私域SaaS,数字化转型,获取方案"
+    />
+    <meta property="og:title" content="开邻智数" />
+    <meta property="og:description" content="一站式企微生态SCRM私域直播。" />
+    <meta name="twitter:card" content="summary" />
+    <meta name="twitter:title" content="开邻智数" />
+    <meta name="twitter:description" content="一站式企微生态SCRM私域直播。" />
+    <link rel="icon" type="image/x-icon" href="/assets/logo-Gtl3ke7j.ico" />
+    <title>开邻智数-私域营销一体化解决方案</title>
+    <script type="module" crossorigin src="/assets/index-CArZgPOj.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Bj3z759M.css">
+  </head>
+  <body>
+    <div id="root"></div>
+  </body>
+</html>

+ 21 - 0
assets/store/MIT-LICENSE.txt

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 DeepSeek
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 28 - 0
assets/store/about-page-copy.md

@@ -0,0 +1,28 @@
+# 关于页 / 开源许可文案(供工程「关于」页直接使用)
+
+## 关于页正文
+
+> **DoTouchAI** 版本 【x.y.z】
+>
+> 一款运行在 HarmonyOS 桌面端的 AI 智能体客户端。
+>
+> 本应用基于开源项目 **DeepSeek Harness**(MIT License)构建。
+> 感谢 DeepSeek 及开源社区。
+>
+> 开发者:智价云 · 深圳大方无隅科技有限公司
+> 官网:www.kailin.com.cn
+
+## 开源许可(MIT 全文,见 MIT-LICENSE.txt)
+
+> 本应用包含以下开源软件的副本,其许可协议如下(完整文本见附随 LICENSE 文件):
+
+**DeepSeek Harness** — https://github.com/deepseek-ai/deepseek-harness
+MIT License, Copyright (c) 2026 DeepSeek
+
+(MIT 全文见 `assets/store/MIT-LICENSE.txt`,需随应用分发,并在「关于 → 开源许可」页可查看)
+
+## 第三方声明
+
+本应用为独立第三方开发者作品,非 DeepSeek 官方产品,亦非华为官方产品,
+与 DeepSeek、华为无任何官方关联或背书关系。
+"DeepSeek""DeepSeek Harness""HarmonyOS"等名称与商标归其各自权利人所有。

+ 82 - 0
assets/store/appgallery-listing.md

@@ -0,0 +1,82 @@
+# Dotouch AI · 华为应用市场上架文案(最终可粘贴版)
+
+> 应用显示名:**Dotouch AI**(备选 Agent鸿蒙端)
+> 品牌/开发者:**智价云** · 深圳大方无隅科技有限公司
+> 包名:com.dotouch.dshharmony(上架前如有改动请同步)
+> Logo:官网 kailin.com.cn 左上角图标(红蓝层叠标,见 assets/logo/appicon/)
+> 截图:assets/store/screenshots/(01 主界面 / 02 设置 / 03 对话)
+
+---
+
+## 一句话简介(17 字)
+
+**在鸿蒙桌面驾驭你的AI智能体工作流**
+
+(备选:AI智能体工作台,鸿蒙桌面开箱即用)
+
+---
+
+## 应用介绍(长文案,可直接粘贴)
+
+Dotouch AI 是一款运行在 HarmonyOS 桌面端的 AI 智能体客户端,基于开源项目 DeepSeek Harness
+(MIT 协议)构建。它将「能读文件、能跑命令、能联网搜索」的 AI 智能体带到鸿蒙桌面,
+让你用自然语言指挥智能体完成真实的开发与工作任务。
+
+**核心能力**
+
+- 智能体对话:实时流式对话,支持随时中断与恢复,长会话不丢上下文
+- 多工作区 · 多会话:按工作区组织会话,一键切换、搜索与恢复历史对话
+- 工具过程透明:智能体读写文件、执行命令、联网搜索等每一步操作都可见、可追溯
+- 目标与任务管理:目标自动拆解为待办清单,进度实时可见
+- 计划与审批:关键操作前人工确认(计划模式/权限预设),安全可控
+- 原生鸿蒙体验:HarmonyOS 原生窗口与系统集成,深色/浅色主题,开箱即用
+
+**开源说明与特别声明**
+
+本应用基于开源项目 DeepSeek Harness(MIT License,© DeepSeek)构建,感谢原作者的开源贡献;
+MIT 许可证全文见应用内「关于 → 开源许可」。
+本应用为独立第三方开发者作品,非 DeepSeek 官方产品,亦非华为官方产品,与 DeepSeek、华为
+无任何官方关联或背书关系。"DeepSeek""DeepSeek Harness""HarmonyOS"等名称与商标归其各自
+权利人所有,文中仅用于如实描述本应用的兼容对象与运行平台。
+
+---
+
+## 截图说明(对应 assets/store/screenshots/)
+
+| 截图 | 文件名 | 内容 | 用于审核展示 |
+| --- | --- | --- | --- |
+| 1 | 01-main-view.png | 主界面:侧边栏会话/工作区树 + 对话输入区 | 应用首屏 |
+| 2 | 02-settings.png | 设置面板 | 功能设置 |
+| 3 | 03-conversation.png | 会话进行中:对话与工具过程 | 核心功能 |
+
+※ 截图来自真实运行的应用(HarmonyOS 模拟器,3120×2080)。提交前请按华为要求尺寸
+(如 1080×2340 竖屏或对应桌面尺寸)裁剪/重导,并确保截图内不出现真实用户数据与
+第三方标识;应用标题栏当前为「DSH Harmony」,需改为「Dotouch AI」后再截最终图。
+
+---
+
+## 上架填写项
+
+| 字段 | 填写 |
+| --- | --- |
+| 应用名称 | Dotouch AI |
+| 应用分类 | 效率 / 工具 |
+| 适用年龄 | 12+(含 AI 生成内容交互) |
+| 关键词 | 智能体、AI 助手、效率工具、鸿蒙桌面(勿填 DeepSeek/DSH) |
+| 隐私政策 URL | https://www.kailin.com.cn/privacy |
+| 用户协议 URL | https://www.kailin.com.cn/terms |
+
+---
+
+## 上架前核对(进度已更新)
+
+| # | 事项 | 状态 |
+| --- | --- | --- |
+| 1 | 应用标题栏/图标改为 Dotouch AI | ✅ 已完成(命名会话,模拟器验证通过) |
+| 2 | 图标替换为 kailin 标 | ✅ 已完成(白底+透明前景分层) |
+| 3 | MIT 许可证全文 + 关于页文案 | ✅ 内容已备(`MIT-LICENSE.txt` + `about-page-copy.md`);⚠️ 工程「关于」页落地待接 |
+| 4 | 隐私政策与用户协议托管官网 | ⏳ 待托管 kailin.com.cn/privacy 与 /terms |
+| 5 | 干净交互截图 | ⏳ 应用已改名,待用全新工作区重拍 |
+
+已备好的 MIT 合规物料:`assets/store/MIT-LICENSE.txt`(DeepSeek Harness MIT 全文,随 App 分发)、
+`assets/store/about-page-copy.md`(关于页文案)。

+ 30 - 0
assets/store/check-pending.sh

@@ -0,0 +1,30 @@
+#!/bin/bash
+# Dotouch AI 收尾任务进度检查脚本(只读,不写工程)
+# 检查三个信号:① 应用改名 ② 图标替换 ③ 关于页 MIT 全文
+set -u
+PJ="/Users/eastudio/DevEcoStudioProjects/dsh-harmony"
+echo "===== 检查时间 $(date '+%H:%M:%S') ====="
+
+echo "--- ① 应用改名(应出现 Dotouch AI,而非 DSH Harmony)---"
+grep -rniE "Dotouch AI|DSH Harmony|label|bundleName|displayName" "$PJ/AppScope/app.json5" 2>/dev/null | head -8
+grep -rniE "Dotouch AI|DSH Harmony|label|mainElement" "$PJ/entry/src/main/module.json5" 2>/dev/null | head -8
+# 字符串资源里的显示名
+grep -rniE "Dotouch AI|DSH Harmony" "$PJ/entry/src/main/resources" 2>/dev/null | head -8
+
+echo "--- ② 图标替换(新图标 md5 应与 assets/logo/appicon/appicon-white-1024.png 一致或为开邻图标)---"
+if [ -f "$PJ/AppScope/resources/base/media/app_icon.png" ]; then
+  md5 "$PJ/AppScope/resources/base/media/app_icon.png" 2>/dev/null || md5sum "$PJ/AppScope/resources/base/media/app_icon.png" 2>/dev/null
+  ls -l "$PJ/AppScope/resources/base/media/app_icon.png"
+else
+  echo "(未找到 app_icon.png)"
+fi
+echo "参考新图标:"
+md5 "/Users/eastudio/Documents/EvanAgent/assets/logo/appicon/appicon-white-1024.png" 2>/dev/null || md5sum "/Users/eastudio/Documents/EvanAgent/assets/logo/appicon/appicon-white-1024.png" 2>/dev/null
+
+echo "--- ③ 关于页 MIT 许可全文(应在 ets 源码中出现 MIT License 字样)---"
+grep -rlniE "MIT License|DeepSeek Harness|开源许可" "$PJ/entry/src/main/ets" 2>/dev/null | head -8
+
+echo "--- git 最近提交(收尾任务应产生新 commit)---"
+git -C "$PJ" log --oneline -8 2>/dev/null || echo "(git 不可用)"
+echo "--- 工作区改动文件(最近 24h 内修改)---"
+find "$PJ" -name "*.json5" -o -name "*.ets" -o -name "app_icon.png" 2>/dev/null | xargs ls -lt 2>/dev/null | head -12

+ 57 - 0
assets/store/launch-checklist.zh.md

@@ -0,0 +1,57 @@
+# 上架执行时间线(华为应用市场)
+
+> 2026-08-23 制定 · 依赖项已标注 · 前置条件:上架版(云端托管、注册即用)功能定型
+> 相关文档:appgallery-listing.md(文案)/ privacy-policy.zh.md(隐私政策)/
+> user-agreement.zh.md(用户协议)/ monetization-design.zh.md(商业化与资质)
+
+---
+
+## 阶段 A:命名与资质(第 1-2 周,可立即启动)
+
+| # | 事项 | 负责人 | 说明 |
+| --- | --- | --- | --- |
+| A1 | 导出公司 51 条商标完整清单 | 用户/代理 | 商标局官网或代理处导出,筛 9/42 类可用标 → 定 App 名(关键路径,卡后续) |
+| A2 | App 名称查重 | 用户 | 华为后台 + 应用市场搜索同名/近似应用 |
+| A3 | 华为开发者**企业**认证 | 用户 | 营业执照 + 对公账户验证,个人号需升级企业号 |
+| A4 | **App 备案**提交 | 用户/华为 | 工信部备案经华为代提交,周期约 1-2 周 |
+| A5 | 生成式 AI 服务登记咨询 | 用户 | 深圳网信办,确认登记材料清单 |
+| A6 | 隐私政策 + 用户协议定稿托管 | 我方+用户 | 替换【】占位符,发布至 kailin.com.cn/privacy 与 /terms |
+| A7 | 图标/截图素材定稿 | 我方 | 图标已出稿(assets/logo/),按 216/1024 规范提供 |
+
+## 阶段 B:上架版开发与材料(第 3-6 周)
+
+| # | 事项 | 说明 |
+| --- | --- | --- |
+| B1 | 协议原生版上架版功能定型 | 云端托管 + 注册即用;删除"需电脑连接"的测试版前提 |
+| B2 | 实名认证接入 | 手机号 + 实名(对接运营商实名或权威 eKYC 服务) |
+| B3 | 内容安全审核接入 | 文本审核 API(对生成内容 + 用户输入双通道) |
+| B4 | 举报通道 | 应用内举报入口 + 处理时限流程(审核会实测) |
+| B5 | 上架材料 | 应用截图(6-8 张)、功能演示视频(可选)、软著(如需要) |
+| B6 | 更新上架文案 | 按 appgallery-listing.md,删除"使用前提"段,改为注册即用 |
+
+## 阶段 C:上架与免费运营(第 6-8 周)
+
+| # | 事项 | 说明 |
+| --- | --- | --- |
+| C1 | 提交审核 | 材料齐备后提交,通常 3-7 个工作日,驳回按意见修改重提 |
+| C2 | 免费版上线 | 注册送 500 积分体验;监控留存/对话量/成本 |
+| C3 | 成本模型验证 | 每千 token 真实成本 vs 预估价,校准 §2.2 费率 |
+
+## 阶段 D:商业化上线(第 8-12 周,ICP/EDI 已持有 ✅)
+
+| # | 事项 | 说明 |
+| --- | --- | --- |
+| D1 | 华为 IAP 接入 | 企业结算协议 + 对公收款账户;沙箱联调 |
+| D2 | 计费系统联调 | 计量扣费、预估冻结/结算、账本流水(monetization §3) |
+| D3 | 电子发票能力 | 充值发票开具 |
+| D4 | 隐私政策 v2 + 用户协议恢复充值条款 | 重新公示并弹窗征求同意 |
+| D5 | 余额/积分体系上线 | 按 monetization §2(余额 or 积分二选一) |
+
+## 关键路径与风险
+
+- **关键路径**:A1 商标清单 → A2 定名 → B5 材料 → C1 审核。
+  定名每延误 1 周,整体顺延 1 周。
+- **最长周期项**:生成式 AI 登记(A5,官方无固定时限,尽早提交)。
+- **高风险点**:① 名称/图标撞牌被驳回(靠 A1-A2 化解);② AI 内容安全不达标被
+  下架(靠 B3-B4);③ 免费期成本失控(靠 C3 提前验证)。
+- **并行项**:A3/A4/A5 与 B 阶段开发完全并行,无需等待定名。

+ 250 - 0
assets/store/monetization-design.zh.md

@@ -0,0 +1,250 @@
+# DSH Harmony 商业化设计(充值 + 积分算力模式)
+
+> 版本 v1 · 2026-08-23
+> 前提:上架版 = 你方云端托管基于开源 DeepSeek Harness(MIT)自建的服务端,
+> 聚合多家模型供应商 API;用户无需自备电脑/key,注册即用、按用量付费。
+> 与 HANDOVER D7 对齐:本期只留支付接口占位,商业化在后续阶段落地。
+
+---
+
+## 1. 商业模式总览
+
+```
+用户充值(人民币,走华为 IAP)
+   ↓ 按档位兑换 + 阶梯赠送
+积分余额(虚拟资产,App 内账本)
+   ↓ 按实际 token 用量扣减(不同模型不同费率)
+云端 DSH 服务 → API 网关 → 多供应商模型(境内已备案)
+```
+
+- **不卖订阅时长,卖算力**:用户为"用了多少"付费,心理门槛低,适合智能体这种
+  消耗波动大的场景(一次深度任务可能几十万 token)
+- **你赚的是差价**:用户积分单价 −(供应商 token 成本 + 华为分成 + 通道/运营成本)
+
+## 2. 积分与定价体系
+
+### 2.1 基础换算
+
+| 项 | 建议值 | 说明 |
+| --- | --- | --- |
+| 充值换算 | 1 元 = 100 积分 | 整数好记,宣传口径统一 |
+| 最小扣费 | 0.1 积分 | 对应约千分之一元,避免零钱纠纷 |
+| 新用户体验金 | 500 积分(=5 元) | 注册即送,仅可消费不可退,有效期 30 天 |
+
+### 2.2 模型费率表(示例,需按供应商实时价校准)
+
+定价公式:**积分单价 = 供应商成本 ÷ 100 × 加价系数 ÷ (1 − 华为分成率)**
+
+示例(成本仅为演示口径,以官网实时价为准;加价系数 2.5、分成按 30% 估):
+
+| 模型档位 | 供应商成本(每百万 token) | 用户扣费(积分/千 token) | 定位 |
+| --- | --- | --- | --- |
+| 轻量模型 | 输入 ¥0.5 / 输出 ¥2 | 输入 0.02 / 输出 0.07 | 日常对话,走量 |
+| 旗舰模型 | 输入 ¥2 / 输出 ¥8 | 输入 0.07 / 输出 0.29 | 复杂任务,利润主力 |
+
+- 费率表做成**服务端配置**,不发版即可调价、上下架模型
+- 智能体场景建议按「任务预估冻结 → 完成结算 → 多退少补」扣费,防止余额透支到负数
+
+### 2.3 充值档位与促销
+
+| 档位 | 到账积分 | 赠送比例 | 说明 |
+| --- | --- | --- | --- |
+| ¥6 | 600 | 0% | 尝鲜档,降低首付门槛 |
+| ¥30 | 3,150 | 5% | |
+| ¥68 | 7,480 | 10% | **主推档**(界面默认选中) |
+| ¥128 | 14,720 | 15% | |
+| ¥328 | 39,360 | 20% | 重度用户/团队 |
+
+- **首充礼包**:首次任意档位额外 +20%(每个账号一次,防刷见 §3.4)
+- **会员订阅(阶段 3 再上)**:¥25/月 = 2,500 积分 + 当月消费 95 折 + 专属模型队列
+- ⚠️ 不要做:概率抽奖式返利、多级分销返利——涉赌/涉传销红线
+
+### 2.4 计费载体的两种形态(华为均允许)
+
+Q:不设积分,直接「充值人民币余额 → 按 token 扣余额」可以吗?
+A:**可以**。华为 IAP 管的是"购买行为"而非"载体名称"——无论叫积分还是余额,
+只要用钱购买、在应用内消耗于数字服务,都属虚拟商品,都必须走 IAP。
+
+| 维度 | 积分模式 | 余额模式 |
+| --- | --- | --- |
+| 合规 | 需公示兑换比例、有效期管理更复杂 | 更透明,投诉率更低 |
+| 运营 | 赠送/折扣灵活,用户价格感知弱 | 赠送余额同样可行,但让利感知过强 |
+| 财务 | 递延收入按积分消耗确认 | 按余额消耗确认,核算更简单 |
+| 用户理解 | 需换算 | 所见即所得,开发者用户更买账 |
+
+建议:面向开发者的产品优先**余额模式**;若计划做签到/任务/裂变运营再引入积分。
+也可混合:余额为主 + 活动发"代金券"。
+共同底线:不可提现、不可转账、单价与费率公示、未消费部分支持退款、可开发票。
+
+## 3. 技术架构要点
+
+### 3.1 API 网关
+多供应商 key 池 + 路由策略(成本优先/质量优先)+ 故障自动降级(A 供应商超时切 B)
++ 按用户限流(防单用户打爆额度)。**境内上架版只接境内已备案模型**(见 §4)。
+
+### 3.2 计量与扣费
+- 流式响应实时统计 token(usage 字段为准,缺失时本地 tokenizer 估算兜底)
+- 幂等键 = 会话 ID + 请求序号,防止重试重复扣费
+- 预估冻结:任务开始时冻结预估积分,结束按实结算、差额返还
+
+### 3.3 积分账本
+- 双录入账本:每笔变动有流水(时间/类型/数量/关联订单),余额 = 流水聚合
+- 充值积分与赠送积分**分账管理**(赠送不可退、有效期更短)
+- 有效期:充值积分 12 个月、赠送积分 90 天;到期前 7 天 App 内提醒
+  (有效期规则必须在充值页明示,否则违反预付卡相关规定)
+
+### 3.4 支付与风控
+- 支付走**华为应用内支付(IAP)**:积分属虚拟商品,AppGallery 分发必须使用 IAP,
+  分成计入定价(见 §2.2 公式)
+- 风控:同设备多账号首充去重、异常用量熔断、供应商 key 定期轮换、
+  消费异常短信/推送确认
+
+## 4. 合规红线(境内上架,逐条落实)
+
+| # | 事项 | 要求 |
+| --- | --- | --- |
+| 1 | **生成式 AI 监管**(《生成式人工智能服务管理暂行办法》) | 面向公众提供服务:① 只接入**境内已备案**的基座模型 API(境外模型——OpenAI/Claude/Gemini 等——不得向境内公众提供);② 向属地网信部门完成生成式 AI 服务**登记/备案**(接已备案模型通常走登记流程,以属地要求为准);③ 用户**实名认证**;④ 内容安全审核 + 举报入口 + 处置机制 |
+| 2 | **华为 IAP** | 虚拟积分必须走应用内支付,不得引导第三方支付(否则下架);分成比例以华为当期政策为准 |
+| 3 | **预付资金** | 积分=单用途预付性质:明示有效期、提供余额查询、退款规则(未消费充值积分应支持退)、开具发票入口 |
+| 4 | **开源合规** | MIT:App「关于」页保留 DeepSeek Harness 版权声明 + 许可证全文;你的自有代码可闭源、可商用,不冲突 |
+| 5 | **PIPL/隐私** | 隐私政策需覆盖:账号信息、实名信息、支付信息、对话内容存储位置与期限;数据出境评估(若供应商在境外——境内上架版应避免) |
+| 6 | **经营资质** | 经营性互联网信息服务需 ICP 许可证;如涉及在线数据处理与交易处理业务可能需 EDI 证;AppGallery 对应类目会校验 |
+| 7 | **用户协议** | 服务协议 + 充值协议 + 未成年人保护条款(未成年人充值限制与退款通道) |
+
+## 5. 分阶段落地路线
+
+| 阶段 | 内容 | 依赖 |
+| --- | --- | --- |
+| **阶段 1**(上架首发) | 云端托管 DSH + 注册送 500 积分免费体验,**暂不收费**;跑通留存与成本模型 | 协议原生版/壳版云化;实名 + 内容审核先行 |
+| **阶段 2** | 上线充值 + 积分计费(§2 全表)+ IAP | 完成生成式 AI 登记、ICP 证、隐私政策 v2 |
+| **阶段 3** | 会员订阅、阶梯折扣运营活动、团队席位 | 阶段 2 数据(ARPU/留存/成本率)验证后 |
+
+## 6. 上架文案联动
+
+- 收费功能未上线前,上架文案**不要出现任何价格/充值描述**(功能真实性审核)
+- 阶段 2 上线后,应用介绍需补:「本应用含应用内购买项目」+ 价格区间说明(华为后台勾选)
+- 文案模板中"使用前提"段在云端版上线后删除,改为「注册即用,新用户赠 500 积分体验额度」
+
+---
+
+## 7. DeepSeek 知识产权与商业化影响分析
+
+**结论:不影响商业化,但要做品牌隔离。** 分四层:
+
+| 层面 | 规则 | 影响 |
+| --- | --- | --- |
+| 代码著作权 | MIT 许可证明示允许商用、修改、销售、再分发 | ✅ 可商业化;唯一义务 = 保留版权声明 + 许可证全文(关于页 + 分发包内 LICENSE) |
+| 商标 | MIT **不授予商标权** | ⚠️ 唯一真正风险点:DeepSeek / DeepSeek Harness / 鲸鱼 Logo 不得用作你的品牌、图标、域名主体;仅可描述性使用("基于 DeepSeek Harness 开源项目构建") |
+| 专利 | MIT 无明示专利授权条款(Apache 2.0 才有) | 理论风险极低(官方以 MIT 发布即表明开放意图);公司化后可做一次 FTO(自由实施)检索求稳 |
+| 模型 / API 条款 | DSH 是运行时不是模型;模型走各供应商 API | ⚠️ 逐家确认平台服务条款是否允许聚合/中转形态,留档;若本地部署开源权重(如 DeepSeek-R1 为 MIT),遵守对应许可 |
+
+实操清单:
+
+- ✅ 关于页放 MIT 全文 + "Based on DeepSeek Harness (MIT), © DeepSeek"
+- ✅ 品牌 / 图标 / 域名不出现 DeepSeek 字样与鲸鱼图形;DSH 缩写尽量淡化
+- ✅ 文案统一"基于开源项目构建",禁用"DeepSeek 出品 / 官方 / 授权"
+- ✅ 逐家供应商确认 API 商用与中转条款并留档
+- ✅ 公司化后做商标检索(中国商标网)+ FTO 检索
+
+**关于"自定义模型供应商"功能的边界**:允许用户自配供应商 key 本身无合规问题
+(属于用户自带工具行为);但**你方代付/中转的那部分流量**才受 §4-1 监管约束——
+境内上架版你自己提供的模型必须是境内已备案模型,用户自配 key 的部分建议在协议中
+写明"用户自行确保其使用的模型服务符合所在地法律法规"。
+
+---
+
+## 8. 华为对"开源项目商业化上架"的政策结论
+
+**允许。** AppGallery 审核指南不含任何"禁止基于开源项目"的条款;审核维度是
+功能真实性、安全、隐私、知识产权四项。商业化通道(付费下载 / IAP / 订阅 / 华为广告)
+对开源衍生应用一视同仁。前提三点:
+
+1. **许可证兼容**:MIT / Apache 2.0 商业友好,直接可用;GPL 类进应用商店有冲突
+   (商店分发附加条款与 GPL 的"不得施加进一步限制"冲突),本项目为 MIT,不涉及。
+2. **有增量价值,不做换皮包**:原封不动编译 + 挂广告会被判低质/重复应用。
+   本项目的鸿蒙原生适配、云端托管、计费体系即增量,文案中应突出。
+3. **品牌独立**:华为审核实务中"名称与已有应用相似且无商标授权证明"是高频驳回点
+   (开发者论坛大量案例)——应用名/图标必须与 DeepSeek 官方做隔离,
+   同时避免与其他在架应用撞名。
+
+---
+
+## 9. 主体资质核查与准备清单(深圳大方无隅科技有限公司)
+
+已公开可查:公司主体存续(天眼查/水滴信用有记录)、官网 **www.kailin.com.cn**、
+dfwy.ltd 已有网站 ICP 备案;**ICP 经营许可证与 EDI 许可证均已持有(用户确认 2026-08-23)**。
+工商详情页(注册资本/经营范围)公开搜索不可见,以下为**需自查补齐项**,
+按"上架必备 / 收费前必备"分两档:
+
+### 9.1 上架必备(免费版)
+
+| # | 事项 | 状态/动作 |
+| --- | --- | --- |
+| 1 | 营业执照经营范围含"软件开发/互联网信息服务"类条目 | ⚠️ 自查执照,不符先做经营范围变更 |
+| 2 | 华为开发者**企业**实名认证(营业执照 + 对公账户打款验证) | 待办 |
+| 3 | **App 备案**(工信部移动互联网应用程序备案,经华为代提交) | 待办;无备案号不予上架 |
+| 4 | 软著(计算机软件著作权登记) | 建议办,部分类目强制;也利于维权 |
+| 5 | 隐私政策 + 用户协议 + 可公开访问 URL | 隐私政策初稿已备(privacy-policy.zh.md),托管至 dfwy.ltd |
+| 6 | 生成式 AI 服务**登记**(属地:深圳网信办) | ⚠️ 上架含 AI 生成功能即需要,提前咨询 |
+| 7 | 内容审核能力 + 举报通道 | 可接第三方文本审核 API,审核会实测举报入口 |
+
+### 9.2 收费前必备(阶段 2)
+
+| # | 事项 | 状态/动作 |
+| --- | --- | --- |
+| 8 | ~~增值电信业务经营许可证(ICP 证 / EDI)~~ | ✅ **已持有(ICP + EDI 双证齐全)**,收费资质障碍清除 |
+| 9 | 华为 IAP 企业结算协议与对公收款账户 | 待办 |
+| 10 | 电子发票开具能力(充值发票) | 税控/电子发票平台接入 |
+| 11 | 用户协议补充充值协议 + 未成年人充值条款 | 阶段 2 文本更新 |
+| 12 | 隐私政策 v2(补支付信息、实名信息条目) | 按 privacy-policy.zh.md 注释 2 更新 |
+
+### 9.3 换皮问题的明确结论
+
+**不需要换皮,且不应换皮**——隐瞒基于 DSH 的事实反而违反 MIT 署名义务。
+正确做法是"品牌独立":自有名称/图标/启动页 + 明确标注"基于 DeepSeek Harness(MIT)构建"。
+唯一注意点:壳版内嵌官方 Web UI 会带出 DeepSeek 品牌元素,上架审核截图可能因此被要求
+提供授权证明——上架版优先使用协议原生版自有 UI,或对壳版做品牌主题化。
+
+### 9.4 命名讨论记录(2026-08-23)
+
+- ❌ 否决「hormonyDSH」:hormony 为 harmony 误拼(故意错拼在商标审查中反而构成攀附故意的
+  证据);DSH 为 DeepSeek 项目缩写——一个名称同时关联华为与 DeepSeek 两家标识,
+  命中华为审核"名称相似无授权证明"的高频驳回画像;且用户搜索时必然误拼为 harmony,传播受损
+- ❌ 否决「DoTouchAI / AiDotouch / TouchAI」:touch 词根在 9/42 类撞 OTOUCH / 3DTOUCH /
+  DON'TTOUCH / 触点互动 DOTOUCH 在先标,加 AI 前后缀均不改变显著部分,死路
+- ❌ 否决「智价云」作 App 显示名:"价"字语义指向定价/报价,与 AI 智能体错位(且 9/42 类未注册下来)
+
+**✅ 定稿方案(2026-08-23)**:
+
+| 层级 | 取值 | 说明 |
+| --- | --- | --- |
+| 应用显示名 | **Dotouch AI**(备选:Agent鸿蒙端) | 显示名≠注册商标,审核看"撞名+违规词",不卡商标近似;先用跑通首发 |
+| 品牌/商标背书 | **智价云**(公司已初审公告的 35/38 类标) | 放关于页/开发者栏,法律资产 |
+| Logo | 官网 kailin.com.cn 左上角图标(红蓝层叠标) | 已提取生成应用图标,见 assets/logo/appicon/ |
+
+- ⚠️ 风险提示:① 显示名"Dotouch AI"仍可能与"触点互动 DOTOUCH"(9/42 类组合标)产生
+  投诉风险——首发可先跑,长期建议布局自有新词(隅方/驭隅/ReinsAI)或核验 51 条清单后替换;
+  ② 备选"Agent鸿蒙端"含华为"鸿蒙"商标,仅作最后兜底。
+
+**商标公开检索快照(2026-08-23 二次核验,数据源:水滴信用内嵌数据)**:
+
+⚠️ 首次检索"未检出"系聚合平台索引不全的误判,已更正:
+
+| 检索项 | 结果 | 含义 |
+| --- | --- | --- |
+| 公司名下商标总数 | **51 条**(水滴信用) | 公司已有成规模商标布局 |
+| 已验证样例 | 「智价云」第 35 类(90750824)+ 第 38 类(90750825),申请日 2026-03-24,状态初审公告 | 公司今年仍在持续申标 |
+| 9 类 / 42 类持有情况 | **待确认**:完整清单在登录墙后,需商标局官网核验或向商标代理索取 | 决定 App 名能否直接用已有标 |
+| "无隅"(文字) | 已被深圳前海蓝钻珠宝注册于第 14 类(珠宝) | 与 9/42 类不构成同类冲突,若公司无现成 9/42 类标,仍可新申请"无隅" |
+| "KAILIN" 近似标 | 第 9 类已有 ADVENTURE KAILIN、KAILIAN、KEALYN 等在先标 | "凯林/KAILIN"9 类需做近似比对 |
+| 其他"大方无隅"企业 | 武汉(教育)、东莞(商顾)、广州(网络科技) | 广州为同行,留意其商标动作 |
+
+企业工商信息(同源已验证):统一社会信用代码 91440300MA5DGCWX1L;注册资本 500 万元;
+成立 2016-07-13;行业:科技推广和应用服务业;许可证:电信业务经营许可证 +
+互联网药品信息服务资格证书。
+
+下一步:从商标代理或商标局官网导出 51 条完整清单 → 筛出 9/42 类可用标 →
+若有合适标直接用作 App 名(省 7-9 个月新申请周期);没有则用"无隅"9/42 类新申请。
+
+自助核验路径:中国商标网(sbj.cnipa.gov.cn)→ 商标网上查询 → 商标综合查询 →
+申请人名称填"深圳大方无隅科技有限公司";阿里云商标平台(tm.aliyun.com)可交叉验证。

+ 49 - 0
assets/store/poll-until-done.sh

@@ -0,0 +1,49 @@
+#!/bin/bash
+# 自动轮询:等待收尾任务完成(改名 + 图标 + MIT 全文)
+# 间隔 8 分钟,最多 20 轮(约 2.6 小时),全部完成则 exit 0
+PJ="/Users/eastudio/DevEcoStudioProjects/dsh-harmony"
+LOG="/Users/eastudio/Documents/EvanAgent/assets/store/poll-status.log"
+INTERVAL=480
+MAX=20
+: > "$LOG"
+
+check_rename() {
+  grep -rhiE "dotouch" "$PJ/AppScope/resources/base/element/string.json" \
+    "$PJ/entry/src/main/resources/base/element/string.json" 2>/dev/null | grep -qi "dotouch"
+}
+check_icon() {
+  python3 - <<'EOF' >/dev/null 2>&1
+from PIL import Image
+p="/Users/eastudio/DevEcoStudioProjects/dsh-harmony/AppScope/resources/base/media/foreground.png"
+try:
+    im=Image.open(p).convert("RGBA"); red=blue=0
+    for r,g,b,a in im.getdata():
+        if a<40: continue
+        if r>200 and g<80 and b<80: red+=1
+        if b>120 and r<60 and g<80: blue+=1
+    exit(0 if (red>50 and blue>100) else 1)
+except Exception:
+    exit(1)
+EOF
+}
+check_mit() {
+  # LICENSE 文件存在即视为完成
+  [ -f "$PJ/LICENSE" ] && return 0
+  # 源码/资源中出现 MIT 字样即视为完成
+  grep -rliE "MIT" "$PJ/entry/src" "$PJ/AppScope" 2>/dev/null | grep -q .
+}
+
+for i in $(seq 1 "$MAX"); do
+  ts=$(date '+%m-%d %H:%M:%S')
+  rn=$(check_rename && echo OK || echo NO)
+  ic=$(check_icon && echo OK || echo NO)
+  mt=$(check_mit && echo OK || echo NO)
+  echo "[$ts] #$i 改名=$rn 图标=$ic MIT=$mt" | tee -a "$LOG"
+  if [ "$rn" = OK ] && [ "$ic" = OK ] && [ "$mt" = OK ]; then
+    echo "[$ts] ✅ 三项全部完成" | tee -a "$LOG"
+    exit 0
+  fi
+  [ "$i" -lt "$MAX" ] && sleep "$INTERVAL"
+done
+echo "[$(date '+%m-%d %H:%M:%S')] ⏰ 轮询超时($MAX 轮)" | tee -a "$LOG"
+exit 1

+ 107 - 0
assets/store/privacy-policy.zh.md

@@ -0,0 +1,107 @@
+# 隐私政策(上架版模板)
+
+> 使用前请替换所有【】占位符;斜体注释(※)为撰写提示,发布前删除。
+> 适用产品:【App 名称】(HarmonyOS 桌面端智能体应用)
+> 运营者:深圳大方无隅科技有限公司
+> 生效日期:【YYYY-MM-DD】  更新日期:【YYYY-MM-DD】
+
+---
+
+【App 名称】(以下简称"本应用")由深圳大方无隅科技有限公司(注册地址:【营业执照注册地址】,
+以下简称"我们")开发并运营。我们深知个人信息的重要性,将按照《中华人民共和国个人信息保护法》
+《中华人民共和国网络安全法》等法律法规要求保护您的个人信息。请您在使用本应用前仔细阅读本政策。
+
+## 一、我们收集的信息及用途
+
+### 1.1 您主动提供的信息
+
+| 信息类型 | 用途 | 是否必需 |
+| --- | --- | --- |
+| 手机号码(注册/登录) | 创建账号、登录验证、找回账号 | 必需 |
+| 实名认证信息(姓名、身份证号) | 履行生成式人工智能服务的实名制法定义务 | 必需(仅使用 AI 生成功能时) |
+| 充值订单信息(订单号、金额、时间) | 完成支付、开具发票、消费对账 | 必需(仅充值时) |
+
+### 1.2 我们自动收集的信息
+
+| 信息类型 | 用途 |
+| --- | --- |
+| 设备信息(设备型号、系统版本、OAID 等设备标识符) | 适配显示、崩溃排查、安全防护 |
+| 日志信息(IP 地址、访问时间、崩溃日志) | 服务稳定性保障、故障定位 |
+| 用量信息(token 消耗、功能使用记录) | 余额/积分扣减、费用对账 |
+
+### 1.3 您的对话内容与文件
+
+您与 AI 智能体的对话内容、您上传的文件将用于:① 向您提供智能体服务本身;
+② 在您主动查看历史会话时予以展示。我们不会将您的对话内容用于模型训练,
+不会向任何无关第三方出售。对话内容的保存期限为账号存续期间;您可随时删除会话,
+删除后我们将在【30】日内从服务器清除(法律另有规定的除外)。
+
+**重要提示**:为提供智能体服务,您的对话内容将发送给您当前所选模型对应的供应商
+服务器进行处理(境内已备案模型供应商)。如您使用"自定义模型供应商"功能并填入自己的
+API 密钥,相关数据将由您的设备/服务端直接发送至您选择的供应商,其数据处理行为由
+该供应商的隐私政策约束,请您自行确认。
+
+## 二、信息的存储与保护
+
+- 您的个人信息存储于中华人民共和国境内的服务器
+- 我们采用加密传输(TLS)、访问控制、日志审计等措施保护您的信息
+- 个人信息的保存期限为实现目的所需的最短期限;超出期限后我们将删除或匿名化处理
+
+## 三、信息的共享、转让与公开披露
+
+我们不会向第三方出售您的个人信息。仅在以下情形共享必要信息:
+
+| 接收方 | 共享内容 | 目的 |
+| --- | --- | --- |
+| 模型供应商(境内已备案) | 您的对话内容及上下文 | 生成 AI 回复(服务必需) |
+| 华为(应用内支付 SDK) | 订单信息、设备信息 | 完成充值支付 |
+| 【短信服务供应商】 | 手机号码 | 发送登录验证码 |
+| 监管机关 | 依法要求提供的信息 | 履行法定义务 |
+
+(※ 请维护一张"第三方 SDK 清单"作为本政策附件,列明 SDK 名称、提供方、收集信息类型,
+华为审核会核对。)
+
+## 四、您的权利
+
+您可以通过【App 内路径:我的 → 设置】或联系我们的客服邮箱行使以下权利:
+
+- **查询、复制**您的个人信息
+- **更正**不准确的信息
+- **删除**您的个人信息(含删除会话记录)
+- **撤回同意**:关闭非必要权限不影响核心功能使用
+- **注销账号**:【设置 → 账号 → 注销】,注销后我们将删除您的个人信息,
+  未消费的充值余额将按原支付路径退回
+- **获取个人信息副本**
+
+我们将在 15 个工作日内响应您的请求。
+
+## 五、未成年人保护
+
+本应用不面向 14 周岁以下儿童提供服务。若您为 14-18 周岁的未成年人,
+请在监护人陪同下阅读本政策并在监护人同意下使用;未成年人充值需经监护人同意,
+监护人可通过客服渠道申请未成年人误充值退款。
+
+## 六、本政策的更新
+
+本政策更新时,我们将在应用内显著位置公示,并在涉及重大变更(如收集范围扩大)时
+重新征求您的同意。
+
+## 七、联系我们
+
+个人信息保护负责人联系方式:
+- 邮箱:【privacy@dfwy.ltd】
+- 客服/投诉:【客服邮箱或电话】
+- 注册地址:【营业执照注册地址】
+
+---
+
+## 附:撰写与核查说明(发布前删除本节)
+
+1. 占位符必须全部替换;第三方 SDK 清单按实际接入情况填写(华为 IAP SDK 必有)
+2. 若上架版先于付费功能发布:删除 1.1 中充值订单、实名认证两行,同步删除未成年人充值段,
+   待付费上线时更新政策并重新公示(政策 URL 保持不变,内容版本化管理)
+3. 隐私政策需要可公开访问的 URL,托管至官网 **www.kailin.com.cn**
+   (如 https://www.kailin.com.cn/privacy ),与备案主体一致;用户协议同理放 /terms
+4. 华为审核会实测对照:App 内「我的 → 设置」须有政策入口;首次启动须有弹窗征求同意
+   (同意前不得收集任何信息);账号注销功能必须真实可用
+5. "对话内容发送给模型供应商"必须如实披露(第二节表格),这是生成式 AI 类应用的审核重点

+ 55 - 0
assets/store/promo-copy.zh.md

@@ -0,0 +1,55 @@
+# Dotouch AI 推广文案
+
+> 用途:应用详情页头图、社交媒体、社群/社区发布
+> 原则:不用极限词(最/第一/唯一/100%),不冒充官方,突出"智能体替你干活"的核心价值
+
+---
+
+## 一、主标语(Slogan,可作头图大字)
+
+- 主推:**在鸿蒙桌面,让 AI 智能体替你干活**
+- 备选:
+  - 说话,然后交给智能体
+  - 你的 AI 智能体,已经就位
+
+## 二、一句话卖点(详情页/海报短句,每条独立可用)
+
+- 一句指令,智能体替你读文件、跑命令、查资料
+- 多会话多工作区,任务再多也不乱
+- 每一步操作都看得见,可控、可追溯
+- 计划先行、人工把关,重要操作不失控
+- 原生鸿蒙桌面体验,开箱即用
+
+## 三、场景化文案(社区/自媒体,3 段式)
+
+**给开发者**
+
+> 让 Dotouch AI 帮你改代码、跑测试、查文档。
+> 它不是"聊天机器人",是一个真正能读写文件、执行命令、联网检索的智能体。
+> 你说目标,它拆任务、定计划、动手执行——每一步都在你眼皮底下。
+
+**给效率党**
+
+> 重复的活,交给智能体;你的时间,留给真正重要的事。
+> 在鸿蒙桌面上,用 Dotouch AI 把"要做的事"变成"已经在做的事"。
+
+**给尝鲜玩家**
+
+> HarmonyOS 桌面上的 AI 智能体,来了。
+> 基于开源 DeepSeek Harness 构建,第三方独立开发,非官方产品。
+> 免费体验,先跑起来。
+
+## 四、发布话术模板(社群/朋友圈)
+
+标题:Dotouch AI 上架啦 🎉
+正文:鸿蒙桌面端的 AI 智能体客户端,能读写文件、跑命令、联网搜索,多会话多工作区。
+基于开源 DeepSeek Harness(MIT)构建,第三方独立作品。
+评论区戳「立即体验」👉
+
+## 五、合规提示
+
+1. 所有文案已避开"最/第一/唯一"等极限词,可直接用
+2. "基于开源 DeepSeek Harness 构建、第三方独立开发、非官方产品"在对外传播时**必须保留**
+   (既满足 MIT 署名,也规避冒充官方风险)
+3. 不要写"DeepSeek 出品/官方/授权";不要用鲸鱼图形做传播素材
+4. 首发期如仍为测试版(需连电脑),传播文案先挂"内测/体验版"标签,避免预期落差

BIN
assets/store/screenshots/01-main-view.png


BIN
assets/store/screenshots/02-settings.png


BIN
assets/store/screenshots/03-conversation.png


+ 103 - 0
assets/store/user-agreement.zh.md

@@ -0,0 +1,103 @@
+# 用户协议(上架版模板)
+
+> 使用前请替换所有【】占位符;斜体注释(※)为撰写提示,发布前删除。
+> 适用产品:【App 名称】(HarmonyOS 桌面端智能体应用)
+> 运营者:深圳大方无隅科技有限公司(统一社会信用代码 91440300MA5DGCWX1L)
+> 官网:www.kailin.com.cn 生效日期:【YYYY-MM-DD】
+
+欢迎使用【App 名称】(以下简称"本应用")。本应用由深圳大方无隅科技有限公司(以下简称"我们")
+开发运营。请您在使用前仔细阅读本协议,勾选同意后即可使用;不同意请停止使用。
+
+## 一、服务说明
+
+1.1 本应用为基于开源项目 DeepSeek Harness(MIT License)构建的第三方智能体客户端,
+非 DeepSeek 官方产品或华为官方产品。
+1.2 本应用为您提供 AI 智能体对话、任务管理、会话管理等功能(具体以应用内实际功能为准)。
+1.3 我们有权根据业务需要调整、暂停或终止部分服务功能,并将在应用内公告。
+
+## 二、账号注册与实名
+
+2.1 您需注册账号并使用。您应对账号下的全部行为负责,妥善保管账号与验证信息。
+2.2 依据生成式人工智能相关法律法规,使用 AI 生成功能前需完成**实名认证**;
+未完成实名认证的用户可能无法使用部分功能。
+2.3 账号仅供您本人使用,不得转让、出借、出租或用于商业性转售。
+
+## 三、充值、余额与积分规则
+
+3.1 本应用提供充值服务(如当前版本未开放,请跳过本节,待上线后启用)。
+3.2 充值款项经【华为应用内支付】完成支付;到账后的余额/积分仅限本应用内消费,
+**不可提现、不可转账、不可兑换法定货币**。
+3.3 余额/积分有效期:【充值部分 12 个月、赠送部分 90 天,具体以充值页公示为准】;
+到期前 7 日我们将在应用内提醒。
+3.4 计费方式:AI 对话按实际 token 用量扣费,各模型费率在应用内公示并实时更新。
+3.5 退款规则:
+- 未消费的充值余额可申请退款,按原支付路径退回【7-15】个工作日内到账;
+- 已消费部分、赠送余额不可退;
+- 未成年人误充值:监护人可凭相关证明申请全额退款(见第七条)。
+
+## 四、用户行为规范
+
+4.1 您承诺不利用本应用从事以下行为:
+- 制作、复制、发布违反法律法规或公序良俗的内容(含暴力、色情、恐怖、谣言等);
+- 攻击、侵入、干扰本服务或第三方系统;
+- 利用本应用进行批量转售、套利、刷量等破坏公平的行为;
+- 反向工程、反编译本应用代码(法律法规允许的除外)。
+
+4.2 您理解:AI 生成内容可能存在不准确、不完整、过时的情况,**仅供您参考,
+您应对基于 AI 内容的决策自行负责**;重要决策请以权威来源核实。
+
+## 五、模型供应商与自定义服务
+
+5.1 为提供服务,您的对话内容将发送至您所选模型的供应商服务器处理;
+相关数据处理见《隐私政策》。
+5.2 如您使用"自定义模型供应商"功能并自行填入 API 密钥,相关数据将由您的
+设备/服务端直接发送至您选择的供应商;**您自行确保所选供应商的服务符合
+所在地法律法规,并自行承担相应后果**。
+
+## 六、知识产权
+
+6.1 本应用的软件、界面、商标等归我们或相应权利人所有;本应用基于
+DeepSeek Harness(MIT License)构建,原项目版权归原作者所有,
+许可证全文见应用内「关于 → 开源许可」。
+6.2 您提交、生成的对话内容,您保留其知识产权;您授予我们为提供服务所必需的
+存储、处理与展示许可。
+
+## 七、未成年人保护
+
+7.1 本应用不面向 14 周岁以下儿童提供服务。
+7.2 14-18 周岁用户应在监护人同意下使用;监护人可申请未成年人误充值退款,
+需提供监护人身份证明、未成年人与监护人关系证明及充值凭证。
+
+## 八、免责声明
+
+8.1 因不可抗力、网络故障、第三方供应商服务异常导致的损失,我们在法律允许的
+范围内不承担责任。
+8.2 本应用为第三方开发者作品,与 DeepSeek、华为无官方关联;
+任何由第三方主张的权利纠纷,按相关法律处理,我们将在知悉后及时配合处理。
+
+## 九、协议变更与终止
+
+9.1 本协议更新时将在应用内公示;重大变更将另行征求您的同意。
+9.2 您可随时注销账号(应用内「设置 → 账号 → 注销」);注销后我们删除您的
+个人信息,未消费余额按第三条退款规则处理。
+9.3 若您违反本协议,我们有权限制或终止向您提供服务,并保留追究法律责任的权利。
+
+## 十、争议解决
+
+10.1 本协议的订立、履行与解释适用中华人民共和国法律。
+10.2 因本协议产生的争议,双方应友好协商;协商不成的,提交**深圳市龙华区人民法院**
+管辖(※ 或运营者住所地法院,按实际填写)。
+
+## 十一、联系我们
+
+邮箱:【service@kailin.com.cn】 地址:【营业执照注册地址】
+
+---
+
+## 附:撰写与核查说明(发布前删除本节)
+
+1. 免费版上线时删除第三条(充值规则),保留"计费方式"一句亦可
+   (如实写明当前免费即可);付费上线时恢复并同步《隐私政策》v2
+2. 7.2 未成年退款条款为审核关注点,必须保留且后台要有真实退款通道
+3. 争议解决条款的管辖法院与公司住所地一致(公司住所地:深圳市龙华区)
+4. 与《隐私政策》放置同一官网域名(kailin.com.cn/terms),应用内「关于」页入口

BIN
dotouchai_logo/DoTouchAI_icon_A_1024.png


BIN
dotouchai_logo/DoTouchAI_icon_A_1024.webp


BIN
dotouchai_logo/DoTouchAI_icon_A_216.png


BIN
dotouchai_logo/DoTouchAI_icon_B_1024.png


BIN
dotouchai_logo/DoTouchAI_icon_B_1024.webp


BIN
dotouchai_logo/DoTouchAI_icon_B_216.png


BIN
dotouchai_logo/DoTouchAI_icon_C_1024.png


BIN
dotouchai_logo/DoTouchAI_icon_C_1024.webp


BIN
dotouchai_logo/DoTouchAI_icon_C_216.png


+ 6 - 0
dsh-desktop/.gitignore

@@ -0,0 +1,6 @@
+app/node_modules/
+dist/
+.electron-cache/
+.npm-cache/
+.smoke-*/
+app/assets/icon.iconset/

+ 54 - 0
dsh-desktop/CHANGELOG.md

@@ -0,0 +1,54 @@
+# Changelog
+
+壳版本(DSH Desktop 自身)遵循 SemVer:`MAJOR` 形态或破坏性变更、`MINOR` 新功能、`PATCH` 修复。
+dsh 本体版本独立于壳版本,由升级管家经 npm 管理。
+
+## 0.3.0 — 2026-08-24
+
+- **语音插件随安装包自动安装**:dsh-voice-client / dsh-voice-server(含 sherpa-onnx、ws 依赖)
+  打进 .app 的 Resources/plugins/,首启自动装入 web profile(拷贝 + 写 cordis.patch.yml),
+  换机重装即用;已装时按版本号增量升级;开发机 symlink 布局自动识别、永不覆盖
+- 设置分区的语音模块现在有导航标题(随插件 0.1.2 生效,见插件 CHANGELOG)
+- DMG 增大约 32M(携带插件与离线识别依赖)
+
+## 0.2.2 — 2026-08-24
+
+- "关于"面板新增 **web 插件版本清单**(如 dsh-voice-client v0.1.1·本地链接)——插件修复
+  是否已就位一眼可查,不必再靠重装确认(web 插件经 profile symlink 热加载,修复本就无需重装本应用)
+- "关于"面板显示**构建时间**(build.json,打包时写入)
+- DMG 卷名带版本号("DSH Desktop 0.2.2"),挂载即可分辨新旧安装包
+
+## 0.2.1 — 2026-08-22
+
+- 修复升级竞态:`npm install -g` 重写 bin 链接的几秒内重启服务会 `spawn ENOENT` 直接失败
+  (实际升级 0.1.1-rc.2 时触发过)——现在每次 spawn 前重新解析 dsh 路径(`resolveDsh()`),
+  ENOENT 时每 3 秒自动重试(最多 5 次),升级闭环不再需要手动"重启服务"
+
+## 0.2.0 — 2026-08-21
+
+- 图标换为官方 DeepSeek 鲸鱼标(路径取自 dsh web 的 /favicon.svg):
+  App 图标 = DeepSeek 品牌蓝渐变圆角方块 + 白鲸;菜单栏 = 黑色鲸鱼 template 轮廓(明暗自适应)
+- 托盘新增"在程序坞显示图标"开关,偏好持久化(settings.json),Dock 图标点击可唤起窗口
+- 修复:Finder/launchd 最小 PATH 环境下 dsh/npm 子进程(`#!/usr/bin/env node`)解析失败 —— 新增 env.js childEnv() 统一增强
+- 版本管理引入:CHANGELOG、`npm run bump` 脚本、"关于"面板(壳版本 + dsh 版本 + Electron 版本)
+- 新增 DMG 拖拽安装包(文件名携带版本号)
+
+## 0.1.0 — 2026-08-21
+
+- 初版:attach/spawn 双路径进程监督、端口冲突自动降级(--port 0)、崩溃指数退避重启、SIGTERM 优雅停机
+- 官方 Web UI 原生窗口(加载 dsh 服务地址,绝不打包 UI 副本)
+- 升级管家:npm dist-tags 发版探测(独立 cache)、一键 `npm i -g` 升级、升级后冒烟、失败自动回滚
+- 菜单栏托盘(状态/版本/端口)、开机自启、单实例、`⌘⇧D` 全局快捷键
+
+## 0.2.2 — 2026-08-24
+
+- 品牌替换:应用图标与菜单栏图标换为 DSH Harmony 原创字母标(规避第三方商标)
+- App 图标:assets/logo/exports/icon-1024.png(深空紫黑底 + DSH 单线字母)经 iconset→icns
+- 托盘:字母标黑色 template 渲染(圆头描边光栅化),明暗模式自适应
+
+
+## 0.2.3 — 2026-08-24
+
+- 更名 DoTouchAI:应用名/托盘提示/关于面板/安装包名全量对齐鸿蒙端
+- 图标换品牌标(智价云 kailin 红蓝层叠标):icns 白底版 + 托盘黑色 template 剪影
+

+ 160 - 0
dsh-desktop/PRODUCT-PLAN.zh.md

@@ -0,0 +1,160 @@
+# DSH Desktop 产品方案 —— 本地桌面端 + 官方同步升级
+
+> 目标:不用手动开终端跑 `dsh web`,点图标即用;官方一发版就能跟上,永不停留在旧版。
+> 本方案基于对本机环境的实际调研(路径、版本、升级链路均已验证),不是泛泛设想。
+
+---
+
+## 1. 现状诊断(本机已验证的事实)
+
+| 事实 | 值 |
+|---|---|
+| dsh 安装方式 | npm 全局,prefix = `~/.local`(升级**无需 sudo**) |
+| 安装位置 | `~/.local/lib/node_modules/@deepseek-ai/dsh`,bin 在 `~/.local/bin/dsh` |
+| 本机版本 | `0.1.0-rc.6` |
+| npm 官方最新 | `0.1.1-rc.2`(dist-tags:`latest` 与 `next` 同版本)→ **本机已落后,升级需求真实存在** |
+| Web 服务 | `dsh web` → `http://127.0.0.1:3080`,支持 `--host` / `--port`(`--port 0` 让 OS 分配空闲端口)/ `--trusted-host` |
+| 数据目录 | `~/.dsh`(profiles / sessions / settings.yaml / storages / web.log) |
+| 优雅停机 | dsh 内建 SIGINT/SIGTERM 有界停机(5 秒优雅退出),**可被安全托管** |
+| 痛点 1 | 无 daemon 模式:必须手动开终端、终端不能关 |
+| 痛点 2 | 升级全靠手记:要自己记得查 npm、跑 `npm i -g`、重启服务 |
+| 环境坑 | `~/.npm` 缓存里有 root 属主文件,npm 目前报 EPERM;用独立 `--cache` 目录可绕过(已验证),彻底修复需一次性 `sudo chown -R 501:20 ~/.npm` |
+
+### 关键架构事实(决定升级方案的正确性)
+
+- Web UI 的全部官方代码(`dsh-base`、`dsh-web-app` 等几十个 bundle)**都是 CLI 包 `@deepseek-ai/dsh` 的依赖**,profile(`~/.dsh/profiles/web`)只引用 bundle 名,实际解析到 CLI 安装目录的 node_modules。
+- 所以:**`npm install -g @deepseek-ai/dsh@latest` + 重启 `dsh web` = 整个 Web UI 立即变成官方最新版**。官方 UI 必须由 `dsh web` 服务注入 `window.__DSH_BOOT__` 才能跑,任何"把 UI 打包进桌面端"的做法都会造成分叉。
+- 用户自有插件(如本机的 `dsh-voice-client`,file: 本地依赖)在 profile 自己的 node_modules 里,不受 CLI 升级影响,但 rc 版本间 plugin API 可能不兼容 → 升级后必须做启动冒烟检查 + 可回滚。
+- 会话持久化在 `~/.dsh/sessions`(按工作区目录分片),服务重启不丢。
+
+### 社区已有方案对比(为什么还要自己做)
+
+| 方案 | 做法 | 与本需求的差距 |
+|---|---|---|
+| [LBurny/deepseek-harness-desktop](https://github.com/LBurny/deepseek-harness-desktop) | Tauri,安装包**内置** Node + dsh(Windows) | 升级 = 重下安装包,无法实时跟随官方 |
+| [sdkwork-ai/deepseek-harness-desktop](https://github.com/sdkwork-ai/deepseek-harness-desktop) | 跨平台安装包,随发版打包 dsh | 同上:官方发版到桌面端跟上有时差,且替换了你自己的 npm 安装 |
+| **本方案** | **薄壳监督者:不打包 dsh,托管你本机的 npm 安装,通过 npm 原地升级** | 官方发版 → 桌面端检测(小时级)→ 一键/自动升级 → 重启即最新 |
+
+---
+
+## 2. 产品定位
+
+**"薄壳 + 管家"**:桌面端不复制、不分发、不 fork 任何官方代码。它只做三件事:
+
+1. **进程托管**(Supervisor):后台拉起/守护 `dsh web`,崩溃自动重启
+2. **窗口**(Shell):原生窗口加载 `http://127.0.0.1:<port>`
+3. **升级管家**(Updater):盯 npm 官方发版,升级你本机的 dsh 并无感重启
+
+一句话:**官方代码永远只有一个来源——npm;桌面端只是让它"开机即在、永不过期"的那层壳。**
+
+---
+
+## 3. 产品形态(macOS)
+
+- **菜单栏常驻**(托盘图标,不占 Dock):图标即状态(运行中 / 已停止 / 有新版本小角标)
+- **主窗口**:加载本地 dsh Web UI,关窗 = 隐藏到托盘(服务继续跑),退出 = 优雅停机
+- **托盘菜单**:
+  - 打开 DSH(主窗口)
+  - 状态行:`运行中 · 0.1.1-rc.2 · 端口 3080`
+  - 检查更新 / `⬆ 新版 0.1.2 可用,点击升级`
+  - 重启服务 / 停止服务
+  - 诊断(日志 tail、会话目录、打开 ~/.dsh)
+  - 开机自启开关、退出
+- **通知**:新版本可用、升级完成并已重启、服务异常退出与自动恢复
+- **首启引导**:分阶段进度(检测 Node/dsh → 拉起服务 → 就绪开窗),首次使用即修复 `~/.npm` 权限问题
+
+---
+
+## 4. 架构
+
+```
+┌────────────────────────────────────────────────┐
+│  DSH Desktop(Electron,约 200 行主进程代码)     │
+│                                                │
+│  ┌──────────┐  ┌────────────┐  ┌───────────┐  │
+│  │ Supervisor│  │  Window    │  │  Updater  │  │
+│  │ 进程托管  │  │ BrowserWindow│ │ 升级管家  │  │
+│  └────┬─────┘  └─────┬──────┘  └─────┬─────┘  │
+└───────┼──────────────┼───────────────┼────────┘
+        │ spawn/守护    │ load URL       │ npm view / npm i -g
+        ▼              ▼                ▼
+  dsh web --port 3080   http://127.0.0.1:3080   npm registry
+  (官方代码,npm 安装)  (官方 UI,随包升级)    (@deepseek-ai/dsh)
+```
+
+### Supervisor(进程托管)
+- `spawn('dsh', ['web', '--port', '3080'])`,detached=false,stdio 管道(日志 → 内存 ring buffer + `~/.dsh/web.log` 落盘)
+- **就绪探测**:轮询 `GET http://127.0.0.1:3080` 直至 2xx 再显示窗口(避免白屏);`--port 0` + 从 stdout 解析实际端口作为端口冲突时的降级方案
+- **端口冲突**:3080 被占时先探测是否已是健康的 dsh → 是则直接 attach 复用;否则换 `--port 0`
+- **崩溃自愈**:异常退出按指数退避自动重启(1s/5s/30s…),托盘与通知可见
+- **优雅停机**:退出时发 SIGTERM,利用 dsh 内建的 5 秒有界停机;超时才 SIGKILL
+- 单实例锁:二次启动只聚焦已有窗口
+
+### Window(窗口壳)
+- `BrowserWindow` 加载 `http://127.0.0.1:<port>`,启动期显示 splash(阶段进度)
+- 升级重启后窗口自动 reload;主题跟随系统
+- 快捷键:`Cmd+Shift+D` 唤起/隐藏(可配置)
+
+### Updater(升级管家,核心差异点)
+- **发现**:`npm view @deepseek-ai/dsh dist-tags --cache <独立缓存目录>`(独立 cache 规避 `~/.npm` 权限坑,已验证可用)。频率:启动时 + 每 6 小时(可配 1h/6h/24h)+ 手动"立即检查"。可选跟随 `latest` 或 `next` tag
+- **对比**:`dsh -V`(读本地真实版本)vs registry
+- **执行**:`npm install -g @deepseek-ai/dsh@<tag>`(prefix `~/.local`,无需 sudo,不动你的 profile 与插件)
+- **生效**:SIGTERM 优雅停机 → 重新 spawn → 就绪后窗口 reload → 托盘通知 `已升级到 0.1.2`
+- **冒烟与回滚**:升级后若服务 60 秒内起不来(rc 版本可能有破坏性变更或本地插件不兼容),自动 `npm i -g @deepseek-ai/dsh@<旧版本>` 回滚并通知
+- **策略**:默认"发现即提示、一键升级";可开"自动升级"(检查到新版直接升,全程通知可溯)
+- **版本史**:设置页保留最近 5 个版本号,随时手动切换(本质就是 `npm i -g` 指定版本)
+
+---
+
+## 5. 技术选型
+
+| 维度 | Electron(推荐 MVP) | Tauri 2 |
+|---|---|---|
+| 语言 | 全 JS,与 dsh 生态一致 | 主进程 Rust,需 Rust 工具链 |
+| 体积/内存 | ~90MB / ~150MB | ~10MB / ~80MB |
+| 托盘/窗口/子进程 | 全部成熟 API,~200 行搞定 | 需在 Rust 侧写进程监督 |
+| 迭代速度 | 快(你本人可维护) | 慢 |
+
+**推荐:Electron 起步。** 壳极薄(三个模块),日后若嫌重,迁移 Tauri 的成本也很低——因为所有复杂度都在 dsh 侧,壳里没有业务逻辑。Tauri 版可作为二期优化。
+
+---
+
+## 6. 路线图
+
+### P0 —— 能用(1~2 天)
+- [ ] Electron 工程 + 单实例 + 托盘 + 开机自启
+- [ ] Supervisor:拉起 `dsh web`、就绪探测、崩溃退避重启、优雅退出
+- [ ] 主窗口加载 `127.0.0.1:3080`,splash 引导,关窗隐藏
+- [ ] 首启环境体检:Node/dsh 存在性、`~/.npm` 权限检测与修复引导
+
+### P1 —— 永不过期(1~2 天)
+- [ ] Updater:版本轮询、新版本通知、一键升级 + 无感重启
+- [ ] 升级冒烟失败自动回滚;版本史手动切换
+- [ ] 独立 npm cache 目录(根治 EPERM)
+
+### P2 —— 好用(按需)
+- [ ] 诊断面板:实时日志(tail `~/.dsh/web.log`)、端口/PID、一键重启
+- [ ] 插件面板:转发 `dsh plugin --profile web add/remove/update`,流式输出
+- [ ] 多 Profile 切换:web / tui / headless 一键切换或并存
+- [ ] 会话快捷入口:按工作区列出 `~/.dsh/sessions` 最近会话,直达恢复
+- [ ] 手机远程访问(Cloudflare Quick Tunnel + token 门禁,参考社区实现)
+- [ ] Tauri 瘦身版 / 上游贡献 `dsh desktop` 子命令
+
+---
+
+## 7. 风险与对策
+
+| 风险 | 对策 |
+|---|---|
+| rc 版本破坏性变更(官方明确 developer preview 会有) | 升级后启动冒烟 + 自动回滚;保守用户可锁版本不自动升 |
+| 本地插件(dsh-voice-client)与新版本 API 不兼容 | 同上冒烟回滚;日志面板能直接看到插件加载错误 |
+| 3080 端口冲突 | 先探测 attach 健康实例,否则 `--port 0` 动态端口 |
+| `~/.npm` root 属主文件(当前真实存在) | 首启检测 + 引导一次性 `sudo chown`;日常升级走独立 cache |
+| npm 网络失败 | 升级是幂等的 `npm i -g`,失败保留旧版运行中,仅提示重试 |
+| 用户终端里已有 `dsh web` 在跑 | 端口探测 attach,不重复拉起 |
+
+## 8. Non-goals(明确不做)
+
+- 不 fork / 不内嵌官方 UI 代码(保证"随官方升级"永远成立的前提)
+- 不做账号、云同步、多用户
+- 不替代 dsh 的 profile / 插件体系,只做转发与展示

+ 98 - 0
dsh-desktop/README.md

@@ -0,0 +1,98 @@
+# DSH Desktop
+
+DSH 的本地桌面端:菜单栏常驻 + 原生窗口 + 升级管家。
+**不打包任何官方代码**——它只托管你本机 npm 安装的 `dsh web`,并通过 npm 跟随官方升级。
+
+## 设计
+
+```
+菜单栏 App(Electron)
+ ├─ Supervisor  attach 已有 dsh(:3080 健康时)/ spawn 并守护自己的子进程
+ ├─ Window      BrowserWindow → http://127.0.0.1:<port>(官方 UI,绝不内置副本)
+ └─ Updater     npm view 盯发版 → 一键 npm i -g 升级 → 优雅重启 → 冒烟失败自动回滚
+```
+
+产品方案见 [PRODUCT-PLAN.zh.md](PRODUCT-PLAN.zh.md)。
+
+## 版本管理
+
+- 壳版本(DSH Desktop 自身)遵循 SemVer:`MAJOR` 形态/破坏性变更、`MINOR` 新功能、`PATCH` 修复
+- 发版流程:`npm run bump -- minor|patch|major` → 在 [CHANGELOG.md](CHANGELOG.md) 登记变更 → `npm run dist`
+  (.app 的 Info.plist 与 DMG 文件名自动携带新版本号)
+- dsh 本体的版本独立于壳版本,由升级管家经 npm 管理;两者都可见于托盘"关于 DSH Desktop"
+
+## 开发
+
+```sh
+cd app
+npm install          # 仅一个 devDependency:electron(42.3.3,复用本机缓存)
+npm run icons        # 生成图标:官方鲸鱼路径 → PNG/icns(纯 Node 光栅化 + iconutil)
+npm start            # 启动
+```
+
+## 打包
+
+```sh
+cd app
+npm run dist
+# → ../dist/DSH Desktop.app(手工构建 bundle:electron dist + 源码,无网络依赖)
+# → ../dist/DSH-Desktop-<版本>-arm64.dmg(拖拽安装:app + Applications 快捷方式)
+```
+
+安装:打开 DMG,把 DSH Desktop 拖进 Applications 文件夹即完成。已做 ad-hoc 签名,首次打开若仍被拦:
+右键 → 打开(或 `xattr -dr com.apple.quarantine "/Applications/DSH Desktop.app"`)。
+
+## 和 Chrome PWA(应用程序文件夹里的 "DeepSeek Harness")的区别
+
+Chrome 装的 "DeepSeek Harness" 是一个 PWA 快捷方式:一个指向 `http://127.0.0.1:3080/` 的
+Chrome 窗口。它**不会启动 dsh**——终端里没有 `dsh web` 在跑,点开就是连接失败;
+服务挂了它也只是白屏。DSH Desktop 是进程监督者:点图标自动拉起并守护服务,
+菜单栏看状态、崩溃自动重启、npm 发版一键升级(失败自动回滚)。装好 DSH Desktop
+后 PWA 可以删掉(两者指向同一个本地服务,功能上前者完全覆盖后者)。
+
+## 使用
+
+- 启动后菜单栏出现图标;主窗口加载本地 dsh Web UI,关窗 = 隐藏(服务继续跑),退出走托盘菜单(优雅停机)
+- 如果终端里已有 `dsh web` 在 3080:直接 attach 复用,不重复拉起
+- 托盘菜单:打开 DSH / 检查更新 / 升级 / 重启服务 / 停止·启动 / 日志 / 开机自启 / 退出
+- 快捷键 `⌘⇧D` 显示/隐藏窗口
+- 日志:`~/Library/Application Support/DSH Desktop/dsh-web.log`(npm 独立缓存在同级 `npm-cache/`)
+- 发现新版本:通知 + 托盘菜单出现"⬆ 升级";升级后服务起不来会自动回滚旧版本并通知
+
+## 测试钩子(环境变量)
+
+| 变量 | 作用 |
+|---|---|
+| `DSH_DESKTOP_SMOKE=1` | 就绪后自动退出并打印状态(冒烟测试) |
+| `DSH_DESKTOP_NO_ATTACH=1` | 不 attach,强制 spawn 自己的子进程 |
+| `DSH_DESKTOP_USER_DATA=dir` | 覆盖 Electron 用户数据目录 |
+| `DSH_DESKTOP_DSH_BIN=path` | 指定 dsh 二进制路径 |
+| `DSH_DESKTOP_PORT=3080` | 覆盖默认端口 |
+
+```sh
+# 冒烟(attach 路径)
+DSH_DESKTOP_SMOKE=1 ./node_modules/.bin/electron .
+# 冒烟(spawn 路径,会自己拉一个 dsh 实例)
+DSSH_DESKTOP_SMOKE=1 DSH_DESKTOP_NO_ATTACH=1 ./node_modules/.bin/electron .
+```
+
+## 已验证 / 待你在真实环境验证
+
+已在本机端到端验证:
+- attach 路径:探测 `__DSH_BOOT__` → 复用 3080 现有实例,退出时不碰它
+- spawn 路径:固定端口冲突 → 自动降级 `--port 0` → 子进程端口解析(stdout + lsof 兜底)→ 就绪 → SIGTERM 优雅退出(exit 0)→ 无残留进程
+- 崩溃退避重启逻辑(1s/5s/30s/60s)
+- 版本读取(package.json 解析)
+- npm 发版探测(独立 cache,发现 0.1.1-rc.2 > 0.1.0-rc.6)
+- 打包后的 .app 冒烟通过;DMG 挂载后从卷内直接运行冒烟通过
+- Finder 式最小 PATH(launchd 环境)下的二进制定位与子进程 PATH 增强(dsh/npm 的 node shebang 可解析)
+
+沙箱内无法实测:`npm install -g` 升级与自动回滚(需要写 `~/.local`,见下)。
+装好 .app 后点一次"检查更新 → 升级到 0.1.1-rc.2"即完成最终验证。
+
+## 环境备注
+
+- 本机 `~/.npm` 缓存存在 root 属主文件(历史上 sudo npm 的遗留),已让 npm 报 EPERM。
+  桌面端所有 npm 操作走独立 cache 目录不受影响;如想根治:
+  `sudo chown -R 501:20 ~/.npm`
+- dsh 升级通道是 npm dist-tag(`latest`/`next`),当前默认跟 `latest`。

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
dsh-desktop/app/assets-src/whale-path.txt


BIN
dsh-desktop/app/assets/icon.icns


BIN
dsh-desktop/app/assets/trayTemplate.png


BIN
dsh-desktop/app/assets/trayTemplate@2x.png


+ 80 - 0
dsh-desktop/app/env.js

@@ -0,0 +1,80 @@
+"use strict";
+/**
+ * Binary discovery + child environment for GUI-context launches.
+ *
+ * From Finder/launchd an app inherits PATH=/usr/bin:/bin:/usr/sbin:/sbin — the
+ * `#!/usr/bin/env node` shebangs of dsh and npm cannot resolve there. All
+ * spawned children therefore run through childEnv(), which prepends the
+ * directories that actually hold dsh, npm, and node: both their symlink spots
+ * (~/.local/bin) and their realpath homes (e.g. a version manager under
+ * ~/.hermes/node/bin).
+ */
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+function firstExisting(candidates) {
+	for (const candidate of candidates) {
+		try { if (candidate && fs.existsSync(candidate)) return candidate; } catch { /* next */ }
+	}
+	return null;
+}
+
+function pathDirs() {
+	return (process.env.PATH || "").split(":").filter(Boolean);
+}
+
+/** The dsh binary: explicit override, common user install spots, then PATH. */
+function findDsh() {
+	const override = process.env.DSH_DESKTOP_DSH_BIN;
+	if (override) return override;
+	const home = os.homedir();
+	return firstExisting([
+		path.join(home, ".local/bin/dsh"),
+		"/opt/homebrew/bin/dsh",
+		"/usr/local/bin/dsh",
+		...pathDirs().map((dir) => path.join(dir, "dsh")),
+	]) || "dsh";
+}
+
+/** The npm binary: the same discovery strategy. */
+function findNpm() {
+	const home = os.homedir();
+	return firstExisting([
+		path.join(home, ".local/bin/npm"),
+		"/opt/homebrew/bin/npm",
+		"/usr/local/bin/npm",
+		...pathDirs().map((dir) => path.join(dir, "npm")),
+	]) || "npm";
+}
+
+const DSH_BIN = findDsh();
+const NPM_BIN = findNpm();
+
+/**
+ * Re-resolve the dsh binary right before every spawn. `npm install -g`
+ * replaces the bin symlink during upgrades; a path captured at shell startup
+ * (or a bare "dsh" fallback resolved while the link was missing) would then
+ * hit ENOENT — exactly the upgrade-race failure seen in the wild.
+ */
+function resolveDsh() {
+	return findDsh();
+}
+
+/** Environment (PATH augmented) for every child process we spawn. */
+function childEnv() {
+	const dirs = [];
+	for (const bin of [DSH_BIN, NPM_BIN]) {
+		if (path.isAbsolute(bin)) dirs.push(path.dirname(bin));
+		try { dirs.push(path.dirname(fs.realpathSync(bin))); } catch { /* not a file */ }
+	}
+	dirs.push(
+		path.join(os.homedir(), ".local/bin"),
+		"/opt/homebrew/bin",
+		"/usr/local/bin",
+	);
+	const unique = [...new Set(dirs.filter(Boolean))];
+	return { ...process.env, PATH: `${unique.join(":")}:${process.env.PATH || ""}` };
+}
+
+module.exports = { DSH_BIN, NPM_BIN, findDsh, findNpm, resolveDsh, childEnv };

+ 347 - 0
dsh-desktop/app/main.js

@@ -0,0 +1,347 @@
+"use strict";
+/**
+ * DoTouchAI — a thin menu-bar shell for the local `dsh web` service.
+ *
+ * Three jobs only:
+ *   1. supervise: attach to a healthy `dsh web` on :3080, else spawn and
+ *      baby-sit our own child (crash restart with backoff, graceful stop).
+ *   2. window: a BrowserWindow pointing at the service URL — never a bundled
+ *      copy of the UI, because the official web app must be served by dsh
+ *      itself (it injects window.__DSH_BOOT__).
+ *   3. update: watch npm for @deepseek-ai/dsh releases; one-click upgrade +
+ *      restart, automatic rollback when the upgraded service fails to boot.
+ *
+ * Test hooks (documented in ../README.md):
+ *   DSH_DESKTOP_SMOKE=1        auto-quit shortly after readiness, log state
+ *   DSH_DESKTOP_NO_ATTACH=1    never attach; always spawn our own child
+ *   DSH_DESKTOP_USER_DATA=dir  force the Electron user-data directory
+ *   DSH_DESKTOP_DSH_BIN=path   force the dsh binary path
+ */
+const { app, BrowserWindow, Tray, Menu, Notification, nativeImage, globalShortcut, shell, dialog } = require("electron");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { Service } = require("./service");
+const { Updater } = require("./updater");
+
+const SMOKE = process.env.DSH_DESKTOP_SMOKE === "1";
+
+// This shell renders one trusted local URL; Chromium's OS-level sandbox and GPU
+// acceleration are unnecessary here and fail outright in constrained launch
+// contexts (seatbelt profiles, VNC/SSH sessions). Renderer isolation stays on
+// via webPreferences below.
+app.commandLine.appendSwitch("no-sandbox");
+app.commandLine.appendSwitch("disable-gpu");
+app.disableHardwareAcceleration();
+
+let tray = null;
+let win = null;
+let service = null;
+let updater = null;
+let quitting = false;
+let reloadTimer = null;
+let settings = { dockIcon: false };
+
+function settingsPath() { return path.join(app.getPath("userData"), "settings.json"); }
+function loadSettings() {
+	try { return { dockIcon: false, ...JSON.parse(fs.readFileSync(settingsPath(), "utf8")) }; }
+	catch { return { dockIcon: false }; }
+}
+function saveSettings() {
+	try { fs.writeFileSync(settingsPath(), JSON.stringify(settings, null, 2)); } catch { /* ignore */ }
+}
+
+if (process.env.DSH_DESKTOP_USER_DATA) {
+	fs.mkdirSync(process.env.DSH_DESKTOP_USER_DATA, { recursive: true });
+	app.setPath("userData", process.env.DSH_DESKTOP_USER_DATA);
+}
+
+if (!app.requestSingleInstanceLock()) {
+	app.quit();
+} else {
+	app.on("second-instance", () => showWindow());
+	app.on("activate", () => showWindow()); // clicking the Dock icon
+	app.whenReady().then(main).catch((err) => {
+		console.error("[desktop] fatal:", err);
+		app.exit(1);
+	});
+	app.on("window-all-closed", () => { /* tray-resident: keep running */ });
+	app.on("before-quit", (event) => { event.preventDefault(); quit(); });
+}
+
+async function main() {
+	// Menu-bar resident by default: the shell is a supervisor that keeps the
+	// dsh service running while its window comes and goes. Users who prefer a
+	// regular Dock app can flip "在程序坞显示图标" in the tray menu.
+	settings = loadSettings();
+	if (!settings.dockIcon) app.dock?.hide();
+	service = new Service({
+		userDataDir: app.getPath("userData"),
+		onState: (s) => {
+			refreshTray();
+			if (s.running && s.url && win && win.webContents.getURL() !== s.url) loadApp();
+		},
+		onLog: (line) => { if (SMOKE) console.log("[dsh]", line); },
+	});
+	updater = new Updater({
+		service,
+		cacheDir: path.join(app.getPath("userData"), "npm-cache"),
+		log: (line) => service.appendLog(line),
+		notify,
+	});
+
+	createTray();
+	createWindow();
+
+	// Bundled plugin sync (voice suite): install/upgrade into the web profile
+	// BEFORE the service starts, so a cold boot loads them immediately; when
+	// attaching to an already-running instance, restart it to pick them up.
+	let pluginChanges = [];
+	try {
+		const { syncPlugins } = require("./plugins");
+		const { changed } = syncPlugins({ log: (line) => service.appendLog(line) });
+		pluginChanges = changed;
+	} catch (err) {
+		service.appendLog(`[plugins] sync failed: ${err.message}`);
+	}
+	if (pluginChanges.length > 0) {
+		notify("语音插件已安装/更新", `${pluginChanges.join("、")} · 即将生效`);
+	}
+
+	const ok = await service.ensure();
+	if (!ok) notify("DSH 服务未能启动", "托盘菜单可“重启服务”,或打开日志排查");
+	if (pluginChanges.length > 0 && service.running && !service.spawned) {
+		// attached instance predates the plugin change — restart to load it
+		service.appendLog("[plugins] restarting attached service to load plugin changes");
+		await service.restart();
+	}
+	if (service.url) await loadApp();
+	refreshTray();
+
+	try {
+		globalShortcut.register("CommandOrControl+Shift+D", () => {
+			if (win && win.isVisible() && win.isFocused()) win.hide();
+			else showWindow();
+		});
+	} catch { /* shortcut already taken — ignore */ }
+
+	updater.start();
+
+	if (SMOKE) {
+		console.log(`[smoke] state=${service.state} url=${service.url} version=${service.version()}`);
+		setTimeout(() => app.quit(), 2500);
+	}
+}
+
+function createWindow() {
+	win = new BrowserWindow({
+		width: 1440,
+		height: 920,
+		minWidth: 960,
+		minHeight: 600,
+		show: false,
+		title: "DSH",
+		backgroundColor: "#17181c",
+		webPreferences: { contextIsolation: true, sandbox: true },
+	});
+	win.loadURL(splashUrl());
+	win.once("ready-to-show", () => win.show());
+	win.on("close", (event) => { if (!quitting) { event.preventDefault(); win.hide(); } });
+	win.webContents.on("did-fail-load", () => { if (!quitting) scheduleReload(); });
+	win.webContents.setWindowOpenHandler(({ url }) => {
+		if (/^https?:/.test(url)) shell.openExternal(url);
+		return { action: "deny" };
+	});
+}
+
+async function loadApp() {
+	if (!win || !service.url || quitting) return;
+	try {
+		await win.loadURL(service.url);
+		if (!win.isVisible()) win.show();
+	} catch { scheduleReload(); }
+}
+
+function scheduleReload() {
+	if (reloadTimer || quitting) return;
+	reloadTimer = setTimeout(async () => {
+		reloadTimer = null;
+		if (!quitting) await loadApp();
+	}, 2000);
+}
+
+function showWindow() {
+	if (!win) {
+		createWindow();
+		if (service && service.url) loadApp();
+		return;
+	}
+	win.show();
+	try { win.focus(); } catch { /* window may be mid-teardown */ }
+	if (service && service.url && win.webContents.getURL() !== service.url) loadApp();
+}
+
+function createTray() {
+	const icon = nativeImage.createFromPath(path.join(__dirname, "assets", "trayTemplate.png"));
+	icon.setTemplateImage(true);
+	tray = new Tray(icon);
+	tray.setToolTip("DoTouchAI");
+	refreshTray();
+}
+
+function tooltipText() {
+	if (!service) return "DoTouchAI";
+	const map = { running: "运行中", starting: "启动中", stopping: "停止中", stopped: "已停止", crashed: "异常退出" };
+	let text = `DSH · ${map[service.state] || service.state}`;
+	if (service.port) text += ` · :${service.port}`;
+	if (updater && updater.available) text += ` · 有新版 ${updater.available.version}`;
+	return text;
+}
+
+function refreshTray() {
+	if (!tray) return;
+	tray.setToolTip(tooltipText());
+	tray.setContextMenu(Menu.buildFromTemplate(menuTemplate()));
+}
+
+/** Build metadata written by tools/package.sh (absent in plain `npm start`). */
+function buildInfo() {
+	try {
+		return JSON.parse(fs.readFileSync(path.join(__dirname, "build.json"), "utf8"));
+	} catch {
+		return {};
+	}
+}
+
+/**
+ * User plugins of the web profile (~/.dsh/profiles/web/node_modules), each as
+ * "插件名 vX.Y.Z". These upgrade independently of the shell — surfacing them
+ * here answers "did that plugin fix make it into my install?" directly.
+ */
+function profilePluginsSummary() {
+	const lines = ["插件(web profile):"];
+	try {
+		const root = path.join(process.env.DSH_HOME || path.join(os.homedir(), ".dsh"), "profiles", "web", "node_modules");
+		const entries = fs.readdirSync(root).filter((name) => !name.startsWith("."));
+		const rows = [];
+		for (const name of entries) {
+			try {
+				const pkgPath = path.join(root, name, "package.json");
+				const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
+				// A dsh plugin: declares a "dsh" field or follows the dsh-* naming
+				// convention; plain deps (ws, sherpa-onnx, …) stay hidden.
+				if (pkg.name && pkg.version && (pkg.dsh || pkg.name.startsWith("dsh-"))) {
+					const linked = fs.lstatSync(path.join(root, name)).isSymbolicLink() ? " · 本地链接" : "";
+					rows.push(`  ${pkg.name} v${pkg.version}${linked}`);
+				}
+			} catch { /* entry without a readable package.json — skip */ }
+		}
+		if (rows.length === 0) return ["插件(web profile):无"];
+		lines.push(...rows.sort());
+	} catch {
+		return [];
+	}
+	return lines;
+}
+
+function menuTemplate() {
+	const items = [];
+	const stateLabel = {
+		running: `● 运行中${service.detail ? " · " + service.detail : ""}`,
+		starting: "◌ 启动中…",
+		stopping: "◌ 停止中…",
+		stopped: "⏸ 已停止",
+		crashed: `⚠ ${service.detail || "异常退出"}`,
+	}[service ? service.state : "stopped"] || "…";
+	const version = (service && service.version()) || updater?.current || "?";
+	items.push({ label: `${stateLabel} · v${version}${service && service.port ? " · :" + service.port : ""}`, enabled: false });
+	items.push({ type: "separator" });
+	items.push({ label: "打开 DSH", click: () => showWindow() });
+	if (updater && updater.available) {
+		items.push({ label: `⬆ 升级到 v${updater.available.version}`, click: () => { updater.upgrade(); } });
+	}
+	items.push({ label: "检查更新…", click: () => { updater.checkNow(true); } });
+	items.push({ type: "separator" });
+	items.push({
+		label: "重启服务",
+		click: () => { service.restart().then((ok) => { if (!ok) notify("重启失败", "详见日志"); }); },
+	});
+	items.push({
+		label: service && (service.running || service.state === "starting") ? "停止服务" : "启动服务",
+		click: () => {
+			if (service && (service.running || service.state === "starting")) service.stop();
+			else if (service) service.start(0);
+		},
+	});
+	items.push({ type: "separator" });
+	items.push({ label: "打开日志文件", click: () => shell.showItemInFolder(service.ensureLogFile()) });
+	items.push({
+		label: "打开数据目录(~/.dsh)",
+		click: () => shell.openPath(process.env.DSH_HOME || path.join(os.homedir(), ".dsh")),
+	});
+	items.push({
+		label: "在程序坞显示图标",
+		type: "checkbox",
+		checked: settings.dockIcon,
+		click: (menuItem) => {
+			settings.dockIcon = menuItem.checked;
+			saveSettings();
+			if (menuItem.checked) app.dock?.show();
+			else app.dock?.hide();
+		},
+	});
+	items.push({
+		label: "开机自启",
+		type: "checkbox",
+		checked: app.getLoginItemSettings().openAtLogin,
+		click: (menuItem) => app.setLoginItemSettings({ openAtLogin: menuItem.checked }),
+	});
+	items.push({ type: "separator" });
+	items.push({
+		label: "关于 DoTouchAI",
+		click: () => {
+			dialog.showMessageBox({
+				type: "info",
+				title: "DoTouchAI",
+				message: `DoTouchAI v${app.getVersion()}${buildInfo().builtAt ? `(构建于 ${buildInfo().builtAt})` : ""}`,
+				detail: [
+					`dsh:v${(service && service.version()) || "?"}(@deepseek-ai/dsh,经 npm 升级)`,
+					`Electron:v${process.versions.electron}`,
+					...profilePluginsSummary(),
+					"",
+					"菜单栏常驻的 dsh 本地桌面壳:进程托管 + 官方 UI 窗口 + npm 升级管家。",
+					"壳版本 / dsh 版本 / 插件版本三者独立升级;web 插件修复无需重装本应用。",
+				].join("\n"),
+				buttons: ["好"],
+			});
+		},
+	});
+	items.push({ label: "退出", click: () => app.quit() });
+	return items;
+}
+
+function notify(title, body) {
+	try {
+		if (Notification.isSupported()) new Notification({ title, body }).show();
+	} catch { /* notifications are best-effort */ }
+	if (service) service.appendLog(`[desktop] ${title}${body ? " — " + body : ""}`);
+	refreshTray();
+}
+
+async function quit() {
+	if (quitting) return;
+	quitting = true;
+	try { globalShortcut.unregisterAll(); } catch { /* ignore */ }
+	try { if (service) await service.stop(); } catch { /* ignore */ } // no-op when we only attached
+	app.exit(0);
+}
+
+function splashUrl() {
+	const html = `<!doctype html><html><head><meta charset="utf-8"><title>DSH</title><style>
+html,body{margin:0;height:100%;background:#17181c;color:#e8eaf0;font-family:-apple-system,"PingFang SC",sans-serif;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px}
+.ring{width:34px;height:34px;border-radius:50%;border:3px solid #33363e;border-top-color:#4d6bfe;animation:spin .9s linear infinite}
+@keyframes spin{to{transform:rotate(360deg)}}
+p{font-size:13px;color:#9aa0ad;margin:0}
+</style></head><body><div class="ring"></div><p>正在启动 DSH 服务…</p></body></html>`;
+	return "data:text/html;charset=utf-8," + encodeURIComponent(html);
+}

+ 175 - 0
dsh-desktop/app/package-lock.json

@@ -0,0 +1,175 @@
+{
+  "name": "dsh-desktop",
+  "version": "0.3.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "dsh-desktop",
+      "version": "0.3.0",
+      "license": "MIT",
+      "devDependencies": {
+        "electron": "^42.3.3"
+      }
+    },
+    "node_modules/@electron-internal/extract-zip": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
+      "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=22.12.0"
+      }
+    },
+    "node_modules/@electron/get": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
+      "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.1.1",
+        "env-paths": "^3.0.0",
+        "graceful-fs": "^4.2.11",
+        "progress": "^2.0.3",
+        "semver": "^7.6.3",
+        "sumchecker": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=22.12.0"
+      },
+      "optionalDependencies": {
+        "undici": "^7.24.4"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "24.13.3",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+      "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~7.18.0"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/electron": {
+      "version": "42.9.3",
+      "resolved": "https://registry.npmjs.org/electron/-/electron-42.9.3.tgz",
+      "integrity": "sha512-REQUgPrCWOP0FajNcKCwwjkssirN+MVbemyd6bT+x51OVq58y6IqjtpA4vmm/KEzKXj2MIOebx/gF497CkRAPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@electron-internal/extract-zip": "^1.0.1",
+        "@electron/get": "^5.0.0",
+        "@types/node": "^24.9.0"
+      },
+      "bin": {
+        "electron": "cli.js",
+        "install-electron": "install.js"
+      },
+      "engines": {
+        "node": ">= 22.12.0"
+      }
+    },
+    "node_modules/env-paths": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
+      "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/progress": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.8.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sumchecker": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
+      "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "debug": "^4.1.0"
+      },
+      "engines": {
+        "node": ">= 8.0"
+      }
+    },
+    "node_modules/undici": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+      "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.18.2",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+      "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+      "dev": true,
+      "license": "MIT"
+    }
+  }
+}

+ 18 - 0
dsh-desktop/app/package.json

@@ -0,0 +1,18 @@
+{
+  "name": "dsh-desktop",
+  "productName": "DoTouchAI",
+  "version": "0.2.4",
+  "description": "Menu-bar shell for the local dsh web UI: supervise, window, and npm-based one-click upgrades.",
+  "main": "main.js",
+  "license": "MIT",
+  "scripts": {
+    "start": "electron .",
+    "icons": "node tools/gen-icons.js && iconutil -c icns -o assets/icon.icns assets/icon.iconset",
+    "smoke": "DSH_DESKTOP_SMOKE=1 electron .",
+    "bump": "bash tools/bump.sh",
+    "dist": "bash tools/package.sh"
+  },
+  "devDependencies": {
+    "electron": "^42.3.3"
+  }
+}

+ 123 - 0
dsh-desktop/app/plugins.js

@@ -0,0 +1,123 @@
+"use strict";
+/**
+ * Auto-install the bundled dsh plugins (voice suite) into the web profile.
+ *
+ * Bundled at build time under <app>/plugins/ together with a manifest.json
+ * ({ "<pkg>": { id, name } } — the loader patch entry each plugin needs).
+ * At boot we compare with what the profile already carries
+ * (~/.dsh/profiles/web) and install/upgrade:
+ *   - copy each plugin package (deps included) into <profile>/node_modules/
+ *   - append its insert entry to the profile's cordis.patch.yml
+ * New installs require a service restart to take effect — the caller decides
+ * whether to restart right away or just notify.
+ */
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+/** Bundled plugin sources (build-time); absent in plain `npm start`. */
+function bundledPluginsRoot() {
+	return path.join(__dirname, "plugins");
+}
+
+/** The web profile directory (DSH_HOME-aware). */
+function webProfileDir() {
+	return path.join(process.env.DSH_HOME || path.join(os.homedir(), ".dsh"), "profiles", "web");
+}
+
+function readJson(file) {
+	try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
+}
+
+/** Copy a directory tree (files + symlinks materialized as files). */
+function copyTree(src, dest) {
+	fs.mkdirSync(dest, { recursive: true });
+	for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
+		const from = path.join(src, entry.name);
+		const to = path.join(dest, entry.name);
+		if (entry.isDirectory()) copyTree(from, to);
+		else fs.copyFileSync(from, to);
+	}
+}
+
+function patchFile(profileDir) {
+	return path.join(profileDir, "cordis.patch.yml");
+}
+
+/** True when the patch yaml already carries an insert entry for this id. */
+function patchHasEntry(patchText, id) {
+	return patchText !== null && patchText.includes(`id: ${id}`);
+}
+
+/**
+ * Install/upgrade bundled plugins into the profile (idempotent).
+ * @returns {{changed: string[], skipped: string[]}} — "changed" entries need
+ *          a service restart to take effect.
+ */
+function syncPlugins({ log = () => {} } = {}) {
+	const bundledRoot = bundledPluginsRoot();
+	const result = { changed: [], skipped: [] };
+	const manifest = readJson(path.join(bundledRoot, "manifest.json"));
+	if (!manifest) return result; // nothing bundled (dev mode) — fine
+
+	const profileDir = webProfileDir();
+	const nodeModules = path.join(profileDir, "node_modules");
+	try { fs.mkdirSync(nodeModules, { recursive: true }); } catch (err) {
+		log(`[plugins] cannot create profile node_modules: ${err.message}`);
+		return result;
+	}
+
+	const patchText = safeReadPatch(profileDir);
+	let patchAppend = "";
+
+	for (const [pkgName, entry] of Object.entries(manifest)) {
+		const src = path.join(bundledRoot, pkgName);
+		const pkg = readJson(path.join(src, "package.json"));
+		if (!pkg || pkg.name !== pkgName || !pkg.version) continue;
+		const dest = path.join(nodeModules, pkgName);
+		// A symlinked install is a developer-managed layout (DSWorkSpace) —
+		// never overwrite it; its source is the live code anyway.
+		try {
+			if (fs.lstatSync(dest).isSymbolicLink()) { result.skipped.push(pkgName); continue; }
+		} catch { /* not installed yet — proceed */ }
+		const installed = readJson(path.join(dest, "package.json"));
+		const needsInstall = !installed || installed.version !== pkg.version;
+		const needsPatch = !entry.dep && !patchHasEntry(patchText, entry.id);
+		if (!needsInstall && !needsPatch) { result.skipped.push(pkgName); continue; }
+
+		log(`[plugins] installing ${pkgName}@${pkg.version}${installed ? ` (was ${installed.version})` : ""}`);
+		try {
+			if (installed) fs.rmSync(dest, { recursive: true, force: true });
+			copyTree(src, dest);
+		} catch (err) {
+			log(`[plugins] copy failed for ${pkgName}: ${err.message}`);
+			continue;
+		}
+		if (needsPatch) {
+			patchAppend += `    - id: ${entry.id}\n      name: ${entry.name}\n      config: {}\n`;
+		}
+		result.changed.push(pkgName);
+	}
+
+	if (patchAppend) {
+		try {
+			const file = patchFile(profileDir);
+			let text = safeReadPatch(profileDir);
+			if (text === null || text.trim() === "") {
+				text = `# Your patch layer for this dsh profile, applied after every bundle layer:\n# a top-level YAML array of loader patch entries.\n- insert:\n`;
+			} else if (!/- insert:/.test(text)) {
+				text = `${text.replace(/\s*$/, "\n")}- insert:\n`;
+			}
+			fs.writeFileSync(file, text.replace(/\n*$/, "\n") + patchAppend);
+		} catch (err) {
+			log(`[plugins] patch update failed: ${err.message}`);
+		}
+	}
+	return result;
+}
+
+function safeReadPatch(profileDir) {
+	try { return fs.readFileSync(patchFile(profileDir), "utf8"); } catch { return null; }
+}
+
+module.exports = { syncPlugins, webProfileDir };

+ 18 - 0
dsh-desktop/app/plugins/dsh-voice-client/CHANGELOG.md

@@ -0,0 +1,18 @@
+# Changelog
+
+## 0.1.2 — 2026-08-24
+
+- 设置面板分区补上导航标题"语音交流 / Voice"(注册 settings.section 时缺 label,
+  导致分区在设置导航里没有文字,不像"通用设置、模型、插件"那样);order 调整为 25,
+  排在官方分区(通用 0 / 模型 10 / 插件 15 / 预设 20)之后
+
+## 0.1.1 — 2026-08-24
+
+- 修复浅色模式下语音设置模块下拉框黑底(CASE-2):两处主题变量名拼写错误——
+  `--dsw-alias-bg-l2` → `--dsw-alias-bg-layer-2`、`--dsw-alias-hover` → `--dsw-alias-interactive-bg-hover`
+  (var() 永不命中导致永远走深色兜底值);深色模式视觉不变
+- 变量名依据官方主题 token 清单(见 dsh-harmony/knowledge-base/cases.md CASE-2)
+
+## 0.1.0 — 2026-08-16
+
+- 初版:语音输入(ASR)+ 朗读(TTS),纯浏览器 Web Speech API

+ 964 - 0
dsh-desktop/app/plugins/dsh-voice-client/lib/client.js

@@ -0,0 +1,964 @@
+/**
+ * dsh-voice-client — browser bundle (hand-written in the DSH __ModuleLoader__ format).
+ *
+ * Voice interaction for the DeepSeek Harness Web GUI, pure browser-native:
+ *   - ASR: Web Speech API (SpeechRecognition) → fills the composer draft (hold-to-talk or toggle)
+ *   - TTS: Web Speech API (speechSynthesis) → reads assistant replies aloud (manual + auto)
+ *
+ * Registered slots:
+ *   - conversation.input.right   → mic button (composer tool row)
+ *   - conversation.chat.turnTail → per-turn read-aloud button (+ auto-read on turn close)
+ *   - conversation.composer.dock → voice status line (listening / speaking / errors)
+ *   - settings.section           → voice settings panel (language, mode, toggles, rate)
+ *
+ * No build step: this file IS the bundle. Edit it and the HMR poll picks up the
+ * content change (rev change) automatically; a fresh page load picks up the graph row.
+ */
+window.__ModuleLoader__.load({
+  id: "dsh-voice-client",
+  factory: (require) => {
+    var module = { exports: {} };
+    var exports = module.exports;
+    Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
+
+    let react = require("react");
+    let jsxRuntime = require("react/jsx-runtime");
+    const { jsx, jsxs, Fragment } = jsxRuntime;
+    const { useState, useEffect, useRef, useMemo, useSyncExternalStore } = react;
+
+    // ─────────────────────────────── settings ───────────────────────────────
+    const LS_PREFIX = "dshVoice.";
+    const DEFAULT_SETTINGS = Object.freeze({
+      lang: "zh-CN",       // 'zh-CN' | 'en-US' | any BCP-47 the engine supports
+      mode: "toggle",      // 'toggle' (click start/stop) | 'hold' (push-to-talk)
+      asrEngine: "server", // 'server' (local, offline) | 'browser' (Web Speech API) | 'auto'
+      autoSubmit: false,   // send the message right after recognition
+      autoRead: false,     // read assistant replies automatically
+      ttsEngine: "edge",  // 'edge' (natural, online) | 'piper' (offline) | 'browser' (system voice)
+      ttsVoice: "zh-CN-YunxiNeural", // edge-tts voice (warm male — Jarvis-like)
+      rate: 1.0,           // TTS rate
+      skipCode: true       // skip fenced code blocks when reading
+    });
+
+    function loadSettings() {
+      const out = { ...DEFAULT_SETTINGS };
+      try {
+        for (const key of Object.keys(DEFAULT_SETTINGS)) {
+          const raw = localStorage.getItem(LS_PREFIX + key);
+          if (raw === null) continue;
+          const def = DEFAULT_SETTINGS[key];
+          if (typeof def === "boolean") out[key] = raw === "true";
+          else if (typeof def === "number") {
+            const n = Number(raw);
+            if (Number.isFinite(n)) out[key] = n;
+          } else out[key] = raw;
+        }
+      } catch { /* storage unavailable — keep defaults */ }
+      return out;
+    }
+    function saveSettings(patch) {
+      try {
+        for (const [k, v] of Object.entries(patch)) localStorage.setItem(LS_PREFIX + k, String(v));
+      } catch { /* storage unavailable */ }
+      return { ...loadSettings(), ...patch };
+    }
+
+    // ─────────────────────────── shared voice state ──────────────────────────
+    let voiceState = { recording: false, interim: "", speaking: false, speakingKey: null, error: null };
+    const voiceListeners = new Set();
+    function setVoice(patch) {
+      voiceState = { ...voiceState, ...patch };
+      for (const fn of voiceListeners) fn();
+    }
+    function subscribeVoice(fn) {
+      voiceListeners.add(fn);
+      return () => { voiceListeners.delete(fn); };
+    }
+    function getVoice() { return voiceState; }
+    function useVoice() { return useSyncExternalStore(subscribeVoice, getVoice); }
+
+    // ───────────────────────────────── TTS ───────────────────────────────────
+    function ttsSupported() {
+      return typeof window !== "undefined" && "speechSynthesis" in window;
+    }
+
+    let speakGeneration = 0;
+    let resumeTimer = null;
+    let currentAudio = null;
+    function clearResumeTimer() {
+      if (resumeTimer !== null) { clearInterval(resumeTimer); resumeTimer = null; }
+    }
+
+    /** Stop any in-flight playback WITHOUT invalidating the generation counter. */
+    function cancelPlayback() {
+      clearResumeTimer();
+      if (ttsSupported()) { try { window.speechSynthesis.cancel(); } catch { /* noop */ } }
+      if (currentAudio) {
+        try { currentAudio.pause(); currentAudio.currentTime = 0; } catch { /* noop */ }
+        try { currentAudio.removeAttribute("src"); currentAudio.load(); } catch { /* noop */ }
+      }
+    }
+
+    function ttsStop() {
+      speakGeneration++;
+      cancelPlayback();
+      setVoice({ speaking: false, speakingKey: null });
+    }
+
+    /** Sentence-aware chunking. Chrome's speechSynthesis stalls on very long utterances. */
+    function splitChunks(text) {
+      const chunks = [];
+      let current = "";
+      for (const ch of text) {
+        current += ch;
+        if ("。!?;.!?;\n".includes(ch) || current.length >= 120) {
+          const trimmed = current.trim();
+          if (trimmed) chunks.push(trimmed);
+          current = "";
+        }
+      }
+      const rest = current.trim();
+      if (rest) chunks.push(rest);
+      return chunks;
+    }
+
+    /** Strip code fences + markdown syntax so TTS reads clean prose (not "星号星号"). */
+    function cleanMarkdown(text) {
+      return text
+        .replace(/```[\s\S]*?```/g, " ")          // fenced code blocks
+        .replace(/`[^`\n]+`/g, " ")               // inline code
+        .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")    // images
+        .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")  // links → text
+        .replace(/^#{1,6}\s*/gm, "")              // headings
+        .replace(/^\s*[-*+]\s+/gm, "")            // bullet lists
+        .replace(/^\s*\d+[.)]\s+/gm, "")          // numbered lists
+        .replace(/\*\*([^*\n]+)\*\*/g, "$1")      // bold
+        .replace(/\*([^*\n]+)\*/g, "$1")          // italic
+        .replace(/__([^_\n]+)__/g, "$1")          // bold (underscore)
+        .replace(/_([^_\n]+)_/g, "$1")            // italic (underscore)
+        .replace(/<[^>]+>/g, " ")                 // html tags
+        .replace(/\|/g, ",")                      // table pipes → pause
+        .replace(/-{3,}/g, " ")                   // horizontal rules
+        .replace(/[ \t]+/g, " ")                  // collapse spaces
+        .replace(/\s*\n\s*\n+/g, "\n")            // collapse blank lines
+        .trim();
+    }
+
+    /**
+     * Speak text aloud. `key` identifies the message so per-message stop works
+     * (the same key is stored in voiceState.speakingKey while speaking).
+     */
+    function speakText(text, key) {
+      if (!text || !text.trim()) return;
+      const engine = loadSettings().ttsEngine;
+      if (engine === "browser" || !ttsSupported()) return speakBrowser(text, key);
+      return speakServer(text, key);
+    }
+
+    /** Browser engine: chunked speechSynthesis (system voices). */
+    function speakBrowser(text, key) {
+      if (!ttsSupported()) return;
+      const gen = ++speakGeneration;
+      clearResumeTimer();
+      const synth = window.speechSynthesis;
+      try { synth.cancel(); } catch { /* noop */ }
+      const chunks = splitChunks(text);
+      setVoice({ speaking: true, speakingKey: key ?? null, error: null });
+      // Heartbeat: Chrome can stall synthesis after ~15s of continuous audio.
+      resumeTimer = setInterval(() => { try { synth.resume(); } catch { /* noop */ } }, 10000);
+      let index = 0;
+      const next = () => {
+        if (gen !== speakGeneration) return; // interrupted / stopped
+        if (index >= chunks.length) {
+          if (gen === speakGeneration) {
+            clearResumeTimer();
+            setVoice({ speaking: false, speakingKey: null });
+          }
+          return;
+        }
+        const settings = loadSettings();
+        const u = new SpeechSynthesisUtterance(chunks[index++]);
+        u.lang = settings.lang;
+        u.rate = settings.rate;
+        u.onend = next;
+        u.onerror = (ev) => {
+          if (gen !== speakGeneration) return;
+          if (ev.error === "interrupted" || ev.error === "canceled") return;
+          clearResumeTimer();
+          setVoice({ speaking: false, speakingKey: null });
+        };
+        try { synth.speak(u); } catch { setVoice({ speaking: false, speakingKey: null }); }
+      };
+      next();
+    }
+
+    /** Server engine: edge-tts / piper via /api/voice/tts, played as audio. */
+    function speakServer(text, key) {
+      const gen = ++speakGeneration;
+      cancelPlayback(); // stop current audio without re-incrementing the generation
+      setVoice({ speaking: true, speakingKey: key ?? null, error: null });
+      const settings = loadSettings();
+      fetch("/api/voice/tts", {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({ text, engine: settings.ttsEngine, voice: settings.ttsVoice, rate: settings.rate })
+      })
+        .then((res) => {
+          if (!res.ok) throw new Error("HTTP " + res.status);
+          return res.blob();
+        })
+        .then((blob) => {
+          if (gen !== speakGeneration) return;
+          const url = URL.createObjectURL(blob);
+          // Reuse a DOM-attached audio element for reliable autoplay.
+          if (currentAudio === null) {
+            currentAudio = document.createElement("audio");
+            currentAudio.style.display = "none";
+            document.body.appendChild(currentAudio);
+          }
+          const audio = currentAudio;
+          audio.onended = () => {
+            if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null });
+            URL.revokeObjectURL(url);
+            maybeResume();
+          };
+          audio.onerror = () => {
+            if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "语音播放失败" });
+            URL.revokeObjectURL(url);
+          };
+          audio.src = url;
+          audio.play().catch((err) => {
+            if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "播放被阻止: " + String(err && err.name || err) });
+          });
+        })
+        .catch((err) => {
+          if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "本地语音服务不可用: " + String(err && err.message || err) });
+        });
+    }
+
+    // ───────────────────────────────── ASR ───────────────────────────────────
+    function asrSupported() {
+      return typeof window !== "undefined" && Boolean(window.SpeechRecognition || window.webkitSpeechRecognition);
+    }
+
+    let recognizer = null;   // browser engine instance (SpeechRecognition)
+    let activeEngine = null; // 'browser' | 'server'
+    let listening = false;   // whether the mic should stay on (held / toggled-on)
+    let finalTranscript = "";
+    let baseDraft = "";      // draft text before recording started
+    let lastWritten = "";    // what updateDraft last wrote (to detect user edits)
+    let liveWriting = true;  // still allowed to overwrite the draft live
+    let commitOnEnd = null;  // { readDraft, setDraft, submit } resolved when recording ends
+    let browserNetworkFailed = false; // auto-mode memory: browser ASR is unusable here
+    let lastMicCtx = null;   // latest mic context (set by MicButton) for auto-resume
+    let loopActive = false;  // hands-free loop running (自由交流 mode)
+
+    /**
+     * Write the recognized text LIVE into the composer draft — but stop the
+     * moment the user edits the draft themselves, so their typing is never
+     * clobbered by the next recognition update.
+     */
+    function updateDraft(text) {
+      if (!commitOnEnd || !commitOnEnd.setDraft) return;
+      if (liveWriting && commitOnEnd.readDraft) {
+        const current = commitOnEnd.readDraft();
+        if (current !== lastWritten) liveWriting = false; // user took over editing
+      }
+      if (!liveWriting) return;
+      const prefix = baseDraft ? baseDraft + " " : "";
+      const next = prefix + text;
+      lastWritten = next;
+      commitOnEnd.setDraft(next);
+    }
+
+    /** End-of-recording: reconcile the draft and optionally auto-submit. */
+    function commitFinal() {
+      const ctxAtEnd = commitOnEnd;
+      commitOnEnd = null;
+      const text = finalTranscript.trim();
+      finalTranscript = "";
+      if (ctxAtEnd === null) return;
+      // If the user edited mid-recording, append the final transcript to their draft.
+      if (!liveWriting && text && ctxAtEnd.readDraft && ctxAtEnd.setDraft) {
+        const cur = ctxAtEnd.readDraft().trimEnd();
+        ctxAtEnd.setDraft(cur ? cur + " " + text : text);
+      }
+      if (text && loadSettings().autoSubmit && ctxAtEnd.submit) {
+        try { ctxAtEnd.submit(); } catch { /* submission refused (busy) */ }
+      }
+    }
+
+    /** Auto-resume the mic loop after the reply finishes reading (自由交流 mode). */
+    function maybeResume() {
+      const s = loadSettings();
+      if (!s.autoSubmit || !s.autoRead) return; // only in 自由交流 (hands-free) mode
+      if (!loopActive) return;
+      if (listening) return;
+      if (lastMicCtx === null) return;
+      setTimeout(() => {
+        if (!listening && loopActive && loadSettings().autoSubmit) startListening(lastMicCtx);
+      }, 700);
+    }
+
+    /** Manual stop: end the hands-free loop too. */
+    function stopLoop() {
+      loopActive = false;
+      stopListening();
+    }
+
+    /**
+     * Precise mic preflight via getUserMedia — the call that triggers the
+     * OS/browser permission prompt and reports WHICH layer denied access.
+     */
+    async function preflightMic() {
+      if (typeof navigator === "undefined" || !navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
+        return { ok: false, reason: "unsupported" };
+      }
+      try {
+        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+        for (const track of stream.getTracks()) track.stop();
+        return { ok: true };
+      } catch (err) {
+        const name = (err && err.name) || "";
+        if (name === "NotAllowedError" || name === "SecurityError") return { ok: false, reason: "permission" };
+        if (name === "NotFoundError" || name === "OverconstrainedError") return { ok: false, reason: "device" };
+        return { ok: false, reason: "other", detail: String((err && err.message) || err) };
+      }
+    }
+
+    function micErrorMessage(res) {
+      return res.reason === "permission"
+        ? "麦克风权限被拒绝。请检查两步:① macOS「系统设置 → 隐私与安全性 → 麦克风」勾选允许 Chrome;② 地址栏左侧锁形图标 → 网站设置 → 麦克风选「允许」。改完后刷新页面再试。"
+        : res.reason === "device"
+          ? "未检测到可用的麦克风设备,请在 macOS「系统设置 → 声音 → 输入」确认有输入设备。"
+          : res.reason === "unsupported"
+            ? "此环境不支持麦克风访问(getUserMedia 不可用)。"
+            : "无法访问麦克风: " + (res.detail || res.reason);
+    }
+
+    /** Entry point: pick the engine from settings, then start it. */
+    function startListening(ctx) {
+      if (listening) return;
+      ttsStop(); // speaking while the user talks would be confusing — interrupt
+      commitOnEnd = ctx ?? null;
+      finalTranscript = "";
+      baseDraft = (ctx && ctx.readDraft ? ctx.readDraft() : "").trimEnd();
+      lastWritten = baseDraft;
+      liveWriting = true;
+      setVoice({ recording: true, interim: "", error: null });
+      const engine = loadSettings().asrEngine;
+      if (engine === "browser") return startBrowserEngine(ctx);
+      if (engine === "server") return startServerEngine(ctx);
+      // 'auto'
+      if (browserNetworkFailed) return startServerEngine(ctx);
+      startBrowserEngine(ctx);
+    }
+
+    // ── browser engine (Web Speech API — needs Google's cloud in Chrome) ────
+    function startBrowserEngine(ctx) {
+      if (!asrSupported()) {
+        listening = false;
+        commitOnEnd = null;
+        setVoice({ recording: false, error: "当前浏览器不支持语音识别(SpeechRecognition)。请在设置里改用「本地服务」识别引擎。" });
+        return;
+      }
+      preflightMic().then((res) => {
+        if (!res.ok) {
+          listening = false;
+          commitOnEnd = null;
+          setVoice({ recording: false, interim: "", error: micErrorMessage(res) });
+          return;
+        }
+        startRecognizer(ctx);
+      });
+    }
+
+    function startRecognizer(ctx) {
+      const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+      const settings = loadSettings();
+      const rec = new SR();
+      rec.lang = settings.lang;
+      rec.continuous = true;
+      rec.interimResults = true;
+      rec.maxAlternatives = 1;
+
+      rec.onresult = (ev) => {
+        let interim = "";
+        for (let k = ev.resultIndex; k < ev.results.length; k++) {
+          const result = ev.results[k];
+          if (result.isFinal) finalTranscript += result[0].transcript;
+          else interim += result[0].transcript;
+        }
+        setVoice({ interim: interim.trim() });
+        updateDraft((finalTranscript + interim).trim());
+      };
+      rec.onerror = (ev) => {
+        if (ev.error === "aborted") return; // normal when we stop() / abort()
+        listening = false;
+        const e = ev.error;
+        if (e === "not-allowed") {
+          setVoice({ error: "麦克风权限被拒绝。请检查 macOS「系统设置 → 隐私与安全性 → 麦克风」是否允许 Chrome,以及地址栏锁形图标 → 网站设置中麦克风是否为「允许」,然后刷新页面重试。", recording: false, interim: "" });
+        } else if (e === "service-not-allowed" || e === "network") {
+          browserNetworkFailed = true;
+          const hint = loadSettings().asrEngine === "auto"
+            ? "已自动切换到本地识别服务,下次点击收音将使用本地识别。"
+            : "请在设置里把「识别引擎」改为「本地服务」。";
+          setVoice({ error: "浏览器语音识别依赖 Google 服务,当前网络不可用。" + hint, recording: false, interim: "" });
+        } else if (e === "no-speech") {
+          setVoice({ recording: false, interim: "" });
+        } else if (e === "audio-capture") {
+          setVoice({ error: "无法捕获麦克风音频,请检查麦克风设备是否被其他应用占用。", recording: false, interim: "" });
+        } else {
+          setVoice({ error: "语音识别错误: " + e, recording: false, interim: "" });
+        }
+      };
+      rec.onend = () => {
+        if (listening) {
+          try { rec.start(); } catch { listening = false; setVoice({ recording: false, interim: "" }); }
+          return;
+        }
+        setVoice({ recording: false, interim: "" });
+        commitFinal();
+      };
+
+      recognizer = rec;
+      activeEngine = "browser";
+      listening = true;
+      try { rec.start(); } catch {
+        listening = false;
+        commitOnEnd = null;
+        setVoice({ recording: false, error: "无法启动语音识别" });
+      }
+    }
+
+    // ── server engine (local sherpa-onnx via /api/voice/asr WebSocket) ──────
+    let serverWs = null;
+    let serverMicStream = null;
+    let serverAudioCtx = null;
+    let serverSource = null;
+    let serverProcessor = null;
+
+    function serverError(message) {
+      listening = false;
+      commitOnEnd = null;
+      activeEngine = null;
+      if (serverWs) { try { serverWs.close(); } catch { /* noop */ } serverWs = null; }
+      teardownCapture();
+      setVoice({ recording: false, interim: "", error: message });
+    }
+
+    function teardownCapture() {
+      if (serverMicStream) { for (const t of serverMicStream.getTracks()) t.stop(); serverMicStream = null; }
+      if (serverSource) { try { serverSource.disconnect(); } catch { /* noop */ } serverSource = null; }
+      if (serverProcessor) { try { serverProcessor.disconnect(); } catch { /* noop */ } serverProcessor = null; }
+      if (serverAudioCtx) { try { serverAudioCtx.close(); } catch { /* noop */ } serverAudioCtx = null; }
+    }
+
+    function startServerEngine(ctx) {
+      if (typeof WebSocket === "undefined") {
+        serverError("当前浏览器不支持 WebSocket,无法连接本地识别服务。");
+        return;
+      }
+      const wsUrl = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/api/voice/asr";
+      let ws;
+      try { ws = new WebSocket(wsUrl); } catch {
+        serverError("无法创建本地识别连接。");
+        return;
+      }
+      serverWs = ws;
+      ws.binaryType = "arraybuffer";
+      const isCurrent = () => serverWs === ws;
+      ws.onopen = () => { if (isCurrent()) startServerCapture(ws); };
+      ws.onmessage = (ev) => {
+        if (!isCurrent()) return;
+        let msg;
+        try { msg = JSON.parse(ev.data); } catch { return; }
+        if (msg.type === "partial") {
+          setVoice({ interim: msg.text || "" });
+          updateDraft(msg.text || "");
+        } else if (msg.type === "final") {
+          finalTranscript = msg.text || "";
+          setVoice({ interim: "" });
+          updateDraft(finalTranscript);
+        } else if (msg.type === "endpoint") {
+          // Silence detected → auto-stop in 自由交流 mode.
+          if (loadSettings().autoSubmit) stopListening();
+        } else if (msg.type === "error") serverError(msg.message || "本地识别服务错误");
+      };
+      ws.onerror = () => { if (isCurrent()) serverError("本地识别服务未连接。请确认 dsh-voice-server 已安装并已重启服务,或在设置里改用浏览器引擎。"); };
+      ws.onclose = () => {
+        if (!isCurrent()) return;
+        serverWs = null;
+        teardownCapture();
+        setVoice({ recording: false, interim: "" });
+        if (activeEngine === "server") {
+          activeEngine = null;
+          commitFinal();
+        }
+      };
+      activeEngine = "server";
+      listening = true;
+    }
+
+    function startServerCapture(ws) {
+      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+        serverError("此环境不支持麦克风访问(getUserMedia 不可用)。");
+        return;
+      }
+      navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
+        if (ws.readyState !== ws.OPEN) { for (const t of stream.getTracks()) t.stop(); return; }
+        serverMicStream = stream;
+        const Ctx = window.AudioContext || window.webkitAudioContext;
+        const ctx = new Ctx({ sampleRate: 16000 });
+        serverAudioCtx = ctx;
+        const source = ctx.createMediaStreamSource(stream);
+        // ScriptProcessor is deprecated but universally supported; 16k mono PCM is what sherpa expects.
+        const processor = ctx.createScriptProcessor(4096, 1, 1);
+        const zeroGain = ctx.createGain();
+        zeroGain.gain.value = 0; // keep the graph connected without feeding the speakers
+        processor.onaudioprocess = (ev) => {
+          const input = ev.inputBuffer.getChannelData(0);
+          const pcm = new Int16Array(input.length);
+          for (let i = 0; i < input.length; i++) {
+            const s = Math.max(-1, Math.min(1, input[i]));
+            pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
+          }
+          if (ws.readyState === ws.OPEN) ws.send(pcm.buffer);
+        };
+        source.connect(processor);
+        processor.connect(zeroGain);
+        zeroGain.connect(ctx.destination);
+        serverSource = source;
+        serverProcessor = processor;
+        setVoice({ recording: true, interim: "", error: null });
+      }).catch((err) => {
+        serverError(micErrorMessage({ reason: (err && err.name === "NotAllowedError" || err && err.name === "SecurityError") ? "permission" : (err && err.name === "NotFoundError" ? "device" : "other"), detail: String(err && err.message || err) }));
+      });
+    }
+
+    function stopListening() {
+      if (!listening) return;
+      listening = false;
+      setVoice({ recording: false, interim: "" });
+      if (activeEngine === "browser") {
+        if (recognizer !== null) { try { recognizer.stop(); } catch { /* noop */ } }
+        return;
+      }
+      if (activeEngine === "server") {
+        teardownCapture();
+        if (serverWs && serverWs.readyState === serverWs.OPEN) {
+          serverWs.send(JSON.stringify({ type: "stop" })); // server replies final, then closes → onclose commits
+        } else {
+          commitFinal();
+        }
+      }
+    }
+
+    function cancelListening() {
+      if (activeEngine === "browser" && recognizer !== null) {
+        listening = false;
+        commitOnEnd = null;
+        try { recognizer.abort(); } catch { /* noop */ }
+      } else if (activeEngine === "server") {
+        listening = false;
+        commitOnEnd = null;
+        teardownCapture();
+        if (serverWs) { try { serverWs.close(); } catch { /* noop */ } serverWs = null; }
+      }
+      setVoice({ recording: false, interim: "" });
+    }
+
+    // ─────────────────────────────── styles ──────────────────────────────────
+    const css = [
+      ".dsh-voice-btn{display:inline-grid;place-items:center;width:28px;height:28px;padding:0;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#a8b3c4);cursor:pointer;font-size:14px;line-height:1}",
+      ".dsh-voice-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,140,160,.16));color:var(--dsw-alias-label-primary,#e8edf4)}",
+      ".dsh-voice-btn:disabled{opacity:.35;cursor:not-allowed}",
+      ".dsh-voice-btn-active{color:#ff6b6b}",
+      ".dsh-voice-btn-recording{color:#ff5252;animation:dshVoicePulse 1.2s ease-in-out infinite}",
+      ".dsh-voice-mode{display:inline-flex;align-items:center;gap:4px;width:auto;height:26px;padding:0 10px;font-size:12px;font-weight:500;white-space:nowrap;color:var(--dsw-alias-label-secondary,#a8b3c4)}",
+      ".dsh-voice-mode:hover:not(:disabled){color:var(--dsw-alias-label-primary,#e8edf4)}",
+      "@keyframes dshVoicePulse{0%,100%{opacity:1}50%{opacity:.45}}",
+      ".dsh-voice-dock{display:flex;align-items:center;gap:8px;min-height:18px;font-size:12px;color:var(--dsw-alias-label-secondary,#a8b3c4)}",
+      ".dsh-voice-dock .dsh-voice-interim{color:var(--dsw-alias-label-tertiary,#7c8798);font-style:italic;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}",
+      ".dsh-voice-dock .dsh-voice-error{color:#ff6b6b}",
+      ".dsh-voice-read{display:inline-flex;align-items:center;margin-left:6px}",
+      ".dsh-voice-settings{display:flex;flex-direction:column;gap:12px;padding:16px}",
+      ".dsh-voice-settings h3{margin:0 0 4px;font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,#e8edf4)}",
+      ".dsh-voice-row{display:flex;align-items:center;justify-content:space-between;gap:16px;font-size:13px;color:var(--dsw-alias-label-secondary,#a8b3c4)}",
+      ".dsh-voice-row label{display:flex;align-items:center;gap:8px;cursor:pointer}",
+      ".dsh-voice-row input[type=range]{width:140px}",
+      ".dsh-voice-row select{background:var(--dsw-alias-bg-layer-2,#1b2230);color:var(--dsw-alias-label-primary,#e8edf4);border:1px solid var(--dsw-alias-border-l1,#2a3446);border-radius:8px;padding:4px 8px;font-size:13px}"
+    ].join("");
+    const styleTagId = "dsh-voice-client/styles";
+    if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(styleTagId) + "]") === null) {
+      const tag = document.createElement("style");
+      tag.dataset.plugin = "dsh-voice-client";
+      tag.dataset.pluginCss = styleTagId;
+      tag.textContent = css;
+      document.head.appendChild(tag);
+    }
+
+    // ─────────────────────────────── locale ──────────────────────────────────
+    const NS = "voice";
+    const zh = {
+      "mic.title.hold": "语音输入:按住说话",
+      "mic.title.toggle": "语音输入:点击开始 / 再点结束",
+      "mic.title.unsupported": "当前浏览器不支持语音识别,请使用 Chrome 或 Edge",
+      "mic.recording": "松开完成识别",
+      "status.listening": "正在聆听…",
+      "status.speaking": "正在朗读…",
+      "read.speak": "朗读这条回复",
+      "read.stop": "停止朗读",
+      "settings.title": "语音交流",
+      "settings.lang": "识别 / 朗读语言",
+      "settings.engine": "识别引擎",
+      "settings.engine.server": "本地服务(推荐,离线)",
+      "settings.engine.browser": "浏览器(依赖 Google 服务)",
+      "settings.engine.auto": "自动(优先浏览器,失败回退本地)",
+      "settings.mode": "说话方式",
+      "settings.mode.hold": "按住说话",
+      "settings.mode.toggle": "点击切换",
+      "mode.dictation": "听写模式",
+      "mode.chat": "自由交流模式",
+      "mode.toChat": "切换到自由交流模式(说完自动发送并朗读回复)",
+      "mode.toDictation": "切换到听写模式(手动发送)",
+      "settings.autoSubmit": "识别后自动发送",
+      "settings.autoRead": "自动朗读回复",
+      "settings.tts": "朗读语音引擎",
+      "settings.tts.edge": "微软神经语音(自然,需联网)",
+      "settings.tts.piper": "Piper 本地(离线)",
+      "settings.tts.browser": "系统语音",
+      "settings.voice": "朗读音色",
+      "settings.rate": "朗读语速",
+      "settings.skipCode": "朗读时跳过代码块",
+      "settings.test": "试听",
+      "settings.test.text": "你好,我是你的语音助手。现在可以开始语音交流了。",
+      "settings.unsupported": "当前浏览器不支持语音识别(请使用 Chrome 或 Edge),但朗读功能可用。"
+    };
+    const en = {
+      "mic.title.hold": "Voice input: hold to talk",
+      "mic.title.toggle": "Voice input: click to start / click again to stop",
+      "mic.title.unsupported": "Speech recognition is not supported by this browser; use Chrome or Edge",
+      "mic.recording": "Release to finish",
+      "status.listening": "Listening…",
+      "status.speaking": "Speaking…",
+      "read.speak": "Read this reply aloud",
+      "read.stop": "Stop reading",
+      "settings.title": "Voice",
+      "settings.lang": "Recognition / reading language",
+      "settings.engine": "Recognition engine",
+      "settings.engine.server": "Local service (offline, recommended)",
+      "settings.engine.browser": "Browser (needs Google service)",
+      "settings.engine.auto": "Auto (browser first, fall back to local)",
+      "settings.mode": "Input mode",
+      "settings.mode.hold": "Hold to talk",
+      "settings.mode.toggle": "Click to toggle",
+      "mode.dictation": "Dictation",
+      "mode.chat": "Voice chat",
+      "mode.toChat": "Switch to voice chat (auto-send and read replies)",
+      "mode.toDictation": "Switch to dictation (send manually)",
+      "settings.autoSubmit": "Auto-send after recognition",
+      "settings.autoRead": "Auto-read replies",
+      "settings.tts": "Reading voice engine",
+      "settings.tts.edge": "Microsoft neural (natural, online)",
+      "settings.tts.piper": "Piper local (offline)",
+      "settings.tts.browser": "System voice",
+      "settings.voice": "Reading voice",
+      "settings.rate": "Reading rate",
+      "settings.skipCode": "Skip code blocks when reading",
+      "settings.test": "Test",
+      "settings.test.text": "Hi, I am your voice assistant. Let's start talking.",
+      "settings.unsupported": "Speech recognition is not supported by this browser (use Chrome or Edge); reading aloud still works."
+    };
+
+    // ─────────────────────────── helper: assistant text ──────────────────────
+    function assistantTextOf(node) {
+      if (!node || node.kind !== "assistant" || !Array.isArray(node.blocks)) return "";
+      const text = node.blocks.filter((b) => b.kind === "text").map((b) => b.text).join("");
+      const cleaned = loadSettings().skipCode ? cleanMarkdown(text) : text;
+      return cleaned;
+    }
+
+    // ───────────────────── component: VoiceModeToggle ────────────────────────
+    function VoiceModeToggle(props) {
+      const { t } = props;
+      const [, force] = useState(0);
+      const chat = loadSettings().autoSubmit; // true = 自由交流, false = 听写
+      const toggle = () => {
+        saveSettings({ autoSubmit: !chat, autoRead: !chat });
+        force((n) => n + 1);
+      };
+      const label = chat ? "💬 " + t("mode.chat") : "✍️ " + t("mode.dictation");
+      return jsx("button", {
+        type: "button",
+        className: "dsh-voice-btn dsh-voice-mode" + (chat ? " dsh-voice-btn-active" : ""),
+        title: chat ? t("mode.toDictation") : t("mode.toChat"),
+        "aria-label": chat ? t("mode.toDictation") : t("mode.toChat"),
+        onClick: toggle,
+        children: label
+      });
+    }
+
+    // ─────────────────────────── component: MicButton ────────────────────────
+    function MicButton(props) {
+      const { t, input, inputActions } = props;
+      const voice = useVoice();
+      const recording = voice.recording;
+      const serverOk = typeof navigator !== "undefined" && !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
+      const supported = asrSupported() || serverOk;
+      const mode = loadSettings().mode;
+
+      const draftRef = useRef("");
+      draftRef.current = typeof input?.draft === "string" ? input.draft : "";
+      const actionsRef = useRef({ setDraft: null, submit: null });
+      actionsRef.current = {
+        setDraft: typeof inputActions?.setDraft === "function" ? inputActions.setDraft : null,
+        submit: typeof inputActions?.submit === "function" ? inputActions.submit : null
+      };
+
+      const makeCtx = () => ({
+        readDraft: () => draftRef.current,
+        setDraft: (text) => actionsRef.current.setDraft?.(text),
+        submit: () => actionsRef.current.submit?.()
+      });
+      // Publish the mic context so the auto-resume loop can restart listening.
+      useEffect(() => { lastMicCtx = makeCtx(); });
+
+      const handleStart = () => {
+        if (!supported || recording) return;
+        loopActive = loadSettings().autoSubmit === true; // hands-free loop only in 自由交流
+        startListening(makeCtx());
+      };
+      const handleStop = () => {
+        if (!recording) return;
+        stopLoop();
+      };
+      const handleToggle = () => {
+        if (recording) handleStop(); else handleStart();
+      };
+
+      const title = !supported ? t("mic.title.unsupported")
+        : recording ? t("mic.recording")
+        : mode === "hold" ? t("mic.title.hold") : t("mic.title.toggle");
+
+      const buttonProps = {
+        type: "button",
+        className: "dsh-voice-btn" + (recording ? " dsh-voice-btn-recording" : ""),
+        title,
+        "aria-label": title,
+        disabled: !supported,
+        children: recording ? "⏹" : "🎤"
+      };
+
+      if (mode === "hold") {
+        buttonProps.onPointerDown = (e) => {
+          e.preventDefault();
+          try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* noop */ }
+          handleStart();
+        };
+        buttonProps.onPointerUp = (e) => {
+          e.preventDefault();
+          handleStop();
+        };
+        buttonProps.onPointerCancel = () => { handleStop(); };
+      } else {
+        buttonProps.onClick = handleToggle;
+      }
+
+      return jsx("button", buttonProps);
+    }
+
+    // ─────────────────────────── component: ReadAloud ────────────────────────
+    const spokenTurns = new Set(); // session:seq keys already auto-read this fiber
+    function ReadAloud(props) {
+      const { t, messageId, useSession } = props;
+      const voice = useVoice();
+      const sessionId = useSession((s) => (s ? s.sessionId : undefined));
+      const node = useSession((s) => (s && Array.isArray(s.nodes) ? s.nodes.find((n) => n.kind === "assistant" && n.messageId === messageId) : undefined));
+      const text = useMemo(() => assistantTextOf(node), [node]);
+      const key = "msg:" + (sessionId ? sessionId + ":" : "") + String(messageId);
+      const isSpeaking = voice.speaking && voice.speakingKey === key;
+
+      // Auto-read when the turn closes (component mounts after finalize).
+      useEffect(() => {
+        if (!loadSettings().autoRead || !text || !sessionId) return;
+        if (spokenTurns.has(key)) return;
+        spokenTurns.add(key);
+        const timer = setTimeout(() => speakText(text, key), 400);
+        return () => clearTimeout(timer);
+      }, [key, text, sessionId]);
+
+      const toggle = () => {
+        if (isSpeaking) ttsStop();
+        else speakText(text, key);
+      };
+
+      return jsx("div", {
+        className: "dsh-voice-read",
+        children: jsx("button", {
+          type: "button",
+          className: "dsh-voice-btn" + (isSpeaking ? " dsh-voice-btn-active" : ""),
+          title: isSpeaking ? t("read.stop") : t("read.speak"),
+          "aria-label": isSpeaking ? t("read.stop") : t("read.speak"),
+          disabled: !text,
+          onClick: toggle,
+          children: isSpeaking ? "🔇" : "🔊"
+        })
+      });
+    }
+
+    // ──────────────────────── component: VoiceStatusLine ─────────────────────
+    function VoiceStatusLine(props) {
+      const { t } = props;
+      const voice = useVoice();
+      if (!voice.recording && !voice.speaking && !voice.error) return null;
+      let content;
+      if (voice.error) {
+        content = jsx("span", { className: "dsh-voice-error", children: "⚠️ " + voice.error });
+      } else if (voice.recording) {
+        content = jsxs(Fragment, {
+          children: [
+            jsx("span", { children: "🎙️ " + t("status.listening") }),
+            voice.interim ? jsx("span", { className: "dsh-voice-interim", children: "「" + voice.interim + "」" }) : null
+          ]
+        });
+      } else {
+        content = jsx("span", { children: "🔊 " + t("status.speaking") });
+      }
+      return jsx("div", { className: "dsh-voice-dock", children: content });
+    }
+
+    // ───────────────────────── component: VoiceSettings ──────────────────────
+    function VoiceSettings(props) {
+      const { t } = props;
+      const [settings, setSettings] = useState(loadSettings);
+      const update = (patch) => setSettings(saveSettings(patch));
+
+      const lang = settings.lang;
+      const mode = settings.mode;
+      const engine = settings.asrEngine;
+      const row = (label, control) => jsx("div", { className: "dsh-voice-row", children: [jsx("span", { children: label }), control] });
+
+      const children = [
+        jsx("h3", { children: "🗣️ " + t("settings.title") }),
+        !asrSupported() ? jsx("div", { className: "dsh-voice-row dsh-voice-error", children: t("settings.unsupported") }) : null,
+        row(t("settings.engine"), jsx("select", {
+          value: engine,
+          onChange: (e) => update({ asrEngine: e.target.value }),
+          children: [
+            jsx("option", { value: "server", children: t("settings.engine.server") }),
+            jsx("option", { value: "browser", children: t("settings.engine.browser") }),
+            jsx("option", { value: "auto", children: t("settings.engine.auto") })
+          ]
+        })),
+        row(t("settings.lang"), jsx("select", {
+          value: lang,
+          onChange: (e) => update({ lang: e.target.value }),
+          children: [
+            jsx("option", { value: "zh-CN", children: "中文(普通话)" }),
+            jsx("option", { value: "zh-TW", children: "中文(台湾)" }),
+            jsx("option", { value: "en-US", children: "English (US)" }),
+            jsx("option", { value: "en-GB", children: "English (UK)" }),
+            jsx("option", { value: "ja-JP", children: "日本語" }),
+            jsx("option", { value: "ko-KR", children: "한국어" })
+          ]
+        })),
+        row(t("settings.mode"), jsxs("span", {
+          children: [
+            jsx("label", { children: [jsx("input", { type: "radio", name: "dsh-voice-mode", checked: mode === "hold", onChange: () => update({ mode: "hold" }) }), t("settings.mode.hold")] }),
+            jsx("label", { children: [jsx("input", { type: "radio", name: "dsh-voice-mode", checked: mode === "toggle", onChange: () => update({ mode: "toggle" }) }), t("settings.mode.toggle")] })
+          ]
+        })),
+        row(t("settings.autoSubmit"), jsx("input", { type: "checkbox", checked: settings.autoSubmit, onChange: (e) => update({ autoSubmit: e.target.checked }) })),
+        row(t("settings.autoRead"), jsx("input", { type: "checkbox", checked: settings.autoRead, onChange: (e) => update({ autoRead: e.target.checked }) })),
+        row(t("settings.tts"), jsx("select", {
+          value: settings.ttsEngine,
+          onChange: (e) => update({ ttsEngine: e.target.value }),
+          children: [
+            jsx("option", { value: "edge", children: t("settings.tts.edge") }),
+            jsx("option", { value: "piper", children: t("settings.tts.piper") }),
+            jsx("option", { value: "browser", children: t("settings.tts.browser") })
+          ]
+        })),
+        settings.ttsEngine === "edge" ? row(t("settings.voice"), jsx("select", {
+          value: settings.ttsVoice,
+          onChange: (e) => update({ ttsVoice: e.target.value }),
+          children: [
+            jsx("option", { value: "zh-CN-XiaoyiNeural", children: "晓伊(温暖活泼 女声)" }),
+            jsx("option", { value: "zh-CN-XiaoxiaoNeural", children: "晓晓(温柔 女声)" }),
+            jsx("option", { value: "zh-CN-YunxiNeural", children: "云希(温暖 男声)" }),
+            jsx("option", { value: "zh-CN-YunjianNeural", children: "云健(沉稳 男声)" }),
+            jsx("option", { value: "zh-CN-YunxiaNeural", children: "云夏(活泼 男声)" }),
+            jsx("option", { value: "zh-CN-YunyangNeural", children: "云扬(新闻 男声)" })
+          ]
+        })) : null,
+        row(t("settings.rate"), jsx("input", {
+          type: "range", min: 0.5, max: 2, step: 0.1, value: settings.rate,
+          onChange: (e) => update({ rate: Number(e.target.value) }),
+          title: String(settings.rate)
+        })),
+        row(t("settings.skipCode"), jsx("input", { type: "checkbox", checked: settings.skipCode, onChange: (e) => update({ skipCode: e.target.checked }) })),
+        row(t("settings.test"), jsx("button", {
+          type: "button",
+          className: "dsh-voice-btn",
+          onClick: () => speakText(t("settings.test.text"), "voice-test"),
+          children: "🔊"
+        }))
+      ];
+
+      return jsx("section", { className: "dsh-voice-settings", children });
+    }
+
+    // ─────────────────────────────── registration ────────────────────────────
+    const inject = ["slots", "locale"];
+
+    function apply(ctx) {
+      ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-voice: dictionaries");
+
+      // Core: read-aloud button (+ auto-read) on each finalized assistant message.
+      ctx.slots.inject("conversation.chat.assistant-actions", () => ctx.slots.register({
+        name: "conversation.chat.assistant-actions",
+        id: "dsh-voice-read",
+        order: 100,
+        locale: NS
+      }, ReadAloud));
+
+      // Core: mic button in the composer tool row.
+      ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
+        name: "conversation.input.right",
+        id: "dsh-voice-mic",
+        order: 100,
+        locale: NS
+      }, MicButton));
+
+      // Voice status line under the composer.
+      ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
+        name: "conversation.composer.dock",
+        id: "dsh-voice-status",
+        order: 100,
+        locale: NS
+      }, VoiceStatusLine));
+
+      // Voice settings section.
+      ctx.slots.inject("settings.section", () => ctx.slots.register({
+        name: "settings.section",
+        id: "dsh-voice-settings",
+        order: 25,
+        label: () => ctx.locale.bind(NS)("settings.title"),
+        locale: NS
+      }, VoiceSettings));
+
+      // Dictation / voice-chat mode switch in the composer tool row (left).
+      ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
+        name: "conversation.input.left",
+        id: "dsh-voice-mode-toggle",
+        order: 100,
+        locale: NS
+      }, VoiceModeToggle));
+    }
+
+    exports.apply = apply;
+    exports.inject = inject;
+    return module.exports;
+  }
+});

+ 3 - 0
dsh-desktop/app/plugins/dsh-voice-client/lib/index.js

@@ -0,0 +1,3 @@
+/** Host loader entry for the browser implementation exported from `./client`. */
+/** Host plugin body — no host-side behavior; the whole feature lives in the browser bundle. */
+export function apply() {}

+ 19 - 0
dsh-desktop/app/plugins/dsh-voice-client/package.json

@@ -0,0 +1,19 @@
+{
+  "name": "dsh-voice-client",
+  "version": "0.1.2",
+  "description": "Voice interaction for the DeepSeek Harness Web GUI: mic input (ASR) + read-aloud (TTS), pure browser-native (Web Speech API).",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "exports": {
+    ".": "./lib/index.js",
+    "./client": "./lib/client.js",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "platform": "web"
+    }
+  },
+  "license": "MIT"
+}

+ 272 - 0
dsh-desktop/app/plugins/dsh-voice-server/lib/index-v6.js

@@ -0,0 +1,272 @@
+/**
+ * dsh-voice-server — local streaming ASR for the DSH Web GUI.
+ *
+ * Registers the exact-path upgrade route `/api/voice/asr` on the harness
+ * webserver. The browser client opens a WebSocket and streams 16 kHz Int16
+ * PCM; this plugin feeds sherpa-onnx's streaming Zipformer recognizer and
+ * replies with partial/final transcripts. Fully offline — no cloud service.
+ *
+ * Wire protocol (JSON frames over the socket):
+ *   client → server: binary frames = Int16LE PCM @ 16 kHz mono
+ *                    { "type": "stop" } → flush the final result and close
+ *   server → client: { "type": "partial", "text" }  (interim)
+ *                    { "type": "final",   "text" }  (committed segment)
+ *                    { "type": "error",   "message" }
+ */
+import { existsSync, appendFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir, homedir } from "node:os";
+import { spawn } from "node:child_process";
+import { WebSocketServer } from "ws";
+
+/** Default model directory (override with $DSH_VOICE_MODELS_DIR). */
+const DEFAULT_MODEL_DIR = "/Users/eastudio/Documents/DSWorkSpace/dsh-voice-models/zh-en-streaming";
+const MODEL_DIR = process.env.DSH_VOICE_MODELS_DIR || DEFAULT_MODEL_DIR;
+
+/** Recognition hotwords (domain terms to bias the recognizer toward). */
+const HOTWORDS_FILE = process.env.DSH_VOICE_HOTWORDS || "/Users/eastudio/Documents/DSWorkSpace/dsh-voice-models/hotwords.txt";
+
+/** Piper TTS model (local, offline). */
+const PIPER_MODEL = process.env.DSH_PIPER_MODEL || join(homedir(), "piper-models", "zh_CN-huayan-medium.onnx");
+const PIPER_CONFIG = process.env.DSH_PIPER_CONFIG || PIPER_MODEL + ".json";
+
+/** Diagnostic log (workspace-writable) to observe activation from inside the server process. */
+const DEBUG_LOG = "/Users/eastudio/Documents/DSWorkSpace/dsh-voice-server-debug.log";
+function dbg(msg) {
+  try { appendFileSync(DEBUG_LOG, new Date().toISOString() + " " + msg + "\n"); } catch { /* noop */ }
+}
+dbg("module evaluated");
+
+let wss = null;
+
+/** Lazy-load sherpa-onnx (native module) and build one recognizer. */
+let recognizerPromise = null;
+function loadRecognizer() {
+  if (recognizerPromise !== null) return recognizerPromise;
+  recognizerPromise = (async () => {
+    const sherpa = await import("sherpa-onnx-node");
+    const encoder = join(MODEL_DIR, "encoder-epoch-99-avg-1.onnx");
+    const decoder = join(MODEL_DIR, "decoder-epoch-99-avg-1.onnx");
+    const joiner = join(MODEL_DIR, "joiner-epoch-99-avg-1.onnx");
+    const tokens = join(MODEL_DIR, "tokens.txt");
+    const missing = [encoder, decoder, joiner, tokens].filter((p) => !existsSync(p));
+    if (missing.length > 0) {
+      throw new Error(`dsh-voice-server: missing model files in ${MODEL_DIR}: ${missing.join(", ")}`);
+    }
+    const rec = new sherpa.OnlineRecognizer({
+      featConfig: { sampleRate: 16000, featureDim: 80 },
+      modelConfig: {
+        transducer: { encoder, decoder, joiner },
+        tokens,
+        numThreads: 2,
+        provider: "cpu",
+        debug: 0
+      },
+      enableEndpoint: true,
+      decodingMethod: "modified_beam_search",
+      ...(existsSync(HOTWORDS_FILE) ? { hotwordsFile: HOTWORDS_FILE, hotwordsScore: 1.5 } : {})
+    });
+    if (rec === null || rec === undefined) throw new Error("dsh-voice-server: sherpa-onnx failed to create the recognizer");
+    return rec;
+  })();
+  recognizerPromise.catch(() => { recognizerPromise = null; }); // allow retry after failure
+  return recognizerPromise;
+}
+
+function send(socket, payload) {
+  if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(payload));
+}
+
+/** One active connection: its online stream + accumulated text. */
+function createSession(rec) {
+  const stream = rec.createStream();
+  // Trailing silence flushes the last syllable the streaming model otherwise drops.
+  const TAIL_SILENCE = new Float32Array(9600); // 0.6s @ 16 kHz
+  return {
+    stream,
+    closed: false,
+    endpointSent: false,
+    feed(pcm16 /* Int16Array */) {
+      const samples = new Float32Array(pcm16.length);
+      for (let i = 0; i < pcm16.length; i++) samples[i] = pcm16[i] / 32768;
+      stream.acceptWaveform({ samples, sampleRate: 16000 });
+      while (rec.isReady(stream)) rec.decode(stream);
+      if (!this.endpointSent && rec.isEndpoint(stream)) {
+        // Speech ended (trailing silence detected) — signal the client once.
+        this.endpointSent = true;
+        return { type: "endpoint", text: (rec.getResult(stream).text || "").trim() };
+      }
+      const partial = (rec.getResult(stream).text || "").trim();
+      if (partial) return { type: "partial", text: partial };
+      return null;
+    },
+    finalize() {
+      stream.acceptWaveform({ samples: TAIL_SILENCE, sampleRate: 16000 });
+      try { stream.inputFinished(); } catch { /* noop */ }
+      while (rec.isReady(stream)) rec.decode(stream);
+      this.closed = true;
+      return (rec.getResult(stream).text || "").trim();
+    }
+  };
+}
+
+function attachSession(socket) {
+  loadRecognizer()
+    .then((rec) => {
+      if (socket.readyState !== socket.OPEN) return;
+      const session = createSession(rec);
+      socket.on("message", (data, isBinary) => {
+        if (session.closed) return;
+        if (isBinary) {
+          try {
+            const pcm16 = new Int16Array(data.buffer, data.byteOffset, data.byteLength >> 1);
+            const ev = session.feed(pcm16);
+            if (ev) send(socket, ev);
+          } catch (err) {
+            send(socket, { type: "error", message: "decode error: " + String(err && err.message || err) });
+          }
+          return;
+        }
+        let msg;
+        try { msg = JSON.parse(data.toString()); } catch { return; }
+        if (msg && msg.type === "stop") {
+          const final = session.finalize();
+          if (final.trim()) send(socket, { type: "final", text: final });
+          session.closed = true;
+          try { socket.close(); } catch { /* noop */ }
+        }
+      });
+      socket.on("close", () => { /* stream has no explicit free in 1.x; GC reclaims */ });
+    })
+    .catch((err) => {
+      dbg("recognizer error: " + String(err && err.message || err));
+      send(socket, { type: "error", message: String(err && err.message || err) });
+      try { socket.close(); } catch { /* noop */ }
+    });
+}
+
+/**
+ * Synthesize speech via edge-tts (online, natural) or piper (offline).
+ * @returns {Promise<{data: Buffer, contentType: string}>}
+ */
+function synthesize({ text, engine, voice, rate }) {
+  return new Promise((resolve, reject) => {
+    const dir = mkdtempSync(join(tmpdir(), "dsh-tts-"));
+    const finish = (outPath, contentType) => {
+      try {
+        const data = readFileSync(outPath);
+        resolve({ data, contentType });
+      } catch (err) {
+        reject(err);
+      } finally {
+        try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ }
+      }
+    };
+
+    let child;
+    if (engine === "piper") {
+      const outPath = join(dir, "out.wav");
+      const args = ["-m", "piper", "-m", PIPER_MODEL, "-c", PIPER_CONFIG, "-f", outPath];
+      if (rate && rate > 0) args.push("--length-scale", String(Math.max(0.4, Math.min(2, 1 / rate))));
+      child = spawn("python3", args);
+      let stderr = "";
+      child.stderr.on("data", (d) => { stderr += d; });
+      child.on("error", (err) => reject(err));
+      child.on("close", (code) => {
+        if (code !== 0) return reject(new Error("piper 合成失败: " + (stderr.trim() || code)));
+        finish(outPath, "audio/wav");
+      });
+      child.stdin.write(text, "utf8");
+      child.stdin.end();
+      return;
+    }
+
+    // edge-tts (default)
+    const outPath = join(dir, "out.mp3");
+    const v = voice || "zh-CN-XiaoxiaoNeural";
+    const args = ["-m", "edge_tts", "--voice", v, "--text", text, "--write-media", outPath];
+    if (rate && rate > 0) {
+      const pct = Math.round((rate - 1) * 100);
+      if (pct !== 0) args.push("--rate", (pct > 0 ? "+" : "") + pct + "%");
+    }
+    child = spawn("python3", args);
+    let stderr = "";
+    child.stderr.on("data", (d) => { stderr += d; });
+    child.on("error", (err) => reject(err));
+    child.on("close", (code) => {
+      if (code !== 0) return reject(new Error("edge-tts 合成失败: " + (stderr.trim() || code)));
+      finish(outPath, "audio/mpeg");
+    });
+  });
+}
+
+/** Read a JSON request body. */
+async function readJson(req) {
+  let body = "";
+  for await (const chunk of req) body += chunk;
+  if (!body) return {};
+  try { return JSON.parse(body); } catch { return null; }
+}
+
+export const inject = ["webServer"];
+
+export function apply(ctx) {
+  dbg("apply called");
+  if (wss === null) wss = new WebSocketServer({ noServer: true });
+  ctx.effect(() => {
+    const disposeUpgrade = ctx.webServer.registerUpgrade({
+      path: "/api/voice/asr",
+      handler: (req, socket, head) => {
+        wss.handleUpgrade(req, socket, head, (ws) => {
+          attachSession(ws);
+        });
+      }
+    });
+    const disposePing = ctx.webServer.register({
+      kind: "exact",
+      path: "/api/voice/ping",
+      handler: (req, res) => {
+        res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
+        res.end(JSON.stringify({ ok: true, service: "dsh-voice-server" }));
+      }
+    });
+    const disposeTts = ctx.webServer.register({
+      kind: "exact",
+      path: "/api/voice/tts",
+      handler: async (req, res) => {
+        if (req.method !== "POST") {
+          res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
+          res.end(JSON.stringify({ error: "method not allowed" }));
+          return;
+        }
+        const parsed = await readJson(req);
+        if (parsed === null || typeof parsed.text !== "string" || !parsed.text.trim()) {
+          res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
+          res.end(JSON.stringify({ error: "missing text" }));
+          return;
+        }
+        try {
+          const { data, contentType } = await synthesize({
+            text: parsed.text,
+            engine: parsed.engine === "piper" ? "piper" : "edge",
+            voice: parsed.voice,
+            rate: parsed.rate
+          });
+          res.writeHead(200, { "content-type": contentType, "content-length": data.length, "cache-control": "no-cache" });
+          res.end(data);
+        } catch (err) {
+          dbg("tts error: " + String(err && err.message || err));
+          res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
+          res.end(JSON.stringify({ error: String(err && err.message || err) }));
+        }
+      }
+    });
+    dbg("routes registered");
+    return () => {
+      dbg("effect disposed — routes torn down");
+      disposeUpgrade();
+      disposePing();
+      disposeTts();
+    };
+  }, "dsh-voice-server: voice routes");
+}

+ 272 - 0
dsh-desktop/app/plugins/dsh-voice-server/lib/index.js

@@ -0,0 +1,272 @@
+/**
+ * dsh-voice-server — local streaming ASR for the DSH Web GUI.
+ *
+ * Registers the exact-path upgrade route `/api/voice/asr` on the harness
+ * webserver. The browser client opens a WebSocket and streams 16 kHz Int16
+ * PCM; this plugin feeds sherpa-onnx's streaming Zipformer recognizer and
+ * replies with partial/final transcripts. Fully offline — no cloud service.
+ *
+ * Wire protocol (JSON frames over the socket):
+ *   client → server: binary frames = Int16LE PCM @ 16 kHz mono
+ *                    { "type": "stop" } → flush the final result and close
+ *   server → client: { "type": "partial", "text" }  (interim)
+ *                    { "type": "final",   "text" }  (committed segment)
+ *                    { "type": "error",   "message" }
+ */
+import { existsSync, appendFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir, homedir } from "node:os";
+import { spawn } from "node:child_process";
+import { WebSocketServer } from "ws";
+
+/** Default model directory (override with $DSH_VOICE_MODELS_DIR). */
+const DEFAULT_MODEL_DIR = "/Users/eastudio/Documents/DSWorkSpace/dsh-voice-models/zh-en-streaming";
+const MODEL_DIR = process.env.DSH_VOICE_MODELS_DIR || DEFAULT_MODEL_DIR;
+
+/** Recognition hotwords (domain terms to bias the recognizer toward). */
+const HOTWORDS_FILE = process.env.DSH_VOICE_HOTWORDS || "/Users/eastudio/Documents/DSWorkSpace/dsh-voice-models/hotwords.txt";
+
+/** Piper TTS model (local, offline). */
+const PIPER_MODEL = process.env.DSH_PIPER_MODEL || join(homedir(), "piper-models", "zh_CN-huayan-medium.onnx");
+const PIPER_CONFIG = process.env.DSH_PIPER_CONFIG || PIPER_MODEL + ".json";
+
+/** Diagnostic log (workspace-writable) to observe activation from inside the server process. */
+const DEBUG_LOG = "/Users/eastudio/Documents/DSWorkSpace/dsh-voice-server-debug.log";
+function dbg(msg) {
+  try { appendFileSync(DEBUG_LOG, new Date().toISOString() + " " + msg + "\n"); } catch { /* noop */ }
+}
+dbg("module evaluated");
+
+let wss = null;
+
+/** Lazy-load sherpa-onnx (native module) and build one recognizer. */
+let recognizerPromise = null;
+function loadRecognizer() {
+  if (recognizerPromise !== null) return recognizerPromise;
+  recognizerPromise = (async () => {
+    const sherpa = await import("sherpa-onnx-node");
+    const encoder = join(MODEL_DIR, "encoder-epoch-99-avg-1.onnx");
+    const decoder = join(MODEL_DIR, "decoder-epoch-99-avg-1.onnx");
+    const joiner = join(MODEL_DIR, "joiner-epoch-99-avg-1.onnx");
+    const tokens = join(MODEL_DIR, "tokens.txt");
+    const missing = [encoder, decoder, joiner, tokens].filter((p) => !existsSync(p));
+    if (missing.length > 0) {
+      throw new Error(`dsh-voice-server: missing model files in ${MODEL_DIR}: ${missing.join(", ")}`);
+    }
+    const rec = new sherpa.OnlineRecognizer({
+      featConfig: { sampleRate: 16000, featureDim: 80 },
+      modelConfig: {
+        transducer: { encoder, decoder, joiner },
+        tokens,
+        numThreads: 2,
+        provider: "cpu",
+        debug: 0
+      },
+      enableEndpoint: true,
+      decodingMethod: "modified_beam_search",
+      ...(existsSync(HOTWORDS_FILE) ? { hotwordsFile: HOTWORDS_FILE, hotwordsScore: 1.5 } : {})
+    });
+    if (rec === null || rec === undefined) throw new Error("dsh-voice-server: sherpa-onnx failed to create the recognizer");
+    return rec;
+  })();
+  recognizerPromise.catch(() => { recognizerPromise = null; }); // allow retry after failure
+  return recognizerPromise;
+}
+
+function send(socket, payload) {
+  if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(payload));
+}
+
+/** One active connection: its online stream + accumulated text. */
+function createSession(rec) {
+  const stream = rec.createStream();
+  // Trailing silence flushes the last syllable the streaming model otherwise drops.
+  const TAIL_SILENCE = new Float32Array(9600); // 0.6s @ 16 kHz
+  return {
+    stream,
+    closed: false,
+    endpointSent: false,
+    feed(pcm16 /* Int16Array */) {
+      const samples = new Float32Array(pcm16.length);
+      for (let i = 0; i < pcm16.length; i++) samples[i] = pcm16[i] / 32768;
+      stream.acceptWaveform({ samples, sampleRate: 16000 });
+      while (rec.isReady(stream)) rec.decode(stream);
+      if (!this.endpointSent && rec.isEndpoint(stream)) {
+        // Speech ended (trailing silence detected) — signal the client once.
+        this.endpointSent = true;
+        return { type: "endpoint", text: (rec.getResult(stream).text || "").trim() };
+      }
+      const partial = (rec.getResult(stream).text || "").trim();
+      if (partial) return { type: "partial", text: partial };
+      return null;
+    },
+    finalize() {
+      stream.acceptWaveform({ samples: TAIL_SILENCE, sampleRate: 16000 });
+      try { stream.inputFinished(); } catch { /* noop */ }
+      while (rec.isReady(stream)) rec.decode(stream);
+      this.closed = true;
+      return (rec.getResult(stream).text || "").trim();
+    }
+  };
+}
+
+function attachSession(socket) {
+  loadRecognizer()
+    .then((rec) => {
+      if (socket.readyState !== socket.OPEN) return;
+      const session = createSession(rec);
+      socket.on("message", (data, isBinary) => {
+        if (session.closed) return;
+        if (isBinary) {
+          try {
+            const pcm16 = new Int16Array(data.buffer, data.byteOffset, data.byteLength >> 1);
+            const ev = session.feed(pcm16);
+            if (ev) send(socket, ev);
+          } catch (err) {
+            send(socket, { type: "error", message: "decode error: " + String(err && err.message || err) });
+          }
+          return;
+        }
+        let msg;
+        try { msg = JSON.parse(data.toString()); } catch { return; }
+        if (msg && msg.type === "stop") {
+          const final = session.finalize();
+          if (final.trim()) send(socket, { type: "final", text: final });
+          session.closed = true;
+          try { socket.close(); } catch { /* noop */ }
+        }
+      });
+      socket.on("close", () => { /* stream has no explicit free in 1.x; GC reclaims */ });
+    })
+    .catch((err) => {
+      dbg("recognizer error: " + String(err && err.message || err));
+      send(socket, { type: "error", message: String(err && err.message || err) });
+      try { socket.close(); } catch { /* noop */ }
+    });
+}
+
+/**
+ * Synthesize speech via edge-tts (online, natural) or piper (offline).
+ * @returns {Promise<{data: Buffer, contentType: string}>}
+ */
+function synthesize({ text, engine, voice, rate }) {
+  return new Promise((resolve, reject) => {
+    const dir = mkdtempSync(join(tmpdir(), "dsh-tts-"));
+    const finish = (outPath, contentType) => {
+      try {
+        const data = readFileSync(outPath);
+        resolve({ data, contentType });
+      } catch (err) {
+        reject(err);
+      } finally {
+        try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ }
+      }
+    };
+
+    let child;
+    if (engine === "piper") {
+      const outPath = join(dir, "out.wav");
+      const args = ["-m", "piper", "-m", PIPER_MODEL, "-c", PIPER_CONFIG, "-f", outPath];
+      if (rate && rate > 0) args.push("--length-scale", String(Math.max(0.4, Math.min(2, 1 / rate))));
+      child = spawn("python3", args);
+      let stderr = "";
+      child.stderr.on("data", (d) => { stderr += d; });
+      child.on("error", (err) => reject(err));
+      child.on("close", (code) => {
+        if (code !== 0) return reject(new Error("piper 合成失败: " + (stderr.trim() || code)));
+        finish(outPath, "audio/wav");
+      });
+      child.stdin.write(text, "utf8");
+      child.stdin.end();
+      return;
+    }
+
+    // edge-tts (default)
+    const outPath = join(dir, "out.mp3");
+    const v = voice || "zh-CN-XiaoxiaoNeural";
+    const args = ["-m", "edge_tts", "--voice", v, "--text", text, "--write-media", outPath];
+    if (rate && rate > 0) {
+      const pct = Math.round((rate - 1) * 100);
+      if (pct !== 0) args.push("--rate", (pct > 0 ? "+" : "") + pct + "%");
+    }
+    child = spawn("python3", args);
+    let stderr = "";
+    child.stderr.on("data", (d) => { stderr += d; });
+    child.on("error", (err) => reject(err));
+    child.on("close", (code) => {
+      if (code !== 0) return reject(new Error("edge-tts 合成失败: " + (stderr.trim() || code)));
+      finish(outPath, "audio/mpeg");
+    });
+  });
+}
+
+/** Read a JSON request body. */
+async function readJson(req) {
+  let body = "";
+  for await (const chunk of req) body += chunk;
+  if (!body) return {};
+  try { return JSON.parse(body); } catch { return null; }
+}
+
+export const inject = ["webServer"];
+
+export function apply(ctx) {
+  dbg("apply called");
+  if (wss === null) wss = new WebSocketServer({ noServer: true });
+  ctx.effect(() => {
+    const disposeUpgrade = ctx.webServer.registerUpgrade({
+      path: "/api/voice/asr",
+      handler: (req, socket, head) => {
+        wss.handleUpgrade(req, socket, head, (ws) => {
+          attachSession(ws);
+        });
+      }
+    });
+    const disposePing = ctx.webServer.register({
+      kind: "exact",
+      path: "/api/voice/ping",
+      handler: (req, res) => {
+        res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
+        res.end(JSON.stringify({ ok: true, service: "dsh-voice-server" }));
+      }
+    });
+    const disposeTts = ctx.webServer.register({
+      kind: "exact",
+      path: "/api/voice/tts",
+      handler: async (req, res) => {
+        if (req.method !== "POST") {
+          res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
+          res.end(JSON.stringify({ error: "method not allowed" }));
+          return;
+        }
+        const parsed = await readJson(req);
+        if (parsed === null || typeof parsed.text !== "string" || !parsed.text.trim()) {
+          res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
+          res.end(JSON.stringify({ error: "missing text" }));
+          return;
+        }
+        try {
+          const { data, contentType } = await synthesize({
+            text: parsed.text,
+            engine: parsed.engine === "piper" ? "piper" : "edge",
+            voice: parsed.voice,
+            rate: parsed.rate
+          });
+          res.writeHead(200, { "content-type": contentType, "content-length": data.length, "cache-control": "no-cache" });
+          res.end(data);
+        } catch (err) {
+          dbg("tts error: " + String(err && err.message || err));
+          res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
+          res.end(JSON.stringify({ error: String(err && err.message || err) }));
+        }
+      }
+    });
+    dbg("routes registered");
+    return () => {
+      dbg("effect disposed — routes torn down");
+      disposeUpgrade();
+      disposePing();
+      disposeTts();
+    };
+  }, "dsh-voice-server: voice routes");
+}

+ 18 - 0
dsh-desktop/app/plugins/dsh-voice-server/package.json

@@ -0,0 +1,18 @@
+{
+  "name": "dsh-voice-server",
+  "version": "0.1.0",
+  "description": "Local streaming ASR for the DSH Web GUI: sherpa-onnx Zipformer recognizer behind a WebSocket route (/api/voice/asr). Fully offline.",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "exports": {
+    ".": "./lib/index.js",
+    "./lib/*": "./lib/*",
+    "./package.json": "./package.json"
+  },
+  "dependencies": {
+    "sherpa-onnx-node": "^1.13.6",
+    "ws": "^8.21.3"
+  },
+  "license": "MIT"
+}

+ 94 - 0
dsh-desktop/app/plugins/dsh-voice-server/pnpm-lock.yaml

@@ -0,0 +1,94 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      sherpa-onnx-node:
+        specifier: ^1.13.6
+        version: 1.13.6
+      ws:
+        specifier: ^8.21.3
+        version: 8.21.3
+
+packages:
+
+  sherpa-onnx-darwin-arm64@1.13.6:
+    resolution: {integrity: sha512-m3QMOGUHVPUl7bAOPc7oJtmzOVdzQVQPsCuTjNuQ2N6zlYuXDZu9NdUR3Er3rxbhHa6SmWa94flUwmUsO1atow==}
+    cpu: [arm64]
+    os: [darwin]
+
+  sherpa-onnx-darwin-x64@1.13.6:
+    resolution: {integrity: sha512-3X5FQ9PpwlBmxLqtb2/uvl7/W2HXXzXHT0MLmB9c0TRMANLujzvIF7YbT6VW3k92ClL/1q9sbIOul0rHgxCTmw==}
+    cpu: [x64]
+    os: [darwin]
+
+  sherpa-onnx-linux-arm64@1.13.6:
+    resolution: {integrity: sha512-xSzhqrGFbBrykpbEFt5dLXB3Sp3a8idNzjhf92uylDgUhupBSRHj3ISeYaL9ZZWTNSzszNXBKlM+xH36dHQjPg==}
+    cpu: [arm64]
+    os: [linux]
+
+  sherpa-onnx-linux-x64@1.13.6:
+    resolution: {integrity: sha512-HiR3yolQDl3WoU1zSXn8L0MSXzF9PXLQpMv+Ptozg1y/DJU8HuvoXBasOarX4cer5d9kGimnOUGm0dl47PCN1A==}
+    cpu: [x64]
+    os: [linux]
+
+  sherpa-onnx-node@1.13.6:
+    resolution: {integrity: sha512-FjUSQSStTi/wcLqlScGFmNA7ySjW3QwquJcMRJ1yDkDpBp0BmWXkXoirDuEj8/v2/Cs4wLYJp2BKaMD9+1lDGA==}
+
+  sherpa-onnx-win-ia32@1.13.6:
+    resolution: {integrity: sha512-CN6rNQqn0wzdSp3XRHxdmNaLUMhP+BgsWVbaASU+V9CYBdx74uutoA/43ThUfh3S3CiArpp8OJtn8dJrPeGHnw==}
+    cpu: [ia32]
+    os: [win32]
+
+  sherpa-onnx-win-x64@1.13.6:
+    resolution: {integrity: sha512-bPMdURD1XCu1Zr3eYYlYx2obc5CMSQhEnd1GQuv7FXjdyOeMJ8jNbQEKFEIAyQdC481FgZvPBb2oSJXaLOOeVw==}
+    cpu: [x64]
+    os: [win32]
+
+  ws@8.21.3:
+    resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
+snapshots:
+
+  sherpa-onnx-darwin-arm64@1.13.6:
+    optional: true
+
+  sherpa-onnx-darwin-x64@1.13.6:
+    optional: true
+
+  sherpa-onnx-linux-arm64@1.13.6:
+    optional: true
+
+  sherpa-onnx-linux-x64@1.13.6:
+    optional: true
+
+  sherpa-onnx-node@1.13.6:
+    optionalDependencies:
+      sherpa-onnx-darwin-arm64: 1.13.6
+      sherpa-onnx-darwin-x64: 1.13.6
+      sherpa-onnx-linux-arm64: 1.13.6
+      sherpa-onnx-linux-x64: 1.13.6
+      sherpa-onnx-win-ia32: 1.13.6
+      sherpa-onnx-win-x64: 1.13.6
+
+  sherpa-onnx-win-ia32@1.13.6:
+    optional: true
+
+  sherpa-onnx-win-x64@1.13.6:
+    optional: true
+
+  ws@8.21.3: {}

+ 19 - 0
dsh-desktop/app/plugins/manifest.json

@@ -0,0 +1,19 @@
+{
+  "dsh-voice-client": {
+    "id": "dsh-voice-client",
+    "name": "dsh-voice-client"
+  },
+  "dsh-voice-server": {
+    "id": "dsh-voice-server",
+    "name": "dsh-voice-server/lib/index-v6.js"
+  },
+  "sherpa-onnx-node": {
+    "dep": true
+  },
+  "sherpa-onnx-darwin-arm64": {
+    "dep": true
+  },
+  "ws": {
+    "dep": true
+  }
+}

+ 3 - 0
dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/README.md

@@ -0,0 +1,3 @@
+# Introduction
+
+Please see [sherpa-onnx-node](https://www.npmjs.com/package/sherpa-onnx-node)

+ 1 - 0
dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/index.js

@@ -0,0 +1 @@
+module.exports = require('./sherpa-onnx.node');

BIN
dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/libonnxruntime.dylib


BIN
dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/libsherpa-onnx-c-api.dylib


BIN
dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/libsherpa-onnx-cxx-api.dylib


+ 65 - 0
dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/package.json

@@ -0,0 +1,65 @@
+{
+  "name": "sherpa-onnx-darwin-arm64",
+  "version": "1.13.6",
+  "description": "Speech-to-text, text-to-speech, speaker diarization, and speech enhancement using Next-gen Kaldi without internet connection",
+  "main": "index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/csukuangfj/sherpa-onnx.git"
+  },
+  "keywords": [
+    "speech to text",
+    "text to speech",
+    "transcription",
+    "real-time speech recognition",
+    "without internet connection",
+    "locally",
+    "local",
+    "embedded systems",
+    "open source",
+    "diarization",
+    "speaker diarization",
+    "speaker recognition",
+    "speaker",
+    "speaker segmentation",
+    "speaker verification",
+    "spoken language identification",
+    "sherpa",
+    "zipformer",
+    "asr",
+    "tts",
+    "stt",
+    "c++",
+    "onnxruntime",
+    "onnx",
+    "ai",
+    "next-gen kaldi",
+    "offline",
+    "privacy",
+    "open source",
+    "streaming speech recognition",
+    "speech",
+    "recognition",
+    "vad",
+    "node-addon-api",
+    "speaker id",
+    "language id",
+    "speech enhancement",
+    "denoising"
+  ],
+  "author": "The next-gen Kaldi team",
+  "license": "Apache-2.0",
+  "bugs": {
+    "url": "https://github.com/csukuangfj/sherpa-onnx/issues"
+  },
+  "homepage": "https://github.com/csukuangfj/sherpa-onnx#readme",
+   "os": [
+    "darwin"
+  ],
+  "cpu": [
+    "arm64"
+  ]
+}

BIN
dsh-desktop/app/plugins/sherpa-onnx-darwin-arm64/sherpa-onnx.node


+ 11 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/README.md

@@ -0,0 +1,11 @@
+# Introduction
+
+Please see
+https://github.com/k2-fsa/sherpa-onnx/blob/master/nodejs-addon-examples/README.md
+for usages.
+
+
+||Method|Support multiple threads|Minimum required node version|
+|---|---|---|---|
+|this package| https://github.com/nodejs/node-addon-api | Yes | v16|
+|https://www.npmjs.com/package/sherpa-onnx| WebAssembly | No | v18|

+ 70 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/addon-static-import.js

@@ -0,0 +1,70 @@
+const os = require('os');
+
+let addon = null;
+
+const platform = os.platform() === 'win32' ? 'win' : os.platform();
+const arch = os.arch();
+
+try {
+  if (arch === 'x64') {
+    if (platform === 'win') {
+      // @ts-expect-error
+      addon = require('../sherpa-onnx-win-x64/sherpa-onnx.node')
+    } else if (platform === 'darwin') {
+      // @ts-expect-error
+      addon = require('../sherpa-onnx-darwin-x64/sherpa-onnx.node')
+    } else if (platform === 'linux') {
+      // @ts-expect-error
+      addon = require('../sherpa-onnx-linux-x64/sherpa-onnx.node')
+    }
+  } else if (arch === 'arm64') {
+    if (platform === 'darwin') {
+      // @ts-expect-error
+      addon = require('../sherpa-onnx-darwin-arm64/sherpa-onnx.node')
+    } else if (platform === 'linux') {
+      // @ts-expect-error
+      addon = require('../sherpa-onnx-linux-arm64/sherpa-onnx.node')
+    }
+  } else if (arch === 'ia32') {
+    if (platform === 'win') {
+      // @ts-expect-error
+      addon = require('../sherpa-onnx-win-ia32/sherpa-onnx.node')
+    }
+  }
+} catch (error) {
+  //
+}
+
+if (!addon) {
+  try {
+    if (arch === 'x64') {
+      if (platform === 'win') {
+        // @ts-expect-error
+        addon = require('./node_modules/sherpa-onnx-win-x64/sherpa-onnx.node')
+      } else if (platform === 'darwin') {
+        // @ts-expect-error
+        addon = require('./node_modules/sherpa-onnx-darwin-x64/sherpa-onnx.node')
+      } else if (platform === 'linux') {
+        // @ts-expect-error
+        addon = require('./node_modules/sherpa-onnx-linux-x64/sherpa-onnx.node')
+      }
+    } else if (arch === 'arm64') {
+      if (platform === 'darwin') {
+        // @ts-expect-error
+        addon = require('./node_modules/sherpa-onnx-darwin-arm64/sherpa-onnx.node')
+      } else if (platform === 'linux') {
+        // @ts-expect-error
+        addon = require('./node_modules/sherpa-onnx-linux-arm64/sherpa-onnx.node')
+      }
+    } else if (arch === 'ia32') {
+      if (platform === 'win') {
+        // @ts-expect-error
+        addon = require('./node_modules/sherpa-onnx-win-ia32/sherpa-onnx.node')
+      }
+    }
+  } catch (error) {
+    //
+  }
+}
+ 
+module.exports = addon;

+ 98 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/addon.js

@@ -0,0 +1,98 @@
+/** @typedef {import('./types').WaveObject} WaveObject */
+
+const os = require('os');
+const path = require('path');
+const addonStaticImport = require('./addon-static-import');
+
+// Package name triggered spam for sherpa-onnx-win32-x64
+// so we have renamed it to sherpa-onnx-win-x64
+const platform = os.platform() === 'win32' ? 'win' : os.platform();
+const arch = os.arch();
+const platform_arch = `${platform}-${arch}`;
+const possible_paths = [
+  '../build/Release/sherpa-onnx.node',
+  '../build/Debug/sherpa-onnx.node',
+  `./node_modules/sherpa-onnx-${platform_arch}/sherpa-onnx.node`,
+  `../sherpa-onnx-${platform_arch}/sherpa-onnx.node`,
+  './sherpa-onnx.node',
+];
+
+let addon = addonStaticImport;
+
+if (!addon) {
+  for (const p of possible_paths) {
+    try {
+      addon = require(p);
+      break;
+    } catch (error) {
+      // do nothing; try the next option
+      ;
+    }
+  }
+}
+
+module.exports = addon;
+
+if (!addon) {
+  let addon_path =
+      `${process.env.PWD}/node_modules/sherpa-onnx-${platform_arch}`;
+  const pnpmIndex = __dirname.indexOf(`node_modules${path.sep}.pnpm`);
+  if (pnpmIndex !== -1) {
+    const parts = __dirname.slice(pnpmIndex).split(path.sep);
+    parts.pop();
+    addon_path =
+        `${process.env.PWD}/${parts.join('/')}/sherpa-onnx-${platform_arch}`;
+  }
+
+  let msg = `Could not find sherpa-onnx-node. Tried\n\n  ${
+      possible_paths.join('\n  ')}\n`
+  if (os.platform() == 'darwin' &&
+      (!process.env.DYLD_LIBRARY_PATH ||
+       !process.env.DYLD_LIBRARY_PATH.includes(
+           `node_modules/sherpa-onnx-${platform_arch}`))) {
+    msg +=
+        'Please remember to set the following environment variable and try again:\n';
+
+    msg += `export DYLD_LIBRARY_PATH=${addon_path}`;
+
+    msg += ':$DYLD_LIBRARY_PATH\n';
+  }
+
+  if (os.platform() == 'linux' &&
+      (!process.env.LD_LIBRARY_PATH ||
+       !process.env.LD_LIBRARY_PATH.includes(
+           `node_modules/sherpa-onnx-${platform_arch}`))) {
+    msg +=
+        'Please remember to set the following environment variable and try again:\n';
+
+    msg += `export LD_LIBRARY_PATH=${addon_path}`;
+
+    msg += ':$LD_LIBRARY_PATH\n';
+  }
+
+  throw new Error(msg)
+}
+
+/**
+ * Read a wave file from disk.
+ * @function module.exports.readWave
+ * @param {string} filename
+ * @param {boolean} [enableExternalBuffer=true]
+ * @returns {WaveObject}
+ */
+
+/**
+ * Read a wave from binary buffer.
+ * @function module.exports.readWaveFromBinary
+ * @param {Uint8Array} data - Binary contents of a wave file.
+ * @param {boolean} [enableExternalBuffer=true]
+ * @returns {WaveObject}
+ */
+
+/**
+ * Write a wave file to disk.
+ * @function module.exports.writeWave
+ * @param {string} filename
+ * @param {WaveObject} obj - { samples: Float32Array, sampleRate: number }
+ * @returns {boolean}
+ */

+ 45 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/audio-tagg.js

@@ -0,0 +1,45 @@
+/** @typedef {import('./types').AudioTaggingConfig} AudioTaggingConfig */
+/** @typedef {import('./types').AudioEvent} AudioEvent */
+/** @typedef {import('./types').AudioTaggingHandle} AudioTaggingHandle */
+/** @typedef {import('./non-streaming-asr').OfflineStream} OfflineStream */
+
+const addon = require('./addon.js');
+const non_streaming_asr = require('./non-streaming-asr.js');
+
+/**
+ * AudioTagging utility.
+ * @class
+ */
+class AudioTagging {
+  /**
+   * Create an AudioTagging instance.
+   * @param {AudioTaggingConfig} config
+   */
+  constructor(config) {
+    this.handle = addon.createAudioTagging(config);
+    this.config = config;
+  }
+
+  /**
+   * Create an offline stream bound to this AudioTagging instance.
+   * @returns {OfflineStream}
+   */
+  createStream() {
+    return new non_streaming_asr.OfflineStream(
+        addon.audioTaggingCreateOfflineStream(this.handle));
+  }
+
+  /**
+   * Compute audio tags from an offline stream.
+   * @param {OfflineStream} stream - An offline stream created by `AudioTagging.createStream()`.
+   * @param {number} [topK=-1] - Return top K results; -1 for all.
+   * @returns {AudioEvent[]}
+   */
+  compute(stream, topK = -1) {
+    return addon.audioTaggingCompute(this.handle, stream.handle, topK);
+  }
+}
+
+module.exports = {
+  AudioTagging,
+}

+ 68 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/keyword-spotter.js

@@ -0,0 +1,68 @@
+/** @typedef {import('./types').KeywordSpotterConfig} KeywordSpotterConfig */
+/** @typedef {import('./types').KeywordSpotterHandle} KeywordSpotterHandle */
+/** @typedef {import('./types').KeywordResult} KeywordResult */
+/** @typedef {import('./streaming-asr').OnlineStream} OnlineStream */
+
+const addon = require('./addon.js');
+const streaming_asr = require('./streaming-asr.js');
+
+/**
+ * KeywordSpotter handles keyword detection.
+ */
+class KeywordSpotter {
+  /**
+   * @param {KeywordSpotterConfig} config
+   */
+  constructor(config) {
+    this.handle = addon.createKeywordSpotter(config);
+    this.config = config
+  }
+
+  /**
+   * Create an OnlineStream for the spotter.
+   * @returns {OnlineStream}
+   */
+  createStream() {
+    const handle = addon.createKeywordStream(this.handle);
+    return new streaming_asr.OnlineStream(handle);
+  }
+
+  /**
+   * @param {OnlineStream} stream
+   * @returns {boolean}
+   */
+  isReady(stream) {
+    return addon.isKeywordStreamReady(this.handle, stream.handle);
+  }
+
+  /**
+   * Trigger decode on a stream.
+   * @param {OnlineStream} stream
+   */
+  decode(stream) {
+    addon.decodeKeywordStream(this.handle, stream.handle);
+  }
+
+  /**
+   * Reset a stream.
+   * @param {OnlineStream} stream
+   */
+  reset(stream) {
+    addon.resetKeywordStream(this.handle, stream.handle);
+  }
+
+  /**
+   * Get the keyword result for a stream.
+   * @param {OnlineStream} stream
+   * @returns {KeywordResult}
+   */
+  getResult(stream) {
+    const jsonStr = addon.getKeywordResultAsJson(this.handle, stream.handle);
+
+    return JSON.parse(jsonStr);
+  }
+}
+
+module.exports = {
+  KeywordSpotter,
+}

+ 158 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/non-streaming-asr.js

@@ -0,0 +1,158 @@
+/** @typedef {import('./types').OfflineStreamObject} OfflineStreamObject */
+/** @typedef {import('./types').OfflineStreamHandle} OfflineStreamHandle */
+/** @typedef {import('./types').OfflineRecognizerHandle} OfflineRecognizerHandle */
+/** @typedef {import('./types').Waveform} Waveform */
+/**
+ * @typedef {import('./types').OfflineRecognizerConfig} OfflineRecognizerConfig
+ */
+/**
+ * @typedef {import('./types').OfflineRecognizerResult} OfflineRecognizerResult
+ */
+
+const addon = require('./addon.js');
+
+/**
+ * Internal symbol to mark async-created recognizers.
+ * Not accessible unless someone has a reference to this Symbol.
+ */
+const kFromAsyncFactory = Symbol('OfflineRecognizer.fromAsync');
+
+/**
+ * OfflineStream represents a synchronous offline audio stream.
+ */
+class OfflineStream {
+  /**
+   * @param {OfflineStreamObject|Object} handle
+   */
+  constructor(handle) {
+    this.handle = handle;
+  }
+
+  /**
+   * Accept a chunk of waveform samples.
+   * @param {Waveform} obj - { samples: Float32Array, sampleRate: number }
+   */
+  acceptWaveform(obj) {
+    addon.acceptWaveformOffline(this.handle, obj);
+  }
+
+  /**
+   * Set a string option on the underlying offline stream.
+   * @param {string} key
+   * @param {string} value
+   */
+  setOption(key, value) {
+    addon.offlineStreamSetOption(this.handle, key, value);
+  }
+}
+
+/**
+ * OfflineRecognizer wraps the native offline recognizer.
+ */
+class OfflineRecognizer {
+  /**
+   * Constructor (SYNC path).
+   *
+   * Users call:
+   *   new OfflineRecognizer(config)
+   *
+   * Async factory calls this with an internal descriptor.
+   *
+   * @param {OfflineRecognizerConfig | Object} configOrInternal
+   */
+  constructor(configOrInternal) {
+    // ----- async factory path -----
+    if (configOrInternal && typeof configOrInternal === 'object' &&
+        configOrInternal[kFromAsyncFactory]) {
+      this.handle = configOrInternal.handle;
+      this.config = configOrInternal.config;
+      return;
+    }
+
+    // ----- sync constructor path -----
+    this.config = configOrInternal;
+    this.handle = addon.createOfflineRecognizer(this.config);
+  }
+
+  /**
+   * Create an OfflineRecognizer asynchronously (non-blocking).
+   *
+   * @param {OfflineRecognizerConfig} config
+   * @returns {Promise<OfflineRecognizer>}
+   */
+  static async createAsync(config) {
+    const handle = await addon.createOfflineRecognizerAsync(config);
+
+    return new OfflineRecognizer({
+      [kFromAsyncFactory]: true,
+      handle,
+      config,
+    });
+  }
+
+  /**
+   * Create a new OfflineStream bound to this recognizer.
+   *
+   * The optional hotwords argument enables contextual biasing for this
+   * stream only. Hotwords are supported only by transducer models decoded
+   * with decodingMethod 'modified_beam_search'. Separate multiple phrases
+   * with '/', and optionally append a per-phrase boosting score, e.g.
+   * 'PHOEBE :2.0/DON QUIXOTE'. When modelConfig.modelingUnit and
+   * modelConfig.bpeVocab are set, phrases are given as normal words;
+   * otherwise each phrase must be a sequence of tokens from tokens.txt
+   * separated by spaces. See also
+   * https://k2-fsa.github.io/sherpa/onnx/hotwords/index.html
+   *
+   * @param {string} [hotwords] Optional hotwords for this stream.
+   * @returns {OfflineStream}
+   */
+  createStream(hotwords) {
+    const handle = hotwords === undefined ?
+        addon.createOfflineStream(this.handle) :
+        addon.createOfflineStream(this.handle, hotwords);
+    return new OfflineStream(handle);
+  }
+
+  /**
+   * Replace the recognizer config at runtime.
+   * @param {OfflineRecognizerConfig} config
+   */
+  setConfig(config) {
+    this.config = config;
+    addon.offlineRecognizerSetConfig(this.handle, config);
+  }
+
+  /**
+   * Decode an offline stream (synchronous).
+   * @param {OfflineStream} stream
+   */
+  decode(stream) {
+    addon.decodeOfflineStream(this.handle, stream.handle);
+  }
+
+  /**
+   * Decode an offline stream asynchronously (non-blocking).
+   * @param {OfflineStream} stream
+   * @returns {Promise<OfflineRecognizerResult>}
+   */
+  async decodeAsync(stream) {
+    const jsonStr =
+        await addon.decodeOfflineStreamAsync(this.handle, stream.handle);
+    return JSON.parse(jsonStr);
+  }
+
+  /**
+   * Get recognition result for a stream.
+   * @param {OfflineStream} stream
+   * @returns {OfflineRecognizerResult}
+   */
+  getResult(stream) {
+    const jsonStr = addon.getOfflineStreamResultAsJson(stream.handle);
+    return JSON.parse(jsonStr);
+  }
+}
+
+module.exports = {
+  OfflineRecognizer,
+  OfflineStream,
+};

+ 38 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/non-streaming-speaker-diarization.js

@@ -0,0 +1,38 @@
+/** @typedef {import('./types').OfflineSpeakerDiarizationConfig} OfflineSpeakerDiarizationConfig */
+/** @typedef {import('./types').OfflineSpeakerDiarizationHandle} OfflineSpeakerDiarizationHandle */
+/** @typedef {import('./types').SpeakerDiarizationSegment} SpeakerDiarizationSegment */
+
+const addon = require('./addon.js');
+
+class OfflineSpeakerDiarization {
+  /**
+   * @param {OfflineSpeakerDiarizationConfig} config
+   */
+  constructor(config) {
+    this.handle = addon.createOfflineSpeakerDiarization(config);
+    this.config = config;
+
+    this.sampleRate = addon.getOfflineSpeakerDiarizationSampleRate(this.handle);
+  }
+
+  /**
+   * @param {Float32Array} samples - 1-D float32 array in [-1, 1]
+   * @returns {SpeakerDiarizationSegment[]}
+   */
+  process(samples) {
+    return addon.offlineSpeakerDiarizationProcess(this.handle, samples);
+  }
+
+  /**
+   * Set clustering configuration.
+   * @param {{clustering: import('./types').FastClusteringConfig}} config
+   */
+  setConfig(config) {
+    addon.offlineSpeakerDiarizationSetConfig(this.handle, config);
+    this.config.clustering = config.clustering;
+  }
+}
+
+module.exports = {
+  OfflineSpeakerDiarization,
+} 

+ 32 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/non-streaming-speech-denoiser.js

@@ -0,0 +1,32 @@
+/** @typedef {import('./types').OfflineSpeechDenoiserConfig} OfflineSpeechDenoiserConfig */
+/** @typedef {import('./types').OfflineSpeechDenoiserHandle} OfflineSpeechDenoiserHandle */
+/** @typedef {import('./types').GeneratedAudio} GeneratedAudio */
+/** @typedef {import('./types').AudioProcessRequest} AudioProcessRequest */
+
+const addon = require('./addon.js');
+
+class OfflineSpeechDenoiser {
+  /**
+   * @param {OfflineSpeechDenoiserConfig} config
+   */
+  constructor(config) {
+    this.handle = addon.createOfflineSpeechDenoiser(config);
+    this.config = config;
+
+    this.sampleRate =
+        addon.offlineSpeechDenoiserGetSampleRateWrapper(this.handle);
+  }
+
+  /**
+   * Run denoiser synchronously.
+   * @param {AudioProcessRequest} obj - { samples: Float32Array, sampleRate: number, enableExternalBuffer?: boolean }
+   * @returns {GeneratedAudio}
+   */
+  run(obj) {
+    return addon.offlineSpeechDenoiserRunWrapper(this.handle, obj);
+  }
+}
+
+module.exports = {
+  OfflineSpeechDenoiser,
+} 

+ 116 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/non-streaming-tts.js

@@ -0,0 +1,116 @@
+/** @typedef {import('./types').OfflineTtsConfig} OfflineTtsConfig */
+/** @typedef {import('./types').OfflineTtsHandle} OfflineTtsHandle */
+/** @typedef {import('./types').TtsRequest} TtsRequest */
+/** @typedef {import('./types').GeneratedAudio} GeneratedAudio */
+
+const addon = require('./addon.js');
+
+/**
+ * Internal symbol to mark async-created TTS instances.
+ */
+const kFromAsyncFactory = Symbol('OfflineTts.fromAsync');
+
+
+class GenerationConfig {
+  constructor(opts = {}) {
+    Object.assign(this, opts);
+  }
+}
+
+
+class OfflineTts {
+  /**
+   * Constructor (sync path).
+   *
+   * Users call:
+   *   new OfflineTts(config)
+   *
+   * Async factory calls this with an internal descriptor.
+   *
+   * @param {OfflineTtsConfig|Object} configOrInternal
+   */
+  constructor(configOrInternal) {
+    if (configOrInternal && typeof configOrInternal === 'object' &&
+        configOrInternal[kFromAsyncFactory]) {
+      // ----- async factory path -----
+      this.handle = configOrInternal.handle;
+      this.config = configOrInternal.config;
+    } else {
+      // ----- sync constructor path -----
+      this.config = configOrInternal;
+      this.handle = addon.createOfflineTts(this.config);
+    }
+
+    // Common initialization
+    this.numSpeakers = addon.getOfflineTtsNumSpeakers(this.handle);
+    this.sampleRate = addon.getOfflineTtsSampleRate(this.handle);
+  }
+
+  /**
+   * Create an OfflineTts asynchronously (non-blocking).
+   * @param {OfflineTtsConfig} config
+   * @returns {Promise<OfflineTts>}
+   */
+  static async createAsync(config) {
+    const handle = await addon.createOfflineTtsAsync(config);
+    return new OfflineTts({
+      [kFromAsyncFactory]: true,
+      handle,
+      config,
+    });
+  }
+
+  /**
+   * Generate audio synchronously.
+   * @param {TtsRequest} obj
+   * @returns {GeneratedAudio}
+   */
+  generate(obj) {
+    if (!obj || typeof obj !== 'object') {
+      throw new TypeError('generate() expects an object');
+    }
+
+    // If generationConfig is present, use new API
+    if (obj.generationConfig !== undefined) {
+      return addon.offlineTtsGenerateWithConfig(this.handle, obj);
+    }
+
+    // Fallback to legacy path
+    return addon.offlineTtsGenerate(this.handle, obj);
+  }
+  /**
+   * Generate audio asynchronously with optional generationConfig and progress
+   * callback
+   *
+   * The progress callback receives streaming audio chunks.
+   *
+   * @param {TtsRequest & { generationConfig?: GenerationConfig, onProgress?: (info: {
+   *     samples: Float32Array, progress: number }) => number | boolean | void
+   *     }} obj
+   * @returns {Promise<GeneratedAudio>}
+   */
+  generateAsync(obj) {
+    const {onProgress, ...rest} = obj;
+
+    const hasConfig = obj.generationConfig !== undefined;
+
+    const fn = hasConfig ? addon.offlineTtsGenerateAsyncWithConfig :
+                           addon.offlineTtsGenerateAsync;
+
+    return fn(this.handle, {
+      ...rest,
+      callback: typeof onProgress === 'function' ?
+          (info) => {
+            const ret = onProgress(info);
+            return ret === 0 || ret === false ? 0 : 1;
+          } :
+          undefined,
+    });
+  }
+}
+
+
+module.exports = {
+  OfflineTts,
+  GenerationConfig,
+}

+ 46 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/online-speech-denoiser.js

@@ -0,0 +1,46 @@
+/** @typedef {import('./types').OnlineSpeechDenoiserConfig} OnlineSpeechDenoiserConfig */
+/** @typedef {import('./types').OnlineSpeechDenoiserHandle} OnlineSpeechDenoiserHandle */
+/** @typedef {import('./types').GeneratedAudio} GeneratedAudio */
+/** @typedef {import('./types').AudioProcessRequest} AudioProcessRequest */
+
+const addon = require('./addon.js');
+
+class OnlineSpeechDenoiser {
+  /**
+   * @param {OnlineSpeechDenoiserConfig} config
+   */
+  constructor(config) {
+    this.handle = addon.createOnlineSpeechDenoiser(config);
+    this.config = config;
+
+    this.sampleRate =
+        addon.onlineSpeechDenoiserGetSampleRateWrapper(this.handle);
+    this.frameShiftInSamples =
+        addon.onlineSpeechDenoiserGetFrameShiftInSamplesWrapper(this.handle);
+  }
+
+  /**
+   * @param {AudioProcessRequest} obj
+   * @returns {GeneratedAudio}
+   */
+  run(obj) {
+    return addon.onlineSpeechDenoiserRunWrapper(this.handle, obj);
+  }
+
+  /**
+   * @param {boolean} [enableExternalBuffer=true]
+   * @returns {GeneratedAudio}
+   */
+  flush(enableExternalBuffer = true) {
+    return addon.onlineSpeechDenoiserFlushWrapper(
+        this.handle, enableExternalBuffer);
+  }
+
+  reset() {
+    addon.onlineSpeechDenoiserResetWrapper(this.handle);
+  }
+}
+
+module.exports = {
+  OnlineSpeechDenoiser,
+};

+ 67 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/package.json

@@ -0,0 +1,67 @@
+{
+  "name": "sherpa-onnx-node",
+  "version": "1.13.6",
+  "description": "Speech-to-text, text-to-speech, speaker diarization, and speech enhancement using Next-gen Kaldi without internet connection",
+  "main": "sherpa-onnx.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/csukuangfj/sherpa-onnx.git"
+  },
+  "keywords": [
+    "speech to text",
+    "text to speech",
+    "transcription",
+    "real-time speech recognition",
+    "without internet connection",
+    "locally",
+    "local",
+    "embedded systems",
+    "open source",
+    "diarization",
+    "speaker diarization",
+    "speaker recognition",
+    "speaker",
+    "speaker segmentation",
+    "speaker verification",
+    "spoken language identification",
+    "sherpa",
+    "zipformer",
+    "asr",
+    "tts",
+    "stt",
+    "c++",
+    "onnxruntime",
+    "onnx",
+    "ai",
+    "next-gen kaldi",
+    "offline",
+    "privacy",
+    "open source",
+    "streaming speech recognition",
+    "speech",
+    "recognition",
+    "vad",
+    "node-addon-api",
+    "speaker id",
+    "language id",
+    "speech enhancement",
+    "denoising"
+  ],
+  "author": "The next-gen Kaldi team",
+  "license": "Apache-2.0",
+  "bugs": {
+    "url": "https://github.com/csukuangfj/sherpa-onnx/issues"
+  },
+  "homepage": "https://github.com/csukuangfj/sherpa-onnx#readme",
+  "optionalDependencies": {
+    "sherpa-onnx-darwin-arm64": "^1.13.6",
+    "sherpa-onnx-darwin-x64": "^1.13.6",
+    "sherpa-onnx-linux-x64": "^1.13.6",
+    "sherpa-onnx-linux-arm64": "^1.13.6",
+    "sherpa-onnx-win-x64": "^1.13.6",
+    "sherpa-onnx-win-ia32": "^1.13.6"
+  }
+}

+ 43 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/punctuation.js

@@ -0,0 +1,43 @@
+/** @typedef {import('./types').OfflinePunctuationHandle} OfflinePunctuationHandle */
+/** @typedef {import('./types').OfflinePunctuationConfig} OfflinePunctuationConfig */
+/** @typedef {import('./types').OnlinePunctuationConfig} OnlinePunctuationConfig */
+/** @typedef {import('./types').OnlinePunctuationHandle} OnlinePunctuationHandle */
+
+const addon = require('./addon.js');
+
+class OfflinePunctuation {
+  /**
+   * @param {OfflinePunctuationConfig} config
+   */
+  constructor(config) {
+    this.handle = addon.createOfflinePunctuation(config);
+    this.config = config;
+  }
+  /**
+   * Add punctuation to `text` and return the punctuated text.
+   * @param {string} text
+   * @returns {string}
+   */
+  addPunct(text) {
+    return addon.offlinePunctuationAddPunct(this.handle, text);
+  }
+}
+
+class OnlinePunctuation {
+  /**
+   * @param {OnlinePunctuationConfig} config
+   */
+  constructor(config) {
+    this.handle = addon.createOnlinePunctuation(config);
+    this.config = config;
+  }
+  /** @param {string} text @returns {string} */
+  addPunct(text) {
+    return addon.onlinePunctuationAddPunct(this.handle, text);
+  }
+}
+
+module.exports = {
+  OfflinePunctuation,
+  OnlinePunctuation,
+} 

+ 80 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/resampler.js

@@ -0,0 +1,80 @@
+/** @typedef {import('./types').LinearResamplerHandle} LinearResamplerHandle */
+
+const addon = require('./addon.js');
+
+/**
+ * A linear resampler that converts audio from one sample rate to another.
+ */
+class LinearResampler {
+  /**
+   * Create a linear resampler.
+   *
+   * @param {number} inputSampleRate - Input sample rate in Hz.
+   * @param {number} outputSampleRate - Output sample rate in Hz.
+   */
+  constructor(inputSampleRate, outputSampleRate) {
+    /** @type {LinearResamplerHandle} */
+    this.handle =
+        addon.createLinearResampler(inputSampleRate, outputSampleRate);
+    this.inputSampleRate = inputSampleRate;
+    this.outputSampleRate = outputSampleRate;
+  }
+
+  /**
+   * Resample a chunk of audio samples.
+   *
+   * Call this for each chunk of input audio. For the final chunk, call
+   * {@link flush} instead so that any internally buffered samples are
+   * emitted.
+   *
+   * @param {Float32Array} samples - Input audio samples.
+   * @returns {Float32Array} Resampled audio samples.
+   */
+  resample(samples) {
+    return addon.resampleLinear(this.handle, samples, 0);
+  }
+
+  /**
+   * Resample the final chunk of audio and flush internal buffers.
+   *
+   * This is the same as {@link resample} but sets flush=1 so that any
+   * remaining samples buffered inside the resampler are emitted. Call
+   * this once after the last chunk of input audio.
+   *
+   * @param {Float32Array} samples - The final chunk of input audio samples.
+   * @returns {Float32Array} Resampled audio samples including buffered tail.
+   */
+  flush(samples) {
+    return addon.resampleLinear(this.handle, samples, 1);
+  }
+
+  /**
+   * Reset the resampler to its initial state, discarding any internal
+   * buffered samples.
+   */
+  reset() {
+    addon.linearResamplerReset(this.handle);
+  }
+
+  /**
+   * Get the input sample rate.
+   *
+   * @returns {number} Input sample rate in Hz.
+   */
+  getInputSampleRate() {
+    return addon.linearResamplerGetInputSampleRate(this.handle);
+  }
+
+  /**
+   * Get the output sample rate.
+   *
+   * @returns {number} Output sample rate in Hz.
+   */
+  getOutputSampleRate() {
+    return addon.linearResamplerGetOutputSampleRate(this.handle);
+  }
+}
+
+module.exports = {
+  LinearResampler,
+}

+ 49 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/sherpa-onnx.js

@@ -0,0 +1,49 @@
+/** @typedef {import('./types').WaveObject} WaveObject */
+/**
+ * @typedef {import('./types').OnlineRecognizerResult} OnlineRecognizerResult
+ */
+/**
+ * @typedef {import('./types').OfflineRecognizerResult} OfflineRecognizerResult
+ */
+
+const addon = require('./addon.js')
+const streaming_asr = require('./streaming-asr.js');
+const non_streaming_asr = require('./non-streaming-asr.js');
+const non_streaming_tts = require('./non-streaming-tts.js');
+const vad = require('./vad.js');
+const slid = require('./spoken-language-identification.js');
+const sid = require('./speaker-identification.js');
+const at = require('./audio-tagg.js');
+const punct = require('./punctuation.js');
+const kws = require('./keyword-spotter.js');
+const sd = require('./non-streaming-speaker-diarization.js');
+const speech_denoiser = require('./non-streaming-speech-denoiser.js');
+const online_speech_denoiser = require('./online-speech-denoiser.js');
+const resampler = require('./resampler.js');
+
+module.exports = {
+  OnlineRecognizer : streaming_asr.OnlineRecognizer,
+  OfflineRecognizer : non_streaming_asr.OfflineRecognizer,
+  OfflineTts : non_streaming_tts.OfflineTts,
+  GenerationConfig : non_streaming_tts.GenerationConfig,
+  readWave : addon.readWave,
+  writeWave : addon.writeWave,
+  Display : streaming_asr.Display,
+  Vad : vad.Vad,
+  CircularBuffer : vad.CircularBuffer,
+  SpokenLanguageIdentification : slid.SpokenLanguageIdentification,
+  SpeakerEmbeddingExtractor : sid.SpeakerEmbeddingExtractor,
+  SpeakerEmbeddingManager : sid.SpeakerEmbeddingManager,
+  AudioTagging : at.AudioTagging,
+  OfflinePunctuation : punct.OfflinePunctuation,
+  OnlinePunctuation : punct.OnlinePunctuation,
+  KeywordSpotter : kws.KeywordSpotter,
+  OfflineSpeakerDiarization : sd.OfflineSpeakerDiarization,
+  OfflineSpeechDenoiser : speech_denoiser.OfflineSpeechDenoiser,
+  OnlineSpeechDenoiser : online_speech_denoiser.OnlineSpeechDenoiser,
+  LinearResampler : resampler.LinearResampler,
+  version : addon.version,
+  gitSha1 : addon.gitSha1,
+  gitDate : addon.gitDate,
+  onnxruntimeVersion : addon.onnxruntimeVersion,
+}

+ 152 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/speaker-identification.js

@@ -0,0 +1,152 @@
+/** @typedef {import('./types').SpeakerEmbeddingEntry} SpeakerEmbeddingEntry */
+/** @typedef {import('./types').SpeakerEmbeddingManagerSearchObj} SpeakerEmbeddingManagerSearchObj */
+/** @typedef {import('./types').SpeakerEmbeddingManagerVerifyObj} SpeakerEmbeddingManagerVerifyObj */
+/** @typedef {import('./types').SpeakerEmbeddingExtractorConfig} SpeakerEmbeddingExtractorConfig */
+/** @typedef {import('./types').SpeakerEmbeddingExtractorHandle} SpeakerEmbeddingExtractorHandle */
+/** @typedef {import('./types').SpeakerEmbeddingManagerHandle} SpeakerEmbeddingManagerHandle */
+/** @typedef {import('./streaming-asr').OnlineStream} OnlineStream */
+
+const addon = require('./addon.js');
+const streaming_asr = require('./streaming-asr.js');
+
+/**
+ * SpeakerEmbeddingExtractor wraps native speaker embedding extractor.
+ */
+class SpeakerEmbeddingExtractor {
+  /**
+   * @param {SpeakerEmbeddingExtractorConfig} config
+   */
+  constructor(config) {
+    this.handle = addon.createSpeakerEmbeddingExtractor(config);
+    this.config = config;
+    this.dim = addon.speakerEmbeddingExtractorDim(this.handle);
+  }
+
+  /**
+   * @returns {OnlineStream}
+   */
+  createStream() {
+    return new streaming_asr.OnlineStream(
+        addon.speakerEmbeddingExtractorCreateStream(this.handle));
+  }
+
+  /**
+   * @param {OnlineStream} stream
+   * @returns {boolean}
+   */
+  isReady(stream) {
+    return addon.speakerEmbeddingExtractorIsReady(this.handle, stream.handle);
+  }
+
+  /**
+   * Compute embedding and return a Float32Array
+   * @param {OnlineStream} stream
+   * @param {boolean} [enableExternalBuffer=true]
+   * @returns {Float32Array}
+   */
+  compute(stream, enableExternalBuffer = true) {
+    return addon.speakerEmbeddingExtractorComputeEmbedding(
+        this.handle, stream.handle, enableExternalBuffer);
+  }
+}
+
+/**
+ * Flattens an array of Float32Arrays into a single Float32Array.
+ * @param {Float32Array[]} arrayList
+ * @returns {Float32Array}
+ */
+function flatten(arrayList) {
+  let n = 0;
+  for (let i = 0; i < arrayList.length; ++i) {
+    n += arrayList[i].length;
+  }
+  let ans = new Float32Array(n);
+
+  let offset = 0;
+  for (let i = 0; i < arrayList.length; ++i) {
+    ans.set(arrayList[i], offset);
+    offset += arrayList[i].length;
+  }
+  return ans;
+}
+
+/**
+ * Manager for speaker embeddings.
+ */
+class SpeakerEmbeddingManager {
+  /**
+   * @param {number} dim - The embedding dimension
+   */
+  constructor(dim) {
+    this.handle = addon.createSpeakerEmbeddingManager(dim);
+    this.dim = dim;
+  }
+
+  /**
+   * @param {SpeakerEmbeddingEntry} obj
+   * @returns {boolean}
+   */
+  add(obj) {
+    return addon.speakerEmbeddingManagerAdd(this.handle, obj);
+  }
+
+  /**
+   * @param {{name:string, v: Float32Array[]}} obj
+   * @returns {boolean}
+   */
+  addMulti(obj) {
+    const c = {
+      name: obj.name,
+      vv: flatten(obj.v),
+      n: obj.v.length,
+    };
+    return addon.speakerEmbeddingManagerAddListFlattened(this.handle, c);
+  }
+
+  /**
+   * @param {string} name
+   * @returns {boolean}
+   */
+  remove(name) {
+    return addon.speakerEmbeddingManagerRemove(this.handle, name);
+  }
+
+  /**
+   * @param {SpeakerEmbeddingManagerSearchObj} obj
+   * @returns {string}
+   */
+  search(obj) {
+    return addon.speakerEmbeddingManagerSearch(this.handle, obj);
+  }
+
+  /**
+   * @param {SpeakerEmbeddingManagerVerifyObj} obj
+   * @returns {boolean}
+   */
+  verify(obj) {
+    return addon.speakerEmbeddingManagerVerify(this.handle, obj);
+  }
+
+  /**
+   * @param {string} name
+   * @returns {boolean}
+   */
+  contains(name) {
+    return addon.speakerEmbeddingManagerContains(this.handle, name);
+  }
+
+  /** @returns {number} */
+  getNumSpeakers() {
+    return addon.speakerEmbeddingManagerNumSpeakers(this.handle);
+  }
+
+  /** @returns {string[]} */
+  getAllSpeakerNames() {
+    return addon.speakerEmbeddingManagerGetAllSpeakers(this.handle);
+  }
+}
+
+module.exports = {
+  SpeakerEmbeddingExtractor,
+  SpeakerEmbeddingManager,
+}

+ 38 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/spoken-language-identification.js

@@ -0,0 +1,38 @@
+/** @typedef {import('./types').SpokenLanguageIdentificationConfig} SpokenLanguageIdentificationConfig */
+/** @typedef {import('./types').SpokenLanguageIdentificationHandle} SpokenLanguageIdentificationHandle */
+/** @typedef {import('./non-streaming-asr').OfflineStream} OfflineStream */
+
+const addon = require('./addon.js');
+const non_streaming_asr = require('./non-streaming-asr.js');
+
+class SpokenLanguageIdentification {
+  /**
+   * @param {SpokenLanguageIdentificationConfig} config
+   */
+  constructor(config) {
+    this.handle = addon.createSpokenLanguageIdentification(config);
+    this.config = config;
+  }
+
+  /**
+   * @returns {OfflineStream}
+   */
+  createStream() {
+    return new non_streaming_asr.OfflineStream(
+        addon.createSpokenLanguageIdentificationOfflineStream(this.handle));
+  }
+
+  /**
+   * Return a 2-letter language code, e.g. 'en', 'de', 'fr', 'es', 'zh'
+   * @param {OfflineStream} stream
+   * @returns {string}
+   */
+  compute(stream) {
+    return addon.spokenLanguageIdentificationCompute(
+        this.handle, stream.handle);
+  }
+}
+
+module.exports = {
+  SpokenLanguageIdentification,
+} 

+ 130 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/streaming-asr.js

@@ -0,0 +1,130 @@
+/** @typedef {import('./types').OnlineStreamObject} OnlineStreamObject */
+/** @typedef {import('./types').OnlineRecognizerHandle} OnlineRecognizerHandle */
+/** @typedef {import('./types').OnlineStreamHandle} OnlineStreamHandle */
+/** @typedef {import('./types').DisplayHandle} DisplayHandle */
+/** @typedef {import('./types').DisplayObject} DisplayObject */
+/** @typedef {import('./types').OnlineRecognizerConfig} OnlineRecognizerConfig */
+/** @typedef {import('./types').Waveform} Waveform */
+/** @typedef {import('./types').OnlineRecognizerResult} OnlineRecognizerResult */
+
+const addon = require('./addon.js');
+
+/**
+ * Display helper for printing recognized words.
+ */
+class Display {
+  /**
+   * @param {number} maxWordPerline
+   */
+  constructor(maxWordPerline) {
+    this.handle = addon.createDisplay(maxWordPerline);
+  }
+
+  /**
+   * Print text to display.
+   * @param {number} idx
+   * @param {string} text
+   */
+  print(idx, text) {
+    addon.print(this.handle, idx, text)
+  }
+}
+
+/**
+ * OnlineStream holds an active online stream handle.
+ */
+class OnlineStream {
+  /**
+   * @param {OnlineStreamObject|Object} handle - object with `handle` property
+   */
+  constructor(handle) {
+    this.handle = handle;
+  }
+
+  /**
+   * Accept waveform data
+   * @param {Waveform} obj - { samples: Float32Array, sampleRate: number }
+   */
+  acceptWaveform(obj) {
+    addon.acceptWaveformOnline(this.handle, obj)
+  }
+
+  /** Notify the stream input has finished. */
+  inputFinished() {
+    addon.inputFinished(this.handle)
+  }
+}
+
+/**
+ * OnlineRecognizer wraps native online recognizer.
+ */
+class OnlineRecognizer {
+  /**
+   * @param {OnlineRecognizerConfig} config - online recognizer config (see C++ for fields)
+   */
+  constructor(config) {
+    this.handle = addon.createOnlineRecognizer(config);
+    this.config = config
+  }
+
+  /**
+   * Create a new OnlineStream.
+   * @returns {OnlineStream}
+   */
+  createStream() {
+    const handle = addon.createOnlineStream(this.handle);
+    return new OnlineStream(handle);
+  }
+
+  /**
+   * Check whether a stream is ready.
+   * @param {OnlineStream} stream
+   * @returns {boolean}
+   */
+  isReady(stream) {
+    return addon.isOnlineStreamReady(this.handle, stream.handle);
+  }
+
+  /**
+   * Trigger decoding on a stream.
+   * @param {OnlineStream} stream
+   */
+  decode(stream) {
+    addon.decodeOnlineStream(this.handle, stream.handle);
+  }
+
+  /**
+   * Check endpoint condition for a stream.
+   * @param {OnlineStream} stream
+   * @returns {boolean}
+   */
+  isEndpoint(stream) {
+    return addon.isEndpoint(this.handle, stream.handle);
+  }
+
+  /**
+   * Reset a stream.
+   * @param {OnlineStream} stream
+   */
+  reset(stream) {
+    addon.reset(this.handle, stream.handle);
+  }
+
+  /**
+   * Get recognition result for a stream.
+   * @param {OnlineStream} stream
+   * @returns {OnlineRecognizerResult}
+   */
+  getResult(stream) {
+    const jsonStr =
+        addon.getOnlineStreamResultAsJson(this.handle, stream.handle);
+
+    return JSON.parse(jsonStr);
+  }
+}
+
+module.exports = {
+  OnlineRecognizer,
+  OnlineStream,
+  Display
+}

+ 787 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/types.js

@@ -0,0 +1,787 @@
+/**
+ * Centralized JSDoc typedefs for the Node addon API.
+ * These typedefs mirror the shapes produced/consumed by the C++ bindings
+ * in `scripts/node-addon-api/src/*` and by the underlying SherpaOnnx C API.
+ *
+ * Keep these typedefs specialized (no `any`/`unknown`) and concise.
+ */
+
+/**
+ * Opaque handle types returned by native constructors. These are opaque
+ * JavaScript objects backed by native pointers. Do not introspect or
+ * mutate their internals; pass them to the API functions as-is.
+ *
+ * @typedef {Object} OfflineStreamHandle
+ * @see src/non-streaming-asr.cc
+ */
+
+/**
+ * @typedef {Object} OnlineStreamHandle
+ * @see src/streaming-asr.cc
+ */
+
+/**
+ * @typedef {Object} OfflineRecognizerHandle
+ * @see src/non-streaming-asr.cc
+ */
+
+/**
+ * @typedef {Object} OnlineRecognizerHandle
+ * @see src/streaming-asr.cc
+ */
+
+/**
+ * @typedef {Object} DisplayHandle
+ * @see src/streaming-asr.cc
+ */
+
+/**
+ * @typedef {Object} CircularBufferHandle
+ * @see src/vad.cc
+ */
+
+/**
+ * @typedef {Object} VoiceActivityDetectorHandle
+ * @see src/vad.cc
+ */
+
+/**
+ * @typedef {Object} AudioTaggingHandle
+ * @see src/audio-tagging.cc
+ */
+
+/**
+ * @typedef {Object} OfflinePunctuationHandle
+ * @see src/offline-punctuation.cc
+ */
+
+/**
+ * @typedef {Object} LinearResamplerHandle
+ * @see src/resampler.cc
+ */
+
+/**
+ * @typedef {Object} OfflineTtsHandle
+ * @see src/non-streaming-tts.cc
+ */
+
+/**
+ * @typedef {Object} OnlinePunctuationHandle
+ * @see src/punctuation.cc
+ */
+
+/**
+ * @typedef {Object} KeywordSpotterHandle
+ * @see src/keyword-spotter.cc
+ */
+
+/**
+ * @typedef {Object} SpeakerEmbeddingExtractorHandle
+ * @see src/speaker-identification.cc
+ */
+
+/**
+ * @typedef {Object} SpeakerEmbeddingManagerHandle
+ * @see src/speaker-identification.cc
+ */
+
+/**
+ * @typedef {Object} SpokenLanguageIdentificationHandle
+ * @see src/spoken-language-identification.cc
+ */
+
+/**
+ * @typedef {Object} OfflineSpeakerDiarizationHandle
+ * @see src/non-streaming-speaker-diarization.cc
+ */
+
+/**
+ * @typedef {Object} OfflineSpeechDenoiserHandle
+ * @see src/non-streaming-speech-denoiser.cc
+ */
+
+/**
+ * @typedef {Object} OnlineSpeechDenoiserHandle
+ * @see src/online-speech-denoiser.cc
+ */
+
+/**
+ * A single audio event returned by AudioTagging.compute().
+ * @typedef {Object} AudioEvent
+ * @property {string} name - The event name.
+ * @property {number} prob - Probability in [0,1].
+ * @property {number} index - Index (integer) of the event.
+ */
+
+/**
+ * AudioTagging specific model config for Zipformer variant
+ * @typedef {Object} AudioTaggingZipformerModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * AudioTagging model config.
+ * @typedef {Object} AudioTaggingModelConfig
+ * @property {AudioTaggingZipformerModelConfig} [zipformer]
+ * @property {string} [ced]
+ * @property {number} [numThreads]
+ * @property {boolean|number} [debug]
+ * @property {string} [provider]
+ */
+
+/**
+ * AudioTagging configuration passed to constructor.
+ * @typedef {Object} AudioTaggingConfig
+ * @property {AudioTaggingModelConfig} [model]
+ * @property {string} [labels]
+ * @property {number} [topK]
+ */
+
+/**
+ * Waveform input object used by acceptWaveform methods.
+ * @typedef {Object} Waveform
+ * @property {Float32Array} samples - Float32Array of samples in [-1, 1].
+ * @property {number} sampleRate - Sample rate as an integer (e.g., 16000).
+ */
+
+/**
+ * Feature config used by recognizers and models.
+ * @typedef {Object} FeatureConfig
+ * @property {number} [sampleRate]
+ * @property {number} [featureDim]
+ */
+
+/**
+ * Silero VAD model config
+ * @typedef {Object} SileroVadModelConfig
+ * @property {string} [model]
+ * @property {number} [threshold]
+ * @property {number} [minSilenceDuration]
+ * @property {number} [minSpeechDuration]
+ * @property {number} [windowSize]
+ * @property {number} [maxSpeechDuration]
+ */
+
+/**
+ * Ten-VAD model config
+ * @typedef {Object} TenVadModelConfig
+ * @property {string} [model]
+ * @property {number} [threshold]
+ * @property {number} [minSilenceDuration]
+ * @property {number} [minSpeechDuration]
+ * @property {number} [windowSize]
+ * @property {number} [maxSpeechDuration]
+ */
+
+/**
+ * Voice activity detector configuration.
+ * @typedef {Object} VadConfig
+ * @property {SileroVadModelConfig} [sileroVad]
+ * @property {TenVadModelConfig} [tenVad]
+ * @property {number} [sampleRate]
+ * @property {number} [numThreads]
+ * @property {string} [provider]
+ * @property {boolean|number} [debug]
+ */
+
+/**
+ * Offline Transducer model config
+ * @typedef {Object} OfflineTransducerModelConfig
+ * @property {string} [encoder]
+ * @property {string} [decoder]
+ * @property {string} [joiner]
+ */
+
+/**
+ * Offline Paraformer model config
+ * @typedef {Object} OfflineParaformerModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Offline Zipformer CTC model config
+ * @typedef {Object} OfflineZipformerCtcModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Offline Wenet CTC model config
+ * @typedef {Object} OfflineWenetCtcModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Offline Omnilingual ASR CTC model config
+ * @typedef {Object} OfflineOmnilingualAsrCtcModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Offline Med ASR CTC model config
+ * @typedef {Object} OfflineMedAsrCtcModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Offline Dolphin model config
+ * @typedef {Object} OfflineDolphinModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Offline NeMo CTC model config
+ * @typedef {Object} OfflineNeMoCtcModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Offline Canary model config
+ * @typedef {Object} OfflineCanaryModelConfig
+ * @property {string} [encoder]
+ * @property {string} [decoder]
+ * @property {string} [srcLang]
+ * @property {string} [tgtLang]
+ * @property {number} [usePnc]
+ */
+
+/**
+ * Offline Whisper model config
+ * @typedef {Object} OfflineWhisperModelConfig
+ * @property {string} [encoder]
+ * @property {string} [decoder]
+ * @property {string} [language]
+ * @property {string} [task]
+ * @property {number} [tailPaddings]
+ */
+
+/**
+ * Offline FireRed ASR model config
+ * @typedef {Object} OfflineFireRedAsrModelConfig
+ * @property {string} [encoder]
+ * @property {string} [decoder]
+ */
+
+/**
+ * Offline Moonshine model config
+ * @typedef {Object} OfflineMoonshineModelConfig
+ * @property {string} [preprocessor]
+ * @property {string} [encoder]
+ * @property {string} [uncachedDecoder]
+ * @property {string} [cachedDecoder]
+ */
+
+/**
+ * Offline TDNN model config
+ * @typedef {Object} OfflineTdnnModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Offline SenseVoice model config
+ * @typedef {Object} OfflineSenseVoiceModelConfig
+ * @property {string} [model]
+ * @property {string} [language]
+ * @property {number} [useInverseTextNormalization]
+ */
+
+/**
+ * Offline Cohere Transcribe model config
+ * @typedef {Object} OfflineCohereTranscribeModelConfig
+ * @property {string} [encoder]
+ * @property {string} [decoder]
+ * @property {string} [language]
+ * @property {number} [usePunct]
+ * @property {number} [useItn]
+ */
+
+/**
+ * Offline model config.
+ * @typedef {Object} OfflineModelConfig
+ * @property {OfflineTransducerModelConfig} [transducer]
+ * @property {OfflineParaformerModelConfig} [paraformer]
+ * @property {OfflineZipformerCtcModelConfig} [zipformerCtc]
+ * @property {OfflineWenetCtcModelConfig} [wenetCtc]
+ * @property {OfflineOmnilingualAsrCtcModelConfig} [omnilingual]
+ * @property {OfflineMedAsrCtcModelConfig} [medasr]
+ * @property {OfflineDolphinModelConfig} [dolphin]
+ * @property {OfflineNeMoCtcModelConfig} [nemoCtc]
+ * @property {OfflineCanaryModelConfig} [canary]
+ * @property {OfflineWhisperModelConfig} [whisper]
+ * @property {OfflineFireRedAsrModelConfig} [fireRedAsr]
+ * @property {OfflineMoonshineModelConfig} [moonshine]
+ * @property {OfflineTdnnModelConfig} [tdnn]
+ * @property {OfflineSenseVoiceModelConfig} [senseVoice]
+ * @property {OfflineCohereTranscribeModelConfig} [cohereTranscribe]
+ * @property {string} [tokens]
+ * @property {number} [numThreads]
+ * @property {boolean|number} [debug]
+ * @property {string} [provider]
+ */
+
+/**
+ * Transducer model config
+ * @typedef {Object} TransducerModelConfig
+ * @property {string} [encoder]
+ * @property {string} [decoder]
+ * @property {string} [joiner]
+ */
+
+/**
+ * Paraformer model config
+ * @typedef {Object} ParaformerModelConfig
+ * @property {string} [encoder]
+ * @property {string} [decoder]
+ */
+
+/**
+ * Zipformer2 CTC model config
+ * @typedef {Object} Zipformer2CtcModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * NeMo CTC model config
+ * @typedef {Object} NemoCtcModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Tone CTC model config
+ * @typedef {Object} ToneCtcModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Online model config (subset of C++ `OnlineModelConfig`).
+ * @typedef {Object} OnlineModelConfig
+ * @property {TransducerModelConfig} [transducer]
+ * @property {ParaformerModelConfig} [paraformer]
+ * @property {Zipformer2CtcModelConfig} [zipformer2Ctc]
+ * @property {NemoCtcModelConfig} [nemoCtc]
+ * @property {ToneCtcModelConfig} [toneCtc]
+ * @property {string} [tokens]
+ * @property {number} [numThreads]
+ * @property {boolean|number} [debug]
+ * @property {string} [provider]
+ * @property {string} [modelType]
+ * @property {string} [modelingUnit]
+ * @property {string} [bpeVocab]
+ * @property {string} [tokensBuf]
+ * @property {number} [tokensBufSize]
+ */
+
+/**
+ * Homophone replacer configuration used both in online and offline recognizers.
+ * @typedef {Object} HomophoneReplacerConfig
+ * @property {string} [lexicon]
+ * @property {string} [ruleFsts]
+ */
+
+/**
+ * Online recognizer configuration passed to createOnlineRecognizer.
+ * @typedef {Object} OnlineRecognizerConfig
+ * @property {FeatureConfig} [featConfig]
+ * @property {OnlineModelConfig} [modelConfig]
+ * @property {HomophoneReplacerConfig} [hr]
+ * @property {string} [decodingMethod]
+ * @property {number} [maxActivePaths]
+ * @property {boolean|number} [enableEndpoint]
+ * @property {number} [rule1MinTrailingSilence]
+ * @property {number} [rule2MinTrailingSilence]
+ * @property {number} [rule3MinUtteranceLength]
+ * @property {string} [hotwordsFile]
+ * @property {number} [hotwordsScore]
+ * @property {string} [ruleFsts]
+ * @property {string} [ruleFars]
+ * @property {number} [blankPenalty]
+ */
+
+/**
+ * Offline recognizer config passed to createOfflineRecognizer.
+ * @typedef {Object} OfflineRecognizerConfig
+ * @property {FeatureConfig} [featConfig]
+ * @property {OfflineModelConfig} [modelConfig]
+ */
+
+/**
+ * Wave object returned by readWave and used by writeWave.
+ * @typedef {Object} WaveObject
+ * @property {Float32Array} samples - 1-D float32 samples in [-1, 1].
+ * @property {number} sampleRate - Sample rate as an integer (e.g., 16000).
+ * @see src/wave-reader.cc
+ */
+
+/**
+ * Speech segment returned by Vad.front().
+ * @typedef {Object} SpeechSegment
+ * @property {number} start - Start index (int32) of this segment.
+ * @property {Float32Array} samples - Float32Array of samples.
+ * @see src/vad.cc
+ */
+
+/**
+ * Audio returned by TTS and speech denoiser.
+ * @typedef {Object} GeneratedAudio
+ * @property {Float32Array} samples - The generated/denoised audio samples.
+ * @property {number} sampleRate - Sample rate in Hz.
+ * @see src/non-streaming-tts.cc
+ * @see src/non-streaming-speech-denoiser.cc
+ */
+
+/**
+ * @typedef {Object} GenerationConfig
+ * @property {number=} silenceScale
+ * @property {number=} speed
+ * @property {number=} sid
+ * @property {number=} numSteps
+ *
+ * @property {Float32Array=} referenceAudio
+ * @property {number=} referenceSampleRate
+ * @property {string=} referenceText
+ *
+ * @property {{[key: string]: number | string}} [extra]
+ */
+
+
+/**
+ * TTS request object passed to generate/generateAsync.
+ * @typedef {Object} TtsRequest
+ * @property {string} text - Input text to synthesize.
+ * @property {number} sid - Speaker id (integer).
+ * @property {number} speed - Playback speed (float).
+ * @property {boolean} [enableExternalBuffer=true] - Whether to use an external
+ *           buffer.
+ * @property {GenerationConfig=} generationConfig - Optional
+ */
+
+/**
+ * Spoken Language ID whisper config
+ * @typedef {Object} SpokenLanguageIdentificationWhisperConfig
+ * @property {string} [encoder]
+ * @property {string} [decoder]
+ * @property {number} [tailPaddings]
+ */
+
+/**
+ * SpokenLanguageIdentification config
+ * @typedef {Object} SpokenLanguageIdentificationConfig
+ * @property {SpokenLanguageIdentificationWhisperConfig} [whisper]
+ * @property {number} [numThreads]
+ * @property {boolean|number} [debug]
+ * @property {string} [provider]
+ */
+
+/**
+ * Speaker embedding extractor config
+ * @typedef {Object} SpeakerEmbeddingExtractorConfig
+ * @property {string} [model]
+ * @property {number} [numThreads]
+ * @property {boolean|number} [debug]
+ * @property {string} [provider]
+ */
+
+/**
+ * Offline punctuation model config
+ * @typedef {Object} OfflinePunctuationModelConfig
+ * @property {string} [ctTransformer]
+ * @property {number} [numThreads]
+ * @property {boolean|number} [debug]
+ * @property {string} [provider]
+ */
+
+/**
+ * Offline punctuation config
+ * @typedef {Object} OfflinePunctuationConfig
+ * @property {OfflinePunctuationModelConfig} [model]
+ */
+
+/**
+ * Online punctuation model config
+ * @typedef {Object} OnlinePunctuationModelConfig
+ * @property {string} [cnnBilstm]
+ * @property {string} [bpeVocab]
+ * @property {number} [numThreads]
+ * @property {boolean|number} [debug]
+ * @property {string} [provider]
+ */
+
+/**
+ * Online punctuation config
+ * @typedef {Object} OnlinePunctuationConfig
+ * @property {OnlinePunctuationModelConfig} [model]
+ */
+
+/**
+ * Generic audio processing request used by denoisers/tts generators.
+ * @typedef {Object} AudioProcessRequest
+ * @property {Float32Array} samples
+ * @property {number} sampleRate
+ * @property {boolean} [enableExternalBuffer]
+ */
+
+/**
+ * Offline TTS model configs
+ * @typedef {Object} OfflineTtsVitsModelConfig
+ * @property {string} [model]
+ * @property {string} [lexicon]
+ * @property {string} [tokens]
+ * @property {string} [dataDir]
+ * @property {number} [noiseScale]
+ * @property {number} [noiseScaleW]
+ * @property {number} [lengthScale]
+ */
+
+/**
+ * @typedef {Object} OfflineTtsMatchaModelConfig
+ * @property {string} [acousticModel]
+ * @property {string} [vocoder]
+ * @property {string} [lexicon]
+ * @property {string} [tokens]
+ * @property {string} [dataDir]
+ * @property {number} [noiseScale]
+ * @property {number} [lengthScale]
+ */
+
+/**
+ * @typedef {Object} OfflineTtsKokoroModelConfig
+ * @property {string} [model]
+ * @property {string} [voices]
+ * @property {string} [tokens]
+ * @property {string} [dataDir]
+ * @property {number} [lengthScale]
+ * @property {string} [lexicon]
+ * @property {string} [lang]
+ */
+
+/**
+ * @typedef {Object} OfflineTtsKittenModelConfig
+ * @property {string} [model]
+ * @property {string} [voices]
+ * @property {string} [tokens]
+ * @property {string} [dataDir]
+ * @property {number} [lengthScale]
+ */
+
+/**
+ * @typedef {Object} OfflineTtsZipvoiceModelConfig
+ * @property {string} [tokens]
+ * @property {string} [encoder]
+ * @property {string} [decoder]
+ * @property {string} [vocoder]
+ * @property {string} [dataDir]
+ * @property {string} [lexicon]
+ * @property {number} [featScale]
+ * @property {number} [tShift]
+ * @property {number} [targetRms]
+ * @property {number} [guidanceScale]
+ */
+
+/**
+ * @typedef {Object} OfflineTtsPocketModelConfig
+ * @property {string} [lmFlow]
+ * @property {string} [lmMain]
+ * @property {string} [encoder]
+ * @property {string} [decoder]
+ * @property {string} [textConditioner]
+ * @property {string} [vocabJson]
+ * @property {string} [tokenScoresJson]
+ * @property {number} [voiceEmbeddingCacheCapacity]
+ */
+
+/**
+ * Offline TTS model config
+ * @typedef {Object} OfflineTtsModelConfig
+ * @property {OfflineTtsVitsModelConfig} [vits]
+ * @property {OfflineTtsMatchaModelConfig} [matcha]
+ * @property {OfflineTtsKokoroModelConfig} [kokoro]
+ * @property {OfflineTtsKittenModelConfig} [kitten]
+ * @property {OfflineTtsZipvoiceModelConfig} [zipvoice]
+ * @property {OfflineTtsPocketModelConfig} [pocket]
+ */
+
+/**
+ * Offline TTS configuration (partial, commonly used props).
+ * @typedef {Object} OfflineTtsConfig
+ * @property {OfflineTtsModelConfig} [model]
+ * @property {number} [maxNumSentences]
+ * @property {number} [silenceScale]
+ * @property {number} [numThreads]
+ * @property {string} [provider]
+ */
+
+/**
+ * Offline Speech Denoiser model config
+ * @typedef {Object} OfflineSpeechDenoiserGtcrnModelConfig
+ * @property {string} [model]
+ */
+
+/**
+ * Offline Speech Denoiser model config
+ * @typedef {Object} OfflineSpeechDenoiserDpdfNetModelConfig
+ * @property {string} [model]
+ * @property {number} [attenuationLimitDb] Offline attenuation limit in dB.
+ */
+
+/**
+ * Offline Speech Denoiser model config
+ * @typedef {Object} OfflineSpeechDenoiserModelConfig
+ * @property {OfflineSpeechDenoiserGtcrnModelConfig} [gtcrn]
+ * @property {OfflineSpeechDenoiserDpdfNetModelConfig} [dpdfnet]
+ * @property {number} [numThreads]
+ * @property {boolean|number} [debug]
+ * @property {string} [provider]
+ */
+
+/**
+ * Offline Speech Denoiser configuration (partial).
+ * @typedef {Object} OfflineSpeechDenoiserConfig
+ * @property {OfflineSpeechDenoiserModelConfig} [model]
+ */
+
+/**
+ * Online Speech Denoiser configuration (partial).
+ * @typedef {Object} OnlineSpeechDenoiserConfig
+ * @property {OfflineSpeechDenoiserModelConfig} [model]
+ */
+
+/**
+ * Offline speaker segmentation (pyannote) model config
+ * @typedef {Object} OfflineSpeakerSegmentationPyannoteModelConfig
+ * @property {string} [model]
+ * @property {number} [windowShiftRatio=0.1]
+ */
+
+/**
+ * Offline speaker segmentation model config
+ * @typedef {Object} OfflineSpeakerSegmentationModelConfig
+ * @property {OfflineSpeakerSegmentationPyannoteModelConfig} [pyannote]
+ * @property {number} [numThreads]
+ * @property {boolean|number} [debug]
+ * @property {string} [provider]
+ */
+
+/**
+ * Offline Speaker Diarization configuration (partial).
+ * @typedef {Object} OfflineSpeakerDiarizationConfig
+ * @property {OfflineSpeakerSegmentationModelConfig} [segmentation]
+ * @property {SpeakerEmbeddingExtractorConfig} [embedding]
+ * @property {FastClusteringConfig} [clustering]
+ * @property {number} [minDurationOn]
+ * @property {number} [minDurationOff]
+ */
+
+/**
+ * Fast clustering configuration used by diarization.
+ * @typedef {Object} FastClusteringConfig
+ * @property {number} [numClusters]
+ * @property {number} [threshold]
+ */
+
+/**
+ * SpeakerEmbeddingManager add-multi flattened object
+ * @typedef {Object} SpeakerEmbeddingManagerAddListFlattenedObj
+ * @property {string} name
+ * @property {Float32Array} vv
+ * @property {number} n
+ */
+
+/**
+ * SpeakerEmbeddingManager search object
+ * @typedef {Object} SpeakerEmbeddingManagerSearchObj
+ * @property {Float32Array} v
+ * @property {number} threshold
+ */
+
+/**
+ * SpeakerEmbeddingManager verify object
+ * @typedef {Object} SpeakerEmbeddingManagerVerifyObj
+ * @property {string} name
+ * @property {Float32Array} v
+ * @property {number} threshold
+ */
+
+/**
+ * KeywordSpotter config (partial)
+ * @typedef {Object} KeywordSpotterConfig
+ * @property {FeatureConfig} [featConfig]
+ * @property {OfflineModelConfig} [modelConfig]
+ * @property {number} [maxActivePaths]
+ * @property {number} [numTrailingBlanks]
+ * @property {number} [keywordsScore]
+ * @property {number} [keywordsThreshold]
+ * @property {string} [keywordsFile]
+ */
+
+/**
+ * Offline recognition result returned by `getOfflineStreamResultAsJson`.
+ * See `OfflineRecognitionResult::AsJsonString()` in C++ for precise fields.
+ * @typedef {Object} OfflineRecognizerResult
+ * @property {string} lang
+ * @property {string} emotion
+ * @property {string} event
+ * @property {string} text
+ * @property {number[]} timestamps
+ * @property {number[]} durations
+ * @property {string[]} tokens
+ * @property {number[]} ys_log_probs
+ * @property {number[]} words
+ */
+
+/**
+ * Online recognition result returned by `getOnlineStreamResultAsJson`.
+ * See `OnlineRecognizerResult::AsJsonString()` in C++.
+ * @typedef {Object} OnlineRecognizerResult
+ * @property {string} text
+ * @property {string[]} tokens
+ * @property {number[]} timestamps
+ * @property {number[]} ys_probs
+ * @property {number[]} lm_probs
+ * @property {number[]} context_scores
+ * @property {number} segment
+ * @property {number[]} words
+ * @property {number} start_time
+ * @property {boolean} is_final
+ * @property {boolean} is_eof
+ */
+
+/**
+ * Keyword spotter result returned by `getKeywordResultAsJson`.
+ * @typedef {Object} KeywordResult
+ * @property {number} start_time
+ * @property {string} keyword
+ * @property {number[]} timestamps
+ * @property {string[]} tokens
+ */
+
+/**
+ * Speaker diarization segment returned by `offlineSpeakerDiarizationProcess`.
+ * @typedef {Object} SpeakerDiarizationSegment
+ * @property {number} start - start time in seconds
+ * @property {number} end - end time in seconds
+ * @property {number} speaker - speaker id (integer)
+ */
+
+/**
+ * Speaker embedding entry used by SpeakerEmbeddingManager.add
+ * @typedef {Object} SpeakerEmbeddingEntry
+ * @property {string} name - speaker name
+ * @property {Float32Array} v - embedding vector
+ */
+
+/**
+ * @typedef {Object} OfflineStreamObject
+ * @property {OfflineStreamHandle} handle
+ */
+
+/**
+ * @typedef {Object} OnlineStreamObject
+ * @property {OnlineStreamHandle} handle
+ */
+
+/**
+ * @typedef {Object} DisplayObject
+ * @property {DisplayHandle} handle
+ */
+
+// Export typedefs so they can be referenced by require('./types.js')
+module.exports = {};

+ 134 - 0
dsh-desktop/app/plugins/sherpa-onnx-node/vad.js

@@ -0,0 +1,134 @@
+/** @typedef {import('./types').CircularBufferHandle} CircularBufferHandle */
+/** @typedef {import('./types').VoiceActivityDetectorHandle} VoiceActivityDetectorHandle */
+/** @typedef {import('./types').SpeechSegment} SpeechSegment */
+/** @typedef {import('./types').VadConfig} VadConfig */
+
+const addon = require('./addon.js');
+
+/**
+ * CircularBuffer stores float32 samples internally.
+ */
+class CircularBuffer {
+  /**
+   * @param {number} capacity - capacity in samples (integer)
+   */
+  constructor(capacity) {
+    this.handle = addon.createCircularBuffer(capacity);
+  }
+
+  /**
+   * Push samples into the buffer.
+   * @param {Float32Array} samples
+   */
+  push(samples) {
+    addon.circularBufferPush(this.handle, samples);
+  }
+
+  /**
+   * Get a slice of samples.
+   * @param {number} startIndex
+   * @param {number} n
+   * @param {boolean} [enableExternalBuffer=true]
+   * @returns {Float32Array}
+   */
+  get(startIndex, n, enableExternalBuffer = true) {
+    return addon.circularBufferGet(
+        this.handle, startIndex, n, enableExternalBuffer);
+  }
+
+  /**
+   * Pop n samples from the buffer.
+   * @param {number} n
+   */
+  pop(n) {
+    return addon.circularBufferPop(this.handle, n);
+  }
+
+  /**
+   * Get current size in samples.
+   * @returns {number}
+   */
+  size() {
+    return addon.circularBufferSize(this.handle);
+  }
+
+  /**
+   * Get head index.
+   * @returns {number}
+   */
+  head() {
+    return addon.circularBufferHead(this.handle);
+  }
+
+  /** Reset the buffer. */
+  reset() {
+    addon.circularBufferReset(this.handle);
+  }
+}
+
+/**
+ * Voice Activity Detector (VAD).
+ */
+class Vad {
+  /**
+   * @param {VadConfig} config
+   * @param {number} bufferSizeInSeconds
+   */
+  constructor(config, bufferSizeInSeconds) {
+    this.handle =
+        addon.createVoiceActivityDetector(config, bufferSizeInSeconds);
+    this.config = config;
+  }
+
+  /**
+   * Accept raw waveform samples.
+   * @param {Float32Array} samples
+   */
+  acceptWaveform(samples) {
+    addon.voiceActivityDetectorAcceptWaveform(this.handle, samples);
+  }
+
+  /** @returns {boolean} */
+  isEmpty() {
+    return addon.voiceActivityDetectorIsEmpty(this.handle);
+  }
+
+  /** @returns {boolean} */
+  isDetected() {
+    return addon.voiceActivityDetectorIsDetected(this.handle);
+  }
+
+  /** Pop the earliest detected speech segment. */
+  pop() {
+    addon.voiceActivityDetectorPop(this.handle);
+  }
+
+  /** Clear internal state. */
+  clear() {
+    addon.voiceActivityDetectorClear(this.handle);
+  }
+
+  /**
+   * Get the front speech segment.
+   * @param {boolean} [enableExternalBuffer=true]
+   * @returns {SpeechSegment}
+   */
+  front(enableExternalBuffer = true) {
+    return addon.voiceActivityDetectorFront(this.handle, enableExternalBuffer);
+  }
+
+  /** Reset detector state. */
+  reset() {
+    addon.voiceActivityDetectorReset(this.handle);
+  }
+
+  /** Flush pending internal buffer. */
+  flush() {
+    addon.voiceActivityDetectorFlush(this.handle);
+  }
+}
+
+module.exports = {
+  Vad,
+  CircularBuffer,
+}

+ 20 - 0
dsh-desktop/app/plugins/ws/LICENSE

@@ -0,0 +1,20 @@
+Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
+Copyright (c) 2013 Arnout Kazemier and contributors
+Copyright (c) 2016 Luigi Pinca and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 548 - 0
dsh-desktop/app/plugins/ws/README.md

@@ -0,0 +1,548 @@
+# ws: a Node.js WebSocket library
+
+[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws)
+[![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=master&label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
+[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws)
+
+ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and
+server implementation.
+
+Passes the quite extensive Autobahn test suite: [server][server-report],
+[client][client-report].
+
+**Note**: This module does not work in the browser. The client in the docs is a
+reference to a backend with the role of a client in the WebSocket communication.
+Browser clients must use the native
+[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
+object. To make the same code work seamlessly on Node.js and the browser, you
+can use one of the many wrappers available on npm, like
+[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws).
+
+## Table of Contents
+
+- [Protocol support](#protocol-support)
+- [Installing](#installing)
+  - [Opt-in for performance](#opt-in-for-performance)
+    - [Legacy opt-in for performance](#legacy-opt-in-for-performance)
+- [API docs](#api-docs)
+- [WebSocket compression](#websocket-compression)
+- [Usage examples](#usage-examples)
+  - [Sending and receiving text data](#sending-and-receiving-text-data)
+  - [Sending binary data](#sending-binary-data)
+  - [Simple server](#simple-server)
+  - [External HTTP/S server](#external-https-server)
+  - [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server)
+  - [Client authentication](#client-authentication)
+  - [Server broadcast](#server-broadcast)
+  - [Round-trip time](#round-trip-time)
+  - [Use the Node.js streams API](#use-the-nodejs-streams-api)
+  - [Other examples](#other-examples)
+- [FAQ](#faq)
+  - [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client)
+  - [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections)
+  - [How to connect via a proxy?](#how-to-connect-via-a-proxy)
+- [Changelog](#changelog)
+- [License](#license)
+
+## Protocol support
+
+- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`)
+- **HyBi drafts 13-17** (Current default, alternatively option
+  `protocolVersion: 13`)
+
+## Installing
+
+```
+npm install ws
+```
+
+### Opt-in for performance
+
+[bufferutil][] is an optional module that can be installed alongside the ws
+module:
+
+```
+npm install --save-optional bufferutil
+```
+
+This is a binary addon that improves the performance of certain operations such
+as masking and unmasking the data payload of the WebSocket frames. Prebuilt
+binaries are available for the most popular platforms, so you don't necessarily
+need to have a C++ compiler installed on your machine.
+
+To force ws to not use bufferutil, use the
+[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This
+can be useful to enhance security in systems where a user can put a package in
+the package search path of an application of another user, due to how the
+Node.js resolver algorithm works.
+
+#### Legacy opt-in for performance
+
+If you are running on an old version of Node.js (prior to v18.14.0), ws also
+supports the [utf-8-validate][] module:
+
+```
+npm install --save-optional utf-8-validate
+```
+
+This contains a binary polyfill for [`buffer.isUtf8()`][].
+
+To force ws not to use utf-8-validate, use the
+[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable.
+
+## API docs
+
+See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and
+utility functions.
+
+## WebSocket compression
+
+ws supports the [permessage-deflate extension][permessage-deflate] which enables
+the client and server to negotiate a compression algorithm and its parameters,
+and then selectively apply it to the data payloads of each WebSocket message.
+
+The extension is disabled by default on the server and enabled by default on the
+client. It adds a significant overhead in terms of performance and memory
+consumption so we suggest to enable it only if it is really needed.
+
+Note that Node.js has a variety of issues with high-performance compression,
+where increased concurrency, especially on Linux, can lead to [catastrophic
+memory fragmentation][node-zlib-bug] and slow performance. If you intend to use
+permessage-deflate in production, it is worthwhile to set up a test
+representative of your workload and ensure Node.js/zlib will handle it with
+acceptable performance and memory usage.
+
+Tuning of permessage-deflate can be done via the options defined below. You can
+also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly
+into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs].
+
+See [the docs][ws-server-options] for more options.
+
+```js
+import WebSocket, { WebSocketServer } from 'ws';
+
+const wss = new WebSocketServer({
+  port: 8080,
+  perMessageDeflate: {
+    zlibDeflateOptions: {
+      // See zlib defaults.
+      chunkSize: 1024,
+      memLevel: 7,
+      level: 3
+    },
+    zlibInflateOptions: {
+      chunkSize: 10 * 1024
+    },
+    // Other options settable:
+    clientNoContextTakeover: true, // Defaults to negotiated value.
+    serverNoContextTakeover: true, // Defaults to negotiated value.
+    serverMaxWindowBits: 10, // Defaults to negotiated value.
+    // Below options specified as default values.
+    concurrencyLimit: 10, // Limits zlib concurrency for perf.
+    threshold: 1024 // Size (in bytes) below which messages
+    // should not be compressed if context takeover is disabled.
+  }
+});
+```
+
+The client will only use the extension if it is supported and enabled on the
+server. To always disable the extension on the client, set the
+`perMessageDeflate` option to `false`.
+
+```js
+import WebSocket from 'ws';
+
+const ws = new WebSocket('ws://www.host.com/path', {
+  perMessageDeflate: false
+});
+```
+
+## Usage examples
+
+### Sending and receiving text data
+
+```js
+import WebSocket from 'ws';
+
+const ws = new WebSocket('ws://www.host.com/path');
+
+ws.on('error', console.error);
+
+ws.on('open', function open() {
+  ws.send('something');
+});
+
+ws.on('message', function message(data) {
+  console.log('received: %s', data);
+});
+```
+
+### Sending binary data
+
+```js
+import WebSocket from 'ws';
+
+const ws = new WebSocket('ws://www.host.com/path');
+
+ws.on('error', console.error);
+
+ws.on('open', function open() {
+  const array = new Float32Array(5);
+
+  for (var i = 0; i < array.length; ++i) {
+    array[i] = i / 2;
+  }
+
+  ws.send(array);
+});
+```
+
+### Simple server
+
+```js
+import { WebSocketServer } from 'ws';
+
+const wss = new WebSocketServer({ port: 8080 });
+
+wss.on('connection', function connection(ws) {
+  ws.on('error', console.error);
+
+  ws.on('message', function message(data) {
+    console.log('received: %s', data);
+  });
+
+  ws.send('something');
+});
+```
+
+### External HTTP/S server
+
+```js
+import { createServer } from 'https';
+import { readFileSync } from 'fs';
+import { WebSocketServer } from 'ws';
+
+const server = createServer({
+  cert: readFileSync('/path/to/cert.pem'),
+  key: readFileSync('/path/to/key.pem')
+});
+const wss = new WebSocketServer({ server });
+
+wss.on('connection', function connection(ws) {
+  ws.on('error', console.error);
+
+  ws.on('message', function message(data) {
+    console.log('received: %s', data);
+  });
+
+  ws.send('something');
+});
+
+server.listen(8080);
+```
+
+### Multiple servers sharing a single HTTP/S server
+
+```js
+import { createServer } from 'http';
+import { WebSocketServer } from 'ws';
+
+const server = createServer();
+const wss1 = new WebSocketServer({ noServer: true });
+const wss2 = new WebSocketServer({ noServer: true });
+
+wss1.on('connection', function connection(ws) {
+  ws.on('error', console.error);
+
+  // ...
+});
+
+wss2.on('connection', function connection(ws) {
+  ws.on('error', console.error);
+
+  // ...
+});
+
+server.on('upgrade', function upgrade(request, socket, head) {
+  const { pathname } = new URL(request.url, 'wss://base.url');
+
+  if (pathname === '/foo') {
+    wss1.handleUpgrade(request, socket, head, function done(ws) {
+      wss1.emit('connection', ws, request);
+    });
+  } else if (pathname === '/bar') {
+    wss2.handleUpgrade(request, socket, head, function done(ws) {
+      wss2.emit('connection', ws, request);
+    });
+  } else {
+    socket.destroy();
+  }
+});
+
+server.listen(8080);
+```
+
+### Client authentication
+
+```js
+import { createServer } from 'http';
+import { WebSocketServer } from 'ws';
+
+function onSocketError(err) {
+  console.error(err);
+}
+
+const server = createServer();
+const wss = new WebSocketServer({ noServer: true });
+
+wss.on('connection', function connection(ws, request, client) {
+  ws.on('error', console.error);
+
+  ws.on('message', function message(data) {
+    console.log(`Received message ${data} from user ${client}`);
+  });
+});
+
+server.on('upgrade', function upgrade(request, socket, head) {
+  socket.on('error', onSocketError);
+
+  // This function is not defined on purpose. Implement it with your own logic.
+  authenticate(request, function next(err, client) {
+    if (err || !client) {
+      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+      socket.destroy();
+      return;
+    }
+
+    socket.removeListener('error', onSocketError);
+
+    wss.handleUpgrade(request, socket, head, function done(ws) {
+      wss.emit('connection', ws, request, client);
+    });
+  });
+});
+
+server.listen(8080);
+```
+
+Also see the provided [example][session-parse-example] using `express-session`.
+
+### Server broadcast
+
+A client WebSocket broadcasting to all connected WebSocket clients, including
+itself.
+
+```js
+import WebSocket, { WebSocketServer } from 'ws';
+
+const wss = new WebSocketServer({ port: 8080 });
+
+wss.on('connection', function connection(ws) {
+  ws.on('error', console.error);
+
+  ws.on('message', function message(data, isBinary) {
+    wss.clients.forEach(function each(client) {
+      if (client.readyState === WebSocket.OPEN) {
+        client.send(data, { binary: isBinary });
+      }
+    });
+  });
+});
+```
+
+A client WebSocket broadcasting to every other connected WebSocket clients,
+excluding itself.
+
+```js
+import WebSocket, { WebSocketServer } from 'ws';
+
+const wss = new WebSocketServer({ port: 8080 });
+
+wss.on('connection', function connection(ws) {
+  ws.on('error', console.error);
+
+  ws.on('message', function message(data, isBinary) {
+    wss.clients.forEach(function each(client) {
+      if (client !== ws && client.readyState === WebSocket.OPEN) {
+        client.send(data, { binary: isBinary });
+      }
+    });
+  });
+});
+```
+
+### Round-trip time
+
+```js
+import WebSocket from 'ws';
+
+const ws = new WebSocket('wss://websocket-echo.com/');
+
+ws.on('error', console.error);
+
+ws.on('open', function open() {
+  console.log('connected');
+  ws.send(Date.now());
+});
+
+ws.on('close', function close() {
+  console.log('disconnected');
+});
+
+ws.on('message', function message(data) {
+  console.log(`Round-trip time: ${Date.now() - data} ms`);
+
+  setTimeout(function timeout() {
+    ws.send(Date.now());
+  }, 500);
+});
+```
+
+### Use the Node.js streams API
+
+```js
+import WebSocket, { createWebSocketStream } from 'ws';
+
+const ws = new WebSocket('wss://websocket-echo.com/');
+
+const duplex = createWebSocketStream(ws, { encoding: 'utf8' });
+
+duplex.on('error', console.error);
+
+duplex.pipe(process.stdout);
+process.stdin.pipe(duplex);
+```
+
+### Other examples
+
+For a full example with a browser client communicating with a ws server, see the
+examples folder.
+
+Otherwise, see the test cases.
+
+## FAQ
+
+### How to get the IP address of the client?
+
+The remote IP address can be obtained from the raw socket.
+
+```js
+import { WebSocketServer } from 'ws';
+
+const wss = new WebSocketServer({ port: 8080 });
+
+wss.on('connection', function connection(ws, req) {
+  const ip = req.socket.remoteAddress;
+
+  ws.on('error', console.error);
+});
+```
+
+When the server runs behind a proxy like NGINX, the de-facto standard is to use
+the `X-Forwarded-For` header.
+
+```js
+wss.on('connection', function connection(ws, req) {
+  const ip = req.headers['x-forwarded-for'].split(',')[0].trim();
+
+  ws.on('error', console.error);
+});
+```
+
+### How to detect and close broken connections?
+
+Sometimes, the link between the server and the client can be interrupted in a
+way that keeps both the server and the client unaware of the broken state of the
+connection (e.g. when pulling the cord).
+
+In these cases, ping messages can be used as a means to verify that the remote
+endpoint is still responsive.
+
+```js
+import { WebSocketServer } from 'ws';
+
+function heartbeat() {
+  this.isAlive = true;
+}
+
+const wss = new WebSocketServer({ port: 8080 });
+
+wss.on('connection', function connection(ws) {
+  ws.isAlive = true;
+  ws.on('error', console.error);
+  ws.on('pong', heartbeat);
+});
+
+const interval = setInterval(function ping() {
+  wss.clients.forEach(function each(ws) {
+    if (ws.isAlive === false) return ws.terminate();
+
+    ws.isAlive = false;
+    ws.ping();
+  });
+}, 30000);
+
+wss.on('close', function close() {
+  clearInterval(interval);
+});
+```
+
+Pong messages are automatically sent in response to ping messages as required by
+the spec.
+
+Just like the server example above, your clients might as well lose connection
+without knowing it. You might want to add a ping listener on your clients to
+prevent that. A simple implementation would be:
+
+```js
+import WebSocket from 'ws';
+
+function heartbeat() {
+  clearTimeout(this.pingTimeout);
+
+  // Use `WebSocket#terminate()`, which immediately destroys the connection,
+  // instead of `WebSocket#close()`, which waits for the close timer.
+  // Delay should be equal to the interval at which your server
+  // sends out pings plus a conservative assumption of the latency.
+  this.pingTimeout = setTimeout(() => {
+    this.terminate();
+  }, 30000 + 1000);
+}
+
+const client = new WebSocket('wss://websocket-echo.com/');
+
+client.on('error', console.error);
+client.on('open', heartbeat);
+client.on('ping', heartbeat);
+client.on('close', function clear() {
+  clearTimeout(this.pingTimeout);
+});
+```
+
+### How to connect via a proxy?
+
+Use a custom `http.Agent` implementation like [https-proxy-agent][] or
+[socks-proxy-agent][].
+
+## Changelog
+
+We're using the GitHub [releases][changelog] for changelog entries.
+
+## License
+
+[MIT](LICENSE)
+
+[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input
+[bufferutil]: https://github.com/websockets/bufferutil
+[changelog]: https://github.com/websockets/ws/releases
+[client-report]: http://websockets.github.io/ws/autobahn/clients/
+[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
+[node-zlib-bug]: https://github.com/nodejs/node/issues/8871
+[node-zlib-deflaterawdocs]:
+  https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options
+[permessage-deflate]: https://tools.ietf.org/html/rfc7692
+[server-report]: http://websockets.github.io/ws/autobahn/servers/
+[session-parse-example]: ./examples/express-session-parse
+[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
+[utf-8-validate]: https://github.com/websockets/utf-8-validate
+[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback

+ 8 - 0
dsh-desktop/app/plugins/ws/browser.js

@@ -0,0 +1,8 @@
+'use strict';
+
+module.exports = function () {
+  throw new Error(
+    'ws does not work in the browser. Browser clients must use the native ' +
+      'WebSocket object'
+  );
+};

+ 22 - 0
dsh-desktop/app/plugins/ws/index.js

@@ -0,0 +1,22 @@
+'use strict';
+
+const createWebSocketStream = require('./lib/stream');
+const extension = require('./lib/extension');
+const PerMessageDeflate = require('./lib/permessage-deflate');
+const Receiver = require('./lib/receiver');
+const Sender = require('./lib/sender');
+const subprotocol = require('./lib/subprotocol');
+const WebSocket = require('./lib/websocket');
+const WebSocketServer = require('./lib/websocket-server');
+
+WebSocket.createWebSocketStream = createWebSocketStream;
+WebSocket.extension = extension;
+WebSocket.PerMessageDeflate = PerMessageDeflate;
+WebSocket.Receiver = Receiver;
+WebSocket.Sender = Sender;
+WebSocket.Server = WebSocketServer;
+WebSocket.subprotocol = subprotocol;
+WebSocket.WebSocket = WebSocket;
+WebSocket.WebSocketServer = WebSocketServer;
+
+module.exports = WebSocket;

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff