dsh-agentic-pet 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
9
9
 
10
10
  ## Summary
11
11
 
12
- `dsh-agentic-pet` is a desktop-pet plugin for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) ecosystem: the pet lives on your desktop as a transparent always-on-top window, follows your agent sessions (idle / thinking / working / error), races the web GUI to answer approvals and user questions (first answer wins), collects a per-pet touch-affection ledger, and exposes expressive tools (`pet_say`, `pet_do`, `pet_digest`, `pet_interactions`, `pet_state`) so the model can speak and act through the pet. Multiple pet implementations can run side by side — a directory registry (`~/.dsh/pet-http.d/`) discovers every live pet and routes state, interactions, and ledgers per pet.
12
+ `dsh-agentic-pet` is a desktop-pet plugin for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) ecosystem: the pet lives on your desktop as a transparent always-on-top window, follows your agent sessions (idle / thinking / working / error), races the web GUI to answer approvals and user questions (first answer wins), collects a per-pet touch-affection ledger, and exposes expressive tools (`pet_say`, `pet_do`, `pet_digest`, `pet_interactions`, `pet_state`) so the model can speak and act through the pet. Spoken lines can be read aloud through [BreezeBlue](https://breezeblue.ai) TTS — the carrier synthesizes each `/say` text and delivers bubble and voice as one event; synthesis failure or timeout degrades to visual-only (configure it in the desktop settings page). Multiple pet implementations can run side by side — a directory registry (`~/.dsh/pet-http.d/`) discovers every live pet and routes state, interactions, and ledgers per pet.
13
13
 
14
14
  The pet itself is a dumb display with zero dsh knowledge. Everything that understands the runtime — event convergence, interaction waterfalls, multi-pet routing, the model tools — lives in the plugin's driver, and the pet only implements a small local HTTP control interface. Any pet implementation that speaks the same HTTP contract can be adopted without changing this plugin.
15
15
 
@@ -40,13 +40,28 @@ The TypeScript build still runs inside the dsh monorepo workspace (the bundled `
40
40
 
41
41
  1. In the monorepo: `pnpm run build` (plugin `lib/`) and `pnpm --filter @deepseek-ai/dsh-pet run build` (carrier `lib/` + `dist-renderer/`).
42
42
  2. Here: `scripts\sync-from-monorepo.cmd` — mirrors sources and build outputs into this repo.
43
- 3. Commit here, then `npm publish` (assets package first; see [dsh-pet-assets/PUBLISH.md](../dsh-pet-assets/PUBLISH.md) for the release manual).
43
+ 3. Commit here, then publish from here (see [Releasing](#releasing); the assets package goes first, manual in [dsh-pet-assets/PUBLISH.md](../dsh-pet-assets/PUBLISH.md)).
44
44
 
45
45
  Tests: `npx vitest run tests/registry-service.spec.ts` runs self-contained (needs only `cordis` and `vitest`); the full suite including `tests/pet-app.spec.ts` runs in the monorepo workspace.
46
46
 
47
+ ## Releasing
48
+
49
+ `package.json`'s `version` is owned by this repo: `scripts\sync-from-monorepo.cmd` mirrors every other manifest field and keeps it. The monorepo copy carries the workspace version (`0.2.1-rc.3`), so a plain mirror copy used to rewrite the published number — which is why npm `0.4.0` had no commit and no tag carrying it.
50
+
51
+ ```bash
52
+ node scripts\release.mjs 0.4.1 --dry-run # preflight and pack listing, nothing published
53
+ node scripts\release.mjs 0.4.1 # publish, commit "chore(release): 0.4.1", tag v0.4.1
54
+ git push --follow-tags
55
+ ```
56
+
57
+ The script refuses to publish unless the working tree is clean, the version exceeds the current one, tag `v<version>` is absent, the build artifacts are present, and `lib/` carries exactly one bundle chunk (`tsdown` runs with `clean:false`, so a stale `service-*.js` would ship). A failed publish restores `package.json`, so the tree never claims a version that did not ship. A prerelease version (`x.y.z-rc.N`) publishes under `--tag rc`.
58
+
59
+ Every published version is reachable as tag `v<version>` — the tag, not the manifest, is what answers "what is on npm". Version in the tree therefore always equals the last *published* version.
60
+
47
61
  ## Documentation
48
62
 
49
63
  - Control-plane contract (the frozen consumer API every pet implementation speaks): see the multi-pet guide in the project documentation.
64
+ - Say-side TTS (BreezeBlue voice-over) design notes: `docs/tts.md` in the monorepo workspace (`packages/bundle/pet-app`).
50
65
  - Known limitations and design notes live in the repo wiki and the project's engineering docs.
51
66
 
52
67
 
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@ kind: "package-bundle"
9
9
 
10
10
  ## 简介
11
11
 
12
- `dsh-agentic-pet` 是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 生态的桌面宠物插件:宠物以透明置顶窗常驻桌面,跟随 agent 会话状态(idle / thinking / working / error),与 web 界面竞速应答审批与提问(先答者赢),维护每宠独立的触摸好感账本,并向模型暴露表达工具(`pet_say`、`pet_do`、`pet_digest`、`pet_interactions`、`pet_state`)——模型可以借宠物说话、做动作、感知被触摸。多只宠物实现可以并存:目录注册表(`~/.dsh/pet-http.d/`)发现全部在线宠物,状态、交互、账本按宠路由。
12
+ `dsh-agentic-pet` 是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 生态的桌面宠物插件:宠物以透明置顶窗常驻桌面,跟随 agent 会话状态(idle / thinking / working / error),与 web 界面竞速应答审批与提问(先答者赢),维护每宠独立的触摸好感账本,并向模型暴露表达工具(`pet_say`、`pet_do`、`pet_digest`、`pet_interactions`、`pet_state`)——模型可以借宠物说话、做动作、感知被触摸。说话内容可通过 [BreezeBlue](https://breezeblue.ai) TTS 朗读——carrier 侧合成每条 `/say` 文本,语音就绪后与气泡同时下发;合成失败或超时静默降级为只显示(桌面设置页可配)。多只宠物实现可以并存:目录注册表(`~/.dsh/pet-http.d/`)发现全部在线宠物,状态、交互、账本按宠路由。
13
13
 
14
14
  宠物本身是零 DSH 知识的纯显示端。理解运行时的一切——事件收敛、交互瀑布、多宠路由、模型工具——都在插件的 driver 里,宠物只实现一个小型本地 HTTP 控制接口。任何讲同一 HTTP 契约的宠物实现都能被采纳,插件零改动。
15
15
 
@@ -40,13 +40,28 @@ TypeScript 构建仍在 dsh monorepo workspace 内进行(被打包的 `@deepse
40
40
 
41
41
  1. monorepo 内:`pnpm run build`(插件 `lib/`)与 `pnpm --filter @deepseek-ai/dsh-pet run build`(载体 `lib/` + `dist-renderer/`)。
42
42
  2. 本仓库:`scripts\sync-from-monorepo.cmd`——把源码与构建产物镜像进本仓库。
43
- 3. 本仓库提交,然后 `npm publish`(先素材包后插件包;发布手册见 [dsh-pet-assets/PUBLISH.md](../dsh-pet-assets/PUBLISH.md))。
43
+ 3. 本仓库提交,然后在本仓库发布(见下文「发布」;先素材包后插件包,手册见 [dsh-pet-assets/PUBLISH.md](../dsh-pet-assets/PUBLISH.md))。
44
44
 
45
45
  测试:`npx vitest run tests/registry-service.spec.ts` 自持可跑(只需 `cordis` 与 `vitest`);含 `tests/pet-app.spec.ts` 的全量测试在 monorepo workspace 内跑。
46
46
 
47
+ ## 发布
48
+
49
+ `package.json` 的 `version` 归本仓库所有:`scripts\sync-from-monorepo.cmd` 镜像其余所有字段、只保留它。monorepo 那份带的是 workspace 版本(`0.2.1-rc.3`),早先的整文件覆盖会把已发布的版本号改掉——npm 上的 `0.4.0` 因此没有对应的 commit 与 tag。
50
+
51
+ ```bash
52
+ node scripts\release.mjs 0.4.1 --dry-run # 预检 + 打包清单,不发布
53
+ node scripts\release.mjs 0.4.1 # 发布,提交 "chore(release): 0.4.1",打 tag v0.4.1
54
+ git push --follow-tags
55
+ ```
56
+
57
+ 以下任一条件不满足,脚本拒绝发布:工作树干净、新版本号大于当前值、tag `v<版本>` 不存在、构建产物齐备、`lib/` 里只有一个 bundle chunk(`tsdown` 用 `clean:false`,残留的旧 `service-*.js` 会被打进包)。发布失败会自动还原 `package.json`,工作树不会停留在一个并未发布的版本号上。预发布版本(`x.y.z-rc.N`)以 `--tag rc` 发布。
58
+
59
+ 每个已发布版本都能用 tag `v<版本>` 定位——回答"npm 上是什么"的是 tag,不是清单。因此仓库里的版本号恒等于**最后已发布**的版本。
60
+
47
61
  ## 文档
48
62
 
49
63
  - 控制面契约(所有宠物实现共同讲述的冻结消费方 API)见项目文档的多宠指南。
64
+ - 说话朗读(BreezeBlue TTS)设计笔记见 monorepo 工作区的 `packages/bundle/pet-app/docs/tts.md`。
50
65
  - 已知边界与设计笔记见仓库 wiki 与项目工程文档。
51
66
 
52
67
 
@@ -1 +1 @@
1
- (function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=`whale`,t=globalThis,n={idle:[{dir:`idle`,file:`待机呼吸休闲`}],thinking:[{dir:`random`,file:`深度思考碎碎念`}],working:[{dir:`random`,file:`工作状态-忙碌点按`},{dir:`random`,file:`写代码`},{dir:`random`,file:`原地敲击桌面互动`}],waiting:[{dir:`turn`,file:`东张西望`}],error:[{dir:`random`,file:`被吓一跳`}],celebrate:[{dir:`click`,file:`点击回应-开心跃动`}],touch:[{dir:`click`,file:`点击回应-开心跃动`},{dir:`click`,file:`点击回应-元气挥手`},{dir:`click`,file:`点击回应-害羞惊讶`},{dir:`click`,file:`点击回应-傲娇生气`},{dir:`click`,file:`点击回应-挠痒咯咯笑`}]},r=new Set([`深度思考碎碎念`,`工作状态-忙碌点按`,`写代码`,`原地敲击桌面互动`,`被吓一跳`]);function i(e){let t=document.querySelector(e);if(t===null)throw Error(`pet renderer: missing element ${e}`);return t}var a=i(`#video`);a.addEventListener(`error`,()=>{console.error(`[pet-video] source failed: ${a.src} ${String(a.error?.message??``)}`)});var o=i(`#stage`),s=i(`#bubble`);function c(t){let r=n[t];if(r===void 0)throw Error(`pet renderer: unknown animation ${t}`);let i=r[Math.floor(Math.random()*r.length)]??r[0];return`pet-asset:///${e}/videos/${i.dir}/${encodeURIComponent(i.file)}.webm`}function l(e,t){let n=t.getContext(`webgl`,{alpha:!0,premultipliedAlpha:!1});if(n===null){t.replaceWith(e),e.style.display=`block`;return}let r=(e,t)=>{let r=n.createShader(e);if(r===null)throw Error(`pet renderer: shader allocation failed`);if(n.shaderSource(r,t),n.compileShader(r),!n.getShaderParameter(r,n.COMPILE_STATUS))throw Error(`pet renderer: shader compile failed: ${String(n.getShaderInfoLog(r))}`);return r},i=n.createProgram();if(i===null)throw Error(`pet renderer: program allocation failed`);if(n.attachShader(i,r(n.VERTEX_SHADER,`attribute vec2 p; varying vec2 v;void main(){ v = vec2(p.x * 0.5 + 0.5, 0.5 - p.y * 0.5); gl_Position = vec4(p, 0.0, 1.0); }`)),n.attachShader(i,r(n.FRAGMENT_SHADER,`precision highp float; varying vec2 v; uniform sampler2D t;void main(){ gl_FragColor = texture2D(t, v); }`)),n.linkProgram(i),!n.getProgramParameter(i,n.LINK_STATUS))throw Error(`pet renderer: program link failed: ${String(n.getProgramInfoLog(i))}`);n.useProgram(i);let a=n.createBuffer();n.bindBuffer(n.ARRAY_BUFFER,a),n.bufferData(n.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),n.STATIC_DRAW);let o=n.getAttribLocation(i,`p`);n.enableVertexAttribArray(o),n.vertexAttribPointer(o,2,n.FLOAT,!1,0,0);let s=n.createTexture();n.bindTexture(n.TEXTURE_2D,s),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_WRAP_S,n.CLAMP_TO_EDGE),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_WRAP_T,n.CLAMP_TO_EDGE),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_MIN_FILTER,n.LINEAR),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_MAG_FILTER,n.LINEAR),n.enable(n.BLEND),n.blendFunc(n.SRC_ALPHA,n.ONE_MINUS_SRC_ALPHA),n.clearColor(0,0,0,0);let c=()=>{e.readyState<2||e.videoWidth===0||((t.width!==e.videoWidth||t.height!==e.videoHeight)&&(t.width=e.videoWidth,t.height=e.videoHeight),n.viewport(0,0,t.width,t.height),n.texImage2D(n.TEXTURE_2D,0,n.RGBA,n.RGBA,n.UNSIGNED_BYTE,e),n.clear(n.COLOR_BUFFER_BIT),n.drawArrays(n.TRIANGLE_STRIP,0,4))},l=e,u=()=>{typeof l.requestVideoFrameCallback==`function`?l.requestVideoFrameCallback(()=>{c(),u()}):requestAnimationFrame(()=>{c(),u()})};u()}var u=`idle`,d=null,f=0;function p(e){let t=c(e);a.src!==t&&(a.src=t,a.loop=e!==`celebrate`,a.play().catch(()=>{}))}function m(){p(Date.now()<f?`error`:d===null?u===`working`?`working`:u:`waiting`),T(),U()}function h(e){if(u===e)return;let t=u===`working`&&e===`idle`;if(u=e,e!==`idle`&&x!==void 0&&(clearTimeout(x),x=void 0),t){g(`celebrate`);return}m()}function g(e,t){_(n[e]?.[0]??{dir:`click`,file:e},t)}function _(n,r){let i=`pet-asset:///${e}/videos/${n.dir}/${encodeURIComponent(n.file)}.webm`;if(a.src===i)return;C=!0;let o=()=>{a.onended=null,a.onerror=null,C=!1,t.petDesktop?.reportActionDone({action:n.file,durationMs:Number.isFinite(a.duration)?Math.round(a.duration*1e3):void 0,...r===void 0?{}:{requestId:r}}),m()};a.onended=o,a.onerror=o,a.loop=!1,a.src=i,a.play().catch(()=>{})}function v(){f=Date.now()+6e3,m(),setTimeout(m,6100)}var y=new Map;t.petDesktop?.onCatalog?.(e=>{let t=e.actions;if(Array.isArray(t)){y.clear();for(let e of t){let t=e;typeof t.id==`string`&&typeof t.dir==`string`&&typeof t.file==`string`&&y.set(t.id,{dir:t.dir,file:t.file})}T()}});var b={enabled:!1,gapMinutes:10},x,S,C=!1;function w(){return[...y.values()].filter(e=>e.dir===`random`&&!r.has(e.file)&&e.file!==S)}function T(){x===void 0&&(!b.enabled||u!==`idle`||d!==null||Date.now()<f||w().length!==0&&(x=setTimeout(()=>{if(x=void 0,!b.enabled||u!==`idle`||d!==null||C||Date.now()<f){T();return}let e=w(),t=e[Math.floor(Math.random()*e.length)];t!==void 0&&(S=t.file,_(t))},Math.max(1,b.gapMinutes)*6e4)))}var E=new Map;function D(){s.hidden=!0,s.replaceChildren(),t.petDesktop?.setBubbleVisible(!1)}function O(e){if(e===void 0||typeof e==`string`)return e;let t=navigator.languages??[navigator.language];for(let n of t){let t=e[n.split(`-`)[0]??``];if(typeof t==`string`)return t}return typeof e.en==`string`?e.en:void 0}function k(e,n){s.replaceChildren();let r=document.createElement(`p`);r.className=`bubble-title`,r.textContent=e.toolName===void 0||e.toolName===``?`请求执行`:`请求执行:${e.toolName}`;let i=document.createElement(`p`);i.className=`bubble-detail`,i.textContent=O(e.displayReason)??e.reason??``;let a=document.createElement(`div`);a.className=`bubble-actions`;let o=document.createElement(`button`);o.className=`bubble-button primary`,o.textContent=`允许`,o.addEventListener(`click`,()=>{D(),n(`allowed-once`)});let c=document.createElement(`button`);c.className=`bubble-button danger`,c.textContent=`拒绝`,c.addEventListener(`click`,()=>{D(),n(`rejected`)}),a.append(o,c),s.append(r,i,a),s.hidden=!1,t.petDesktop?.setBubbleVisible(!0),m()}function A(e,n){E.clear(),s.replaceChildren();for(let t of e){E.set(t.id,[]);let r=document.createElement(`p`);r.className=`bubble-question`;let i=O(t.question)??``,a=O(t.header);r.textContent=a===void 0||a===``?i:`${a} · ${i}`,s.append(r);let o=O(t.detail);if(o!==void 0&&o!==``){let e=document.createElement(`p`);e.className=`bubble-detail`,e.textContent=o,s.append(e)}let c=document.createElement(`div`);c.className=`bubble-options`;for(let r of t.options??[]){let i=O(r.label)??``,a=document.createElement(`button`);a.className=`bubble-button`,a.textContent=i;let o=O(r.description);o!==void 0&&(a.title=o),a.addEventListener(`click`,()=>{E.set(t.id,[i]);for(let e of c.children)e.classList.remove(`primary`);a.classList.add(`primary`),e.every(e=>(E.get(e.id)??[]).length>0)&&n({answers:e.map(e=>({id:e.id,selected:E.get(e.id)??[]}))})}),c.append(a)}s.append(c)}s.hidden=!1,t.petDesktop?.setBubbleVisible(!0),m()}var j=0,M,N=[];function P(){l(a,o),m()}var F=i(`#pet`);function I(e,t){let n=F.getBoundingClientRect(),r=Math.min(n.width/640,n.height/360),i=640*r,a=360*r,o=(e-n.left-(n.width-i)/2)/r,s=(t-n.top-(n.height-a)/2)/r,c=e=>o>=e[0]&&o<=e[2]&&s>=e[1]&&s<=e[3];return c([185,60,436,195])?`head`:c([212,60,428,330])?`body`:`unknown`}var L=5,R=500,z;function B(e,r){let i=I(e,r),a=n.touch;a!==void 0&&a.length>0&&d===null&&Date.now()>=f&&_(a[Math.floor(Math.random()*a.length)]),z!==void 0&&clearTimeout(z),z=setTimeout(()=>{z=void 0,t.petDesktop?.reportTouch({part:i})},260)}F.addEventListener(`pointerdown`,e=>{if(e.button!==0)return;try{F.setPointerCapture(e.pointerId)}catch{}let n=e.screenX,r=e.screenY,i=Date.now();t.petDesktop?.dragStart(n,r);let a=e=>{t.petDesktop?.dragMove(e.screenX,e.screenY)},o=e=>{F.removeEventListener(`pointermove`,a),F.removeEventListener(`pointerup`,o),F.removeEventListener(`pointercancel`,o),t.petDesktop?.dragEnd(),Math.hypot(e.screenX-n,e.screenY-r)<=L&&Date.now()-i<=R&&B(e.clientX,e.clientY)};F.addEventListener(`pointermove`,a),F.addEventListener(`pointerup`,o),F.addEventListener(`pointercancel`,o)});function V(){if(d!==null||Date.now()<f)return;s.replaceChildren();let e=document.createElement(`div`);e.className=`bubble-chat`;let n=document.createElement(`input`);n.className=`bubble-chat-input`,n.maxLength=2e3,n.placeholder=`和鲸鱼娘说点什么…`;let r=document.createElement(`button`);r.className=`bubble-button primary`,r.textContent=`发送`;let i=document.createElement(`button`);i.className=`bubble-button`,i.textContent=`取消`,i.addEventListener(`click`,()=>{D()});let a=async()=>{let e=n.value.trim();if(e===``||r.disabled)return;r.disabled=!0,n.disabled=!0;let i=await t.petDesktop?.sendChat(e);if(i?.ok===!0){D();return}r.disabled=!1,n.disabled=!1,n.placeholder=i?.reason===`busy`?`上一条还没送达,稍等一下…`:`没发出去,再试一次`,n.focus()};r.addEventListener(`click`,()=>{a()}),n.addEventListener(`keydown`,e=>{e.key===`Enter`?(e.preventDefault(),a()):e.key===`Escape`&&D()}),e.append(n,r,i),s.append(e),s.hidden=!1,t.petDesktop?.setBubbleVisible(!0),m(),n.focus()}F.addEventListener(`dblclick`,e=>{e.stopPropagation(),z!==void 0&&(clearTimeout(z),z=void 0),V()}),t.petDesktop?.onSay(e=>{if(d!==null||e.text===``)return;s.replaceChildren();let n=document.createElement(`p`);n.className=`bubble-detail`,n.textContent=e.text,s.append(n),s.hidden=!1,t.petDesktop?.setBubbleVisible(!0),setTimeout(()=>{d===null&&s.textContent===e.text&&D()},4e3+Math.min(e.text.length*60,4e3))});var H=``,U=()=>{let e=JSON.stringify({state:u,override:d,animation:decodeURIComponent(a.src.split(`/`).pop()??``)});e!==H&&(H=e,t.petDesktop?.reportStatus(JSON.parse(e)))};t.petDesktop?.onState(e=>{let t=e,n=t.autonomy;if(typeof n==`object`&&n){let e=n,t=typeof e.idleAnimation==`boolean`?e.idleAnimation:b.enabled,r=typeof e.gapMinutes==`number`&&Number.isFinite(e.gapMinutes)?Math.min(120,Math.max(1,Math.round(e.gapMinutes))):b.gapMinutes;(t!==b.enabled||r!==b.gapMinutes)&&(b={enabled:t,gapMinutes:r},x!==void 0&&(clearTimeout(x),x=void 0),T())}let r=t.state;r===`idle`||r===`thinking`||r===`working`?h(r):r===`error`&&v()}),t.petDesktop?.onAction(e=>{let t=e,n=t.action,r=typeof t.requestId==`string`?t.requestId:void 0;if(n===`celebrate`)g(`celebrate`,r);else if(n===`error`)v();else if(typeof n==`string`){let e=y.get(n);e!==void 0&&_(e,r)}});function W(){let e=s.querySelector(`.bubble-queue`);if(N.length===0){e?.remove();return}let t=`还有 ${String(N.length)} 个待处理`;if(e!==null){e.textContent=t;return}let n=document.createElement(`p`);n.className=`bubble-queue`,n.textContent=t,s.append(n)}function G(e,n){let r=++j;M=e;let i=n=>{if(r!==j)return;D(),d=null,M=void 0,m(),t.petDesktop?.answerInteraction(e,n);let i=N.shift();i!==void 0&&G(i.id,i.message)};n.kind===`question`?(d=`waiting_question`,m(),A(n.questions??[],i)):(d=`waiting_approval`,m(),k({toolName:n.toolName,reason:n.reason,...n.displayReason===void 0?{}:{displayReason:n.displayReason}},i)),W()}t.petDesktop?.onInteraction(e=>{let t=e,n=t.id;if(typeof n==`string`){if(M!==void 0){N.push({id:n,message:t}),W();return}G(n,t)}}),t.petDesktop?.onInteractionCancel(e=>{let t=N.findIndex(t=>t.id===e.id);if(t!==-1){N.splice(t,1),W();return}if(e.id!==M)return;j+=1,M=void 0,D(),d=null,m();let n=N.shift();n!==void 0&&G(n.id,n.message)}),P();
1
+ (function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=`whale`,t=globalThis,n={idle:[{dir:`idle`,file:`待机呼吸休闲`}],thinking:[{dir:`random`,file:`深度思考碎碎念`}],working:[{dir:`random`,file:`工作状态-忙碌点按`},{dir:`random`,file:`写代码`},{dir:`random`,file:`原地敲击桌面互动`}],waiting:[{dir:`turn`,file:`东张西望`}],error:[{dir:`random`,file:`被吓一跳`}],celebrate:[{dir:`click`,file:`点击回应-开心跃动`}],touch:[{dir:`click`,file:`点击回应-开心跃动`},{dir:`click`,file:`点击回应-元气挥手`},{dir:`click`,file:`点击回应-害羞惊讶`},{dir:`click`,file:`点击回应-傲娇生气`},{dir:`click`,file:`点击回应-挠痒咯咯笑`}]},r=new Set([`深度思考碎碎念`,`工作状态-忙碌点按`,`写代码`,`原地敲击桌面互动`,`被吓一跳`]);function i(e){let t=document.querySelector(e);if(t===null)throw Error(`pet renderer: missing element ${e}`);return t}var a=i(`#video`);a.addEventListener(`error`,()=>{console.error(`[pet-video] source failed: ${a.src} ${String(a.error?.message??``)}`)});var o=i(`#stage`),s=i(`#bubble`);function c(t){let r=n[t];if(r===void 0)throw Error(`pet renderer: unknown animation ${t}`);let i=r[Math.floor(Math.random()*r.length)]??r[0];return`pet-asset:///${e}/videos/${i.dir}/${encodeURIComponent(i.file)}.webm`}function l(e,t){let n=t.getContext(`webgl`,{alpha:!0,premultipliedAlpha:!1});if(n===null){t.replaceWith(e),e.style.display=`block`;return}let r=(e,t)=>{let r=n.createShader(e);if(r===null)throw Error(`pet renderer: shader allocation failed`);if(n.shaderSource(r,t),n.compileShader(r),!n.getShaderParameter(r,n.COMPILE_STATUS))throw Error(`pet renderer: shader compile failed: ${String(n.getShaderInfoLog(r))}`);return r},i=n.createProgram();if(i===null)throw Error(`pet renderer: program allocation failed`);if(n.attachShader(i,r(n.VERTEX_SHADER,`attribute vec2 p; varying vec2 v;void main(){ v = vec2(p.x * 0.5 + 0.5, 0.5 - p.y * 0.5); gl_Position = vec4(p, 0.0, 1.0); }`)),n.attachShader(i,r(n.FRAGMENT_SHADER,`precision highp float; varying vec2 v; uniform sampler2D t;void main(){ gl_FragColor = texture2D(t, v); }`)),n.linkProgram(i),!n.getProgramParameter(i,n.LINK_STATUS))throw Error(`pet renderer: program link failed: ${String(n.getProgramInfoLog(i))}`);n.useProgram(i);let a=n.createBuffer();n.bindBuffer(n.ARRAY_BUFFER,a),n.bufferData(n.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),n.STATIC_DRAW);let o=n.getAttribLocation(i,`p`);n.enableVertexAttribArray(o),n.vertexAttribPointer(o,2,n.FLOAT,!1,0,0);let s=n.createTexture();n.bindTexture(n.TEXTURE_2D,s),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_WRAP_S,n.CLAMP_TO_EDGE),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_WRAP_T,n.CLAMP_TO_EDGE),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_MIN_FILTER,n.LINEAR),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_MAG_FILTER,n.LINEAR),n.enable(n.BLEND),n.blendFunc(n.SRC_ALPHA,n.ONE_MINUS_SRC_ALPHA),n.clearColor(0,0,0,0);let c=()=>{e.readyState<2||e.videoWidth===0||((t.width!==e.videoWidth||t.height!==e.videoHeight)&&(t.width=e.videoWidth,t.height=e.videoHeight),n.viewport(0,0,t.width,t.height),n.texImage2D(n.TEXTURE_2D,0,n.RGBA,n.RGBA,n.UNSIGNED_BYTE,e),n.clear(n.COLOR_BUFFER_BIT),n.drawArrays(n.TRIANGLE_STRIP,0,4))},l=e,u=()=>{typeof l.requestVideoFrameCallback==`function`?l.requestVideoFrameCallback(()=>{c(),u()}):requestAnimationFrame(()=>{c(),u()})};u()}var u=`idle`,d=null,f=0;function p(e){let t=c(e);a.src!==t&&(a.src=t,a.loop=e!==`celebrate`,a.play().catch(()=>{}))}function m(){p(Date.now()<f?`error`:d===null?u===`working`?`working`:u:`waiting`),T(),Y()}function h(e){if(u===e)return;let t=u===`working`&&e===`idle`;if(u=e,e!==`idle`&&x!==void 0&&(clearTimeout(x),x=void 0),t){g(`celebrate`);return}m()}function g(e,t){_(n[e]?.[0]??{dir:`click`,file:e},t)}function _(n,r){let i=`pet-asset:///${e}/videos/${n.dir}/${encodeURIComponent(n.file)}.webm`;if(a.src===i)return;C=!0;let o=()=>{a.onended=null,a.onerror=null,C=!1,t.petDesktop?.reportActionDone({action:n.file,durationMs:Number.isFinite(a.duration)?Math.round(a.duration*1e3):void 0,...r===void 0?{}:{requestId:r}}),m()};a.onended=o,a.onerror=o,a.loop=!1,a.src=i,a.play().catch(()=>{})}function v(){f=Date.now()+6e3,m(),setTimeout(m,6100)}var y=new Map;t.petDesktop?.onCatalog?.(e=>{let t=e.actions;if(Array.isArray(t)){y.clear();for(let e of t){let t=e;typeof t.id==`string`&&typeof t.dir==`string`&&typeof t.file==`string`&&y.set(t.id,{dir:t.dir,file:t.file})}T()}});var b={enabled:!1,gapMinutes:10},x,S,C=!1;function w(){return[...y.values()].filter(e=>e.dir===`random`&&!r.has(e.file)&&e.file!==S)}function T(){x===void 0&&(!b.enabled||u!==`idle`||d!==null||Date.now()<f||w().length!==0&&(x=setTimeout(()=>{if(x=void 0,!b.enabled||u!==`idle`||d!==null||C||Date.now()<f){T();return}let e=w(),t=e[Math.floor(Math.random()*e.length)];t!==void 0&&(S=t.file,_(t))},Math.max(1,b.gapMinutes)*6e4)))}var E=new Map;function D(){s.hidden=!0,s.replaceChildren(),t.petDesktop?.setBubbleVisible(!1)}function O(e){if(e===void 0||typeof e==`string`)return e;let t=navigator.languages??[navigator.language];for(let n of t){let t=e[n.split(`-`)[0]??``];if(typeof t==`string`)return t}return typeof e.en==`string`?e.en:void 0}function k(e,n){s.replaceChildren();let r=document.createElement(`p`);r.className=`bubble-title`,r.textContent=e.toolName===void 0||e.toolName===``?`请求执行`:`请求执行:${e.toolName}`;let i=document.createElement(`p`);i.className=`bubble-detail`,i.textContent=O(e.displayReason)??e.reason??``;let a=document.createElement(`div`);a.className=`bubble-actions`;let o=document.createElement(`button`);o.className=`bubble-button primary`,o.textContent=`允许`,o.addEventListener(`click`,()=>{D(),n(`allowed-once`)});let c=document.createElement(`button`);c.className=`bubble-button danger`,c.textContent=`拒绝`,c.addEventListener(`click`,()=>{D(),n(`rejected`)}),a.append(o,c),s.append(r,i,a),s.hidden=!1,t.petDesktop?.setBubbleVisible(!0),m()}function A(e,n){E.clear(),s.replaceChildren();for(let t of e){E.set(t.id,[]);let r=document.createElement(`p`);r.className=`bubble-question`;let i=O(t.question)??``,a=O(t.header);r.textContent=a===void 0||a===``?i:`${a} · ${i}`,s.append(r);let o=O(t.detail);if(o!==void 0&&o!==``){let e=document.createElement(`p`);e.className=`bubble-detail`,e.textContent=o,s.append(e)}let c=document.createElement(`div`);c.className=`bubble-options`;for(let r of t.options??[]){let i=O(r.label)??``,a=document.createElement(`button`);a.className=`bubble-button`,a.textContent=i;let o=O(r.description);o!==void 0&&(a.title=o),a.addEventListener(`click`,()=>{E.set(t.id,[i]);for(let e of c.children)e.classList.remove(`primary`);a.classList.add(`primary`),e.every(e=>(E.get(e.id)??[]).length>0)&&n({answers:e.map(e=>({id:e.id,selected:E.get(e.id)??[]}))})}),c.append(a)}s.append(c)}s.hidden=!1,t.petDesktop?.setBubbleVisible(!0),m()}var j=0,M,N=[];function P(){l(a,o),m()}var F=i(`#pet`);function I(e,t){let n=F.getBoundingClientRect(),r=Math.min(n.width/640,n.height/360),i=640*r,a=360*r,o=(e-n.left-(n.width-i)/2)/r,s=(t-n.top-(n.height-a)/2)/r,c=e=>o>=e[0]&&o<=e[2]&&s>=e[1]&&s<=e[3];return c([185,60,436,195])?`head`:c([212,60,428,330])?`body`:`unknown`}var L=5,R=500,z;function B(e,r){let i=I(e,r),a=n.touch;a!==void 0&&a.length>0&&d===null&&Date.now()>=f&&_(a[Math.floor(Math.random()*a.length)]),z!==void 0&&clearTimeout(z),z=setTimeout(()=>{z=void 0,t.petDesktop?.reportTouch({part:i})},260)}F.addEventListener(`pointerdown`,e=>{if(e.button!==0)return;try{F.setPointerCapture(e.pointerId)}catch{}let n=e.screenX,r=e.screenY,i=Date.now();t.petDesktop?.dragStart(n,r);let a=e=>{t.petDesktop?.dragMove(e.screenX,e.screenY)},o=e=>{F.removeEventListener(`pointermove`,a),F.removeEventListener(`pointerup`,o),F.removeEventListener(`pointercancel`,o),t.petDesktop?.dragEnd(),Math.hypot(e.screenX-n,e.screenY-r)<=L&&Date.now()-i<=R&&B(e.clientX,e.clientY)};F.addEventListener(`pointermove`,a),F.addEventListener(`pointerup`,o),F.addEventListener(`pointercancel`,o)});function V(){if(d!==null||Date.now()<f)return;K(),s.replaceChildren();let e=document.createElement(`div`);e.className=`bubble-chat`;let n=document.createElement(`input`);n.className=`bubble-chat-input`,n.maxLength=2e3,n.placeholder=`和鲸鱼娘说点什么…`;let r=document.createElement(`button`);r.className=`bubble-button primary`,r.textContent=`发送`;let i=document.createElement(`button`);i.className=`bubble-button`,i.textContent=`取消`,i.addEventListener(`click`,()=>{D()});let a=async()=>{let e=n.value.trim();if(e===``||r.disabled)return;r.disabled=!0,n.disabled=!0;let i=await t.petDesktop?.sendChat(e);if(i?.ok===!0){D();return}r.disabled=!1,n.disabled=!1,n.placeholder=i?.reason===`busy`?`上一条还没送达,稍等一下…`:`没发出去,再试一次`,n.focus()};r.addEventListener(`click`,()=>{a()}),n.addEventListener(`keydown`,e=>{e.key===`Enter`?(e.preventDefault(),a()):e.key===`Escape`&&D()}),e.append(n,r,i),s.append(e),s.hidden=!1,t.petDesktop?.setBubbleVisible(!0),m(),n.focus()}F.addEventListener(`dblclick`,e=>{e.stopPropagation(),z!==void 0&&(clearTimeout(z),z=void 0),V()});var H;function U(e,t){H!==void 0&&clearTimeout(H),H=setTimeout(()=>{H=void 0,d===null&&s.textContent===e&&D()},Math.max(1e3,t))}var W,G;function K(){let e=W;e!==void 0&&!e.paused&&e.pause()}function q(e,t){G!==void 0&&URL.revokeObjectURL(G);let n=W??=new Audio;G=URL.createObjectURL(new Blob([t],{type:`audio/mpeg`})),n.src=G,n.onloadedmetadata=()=>{Number.isFinite(n.duration)&&n.duration>0&&U(e,n.duration*1e3+1500)},n.onended=()=>{d===null&&s.textContent===e&&D()},n.onerror=()=>{},n.play().catch(()=>{})}t.petDesktop?.onSay(e=>{if(d!==null||e.text===``)return;K(),s.replaceChildren();let n=document.createElement(`p`);n.className=`bubble-detail`,n.textContent=e.text,s.append(n),s.hidden=!1,t.petDesktop?.setBubbleVisible(!0),U(e.text,4e3+Math.min(e.text.length*60,4e3));let r=e.audio;r!==void 0&&r.length>0&&q(e.text,r)});var J=``,Y=()=>{let e=JSON.stringify({state:u,override:d,animation:decodeURIComponent(a.src.split(`/`).pop()??``)});e!==J&&(J=e,t.petDesktop?.reportStatus(JSON.parse(e)))};t.petDesktop?.onState(e=>{let t=e,n=t.autonomy;if(typeof n==`object`&&n){let e=n,t=typeof e.idleAnimation==`boolean`?e.idleAnimation:b.enabled,r=typeof e.gapMinutes==`number`&&Number.isFinite(e.gapMinutes)?Math.min(120,Math.max(1,Math.round(e.gapMinutes))):b.gapMinutes;(t!==b.enabled||r!==b.gapMinutes)&&(b={enabled:t,gapMinutes:r},x!==void 0&&(clearTimeout(x),x=void 0),T())}let r=t.state;r===`idle`||r===`thinking`||r===`working`?h(r):r===`error`&&v()}),t.petDesktop?.onAction(e=>{let t=e,n=t.action,r=typeof t.requestId==`string`?t.requestId:void 0;if(n===`celebrate`)g(`celebrate`,r);else if(n===`error`)v();else if(typeof n==`string`){let e=y.get(n);e!==void 0&&_(e,r)}});function X(){let e=s.querySelector(`.bubble-queue`);if(N.length===0){e?.remove();return}let t=`还有 ${String(N.length)} 个待处理`;if(e!==null){e.textContent=t;return}let n=document.createElement(`p`);n.className=`bubble-queue`,n.textContent=t,s.append(n)}function Z(e,n){K();let r=++j;M=e;let i=n=>{if(r!==j)return;D(),d=null,M=void 0,m(),t.petDesktop?.answerInteraction(e,n);let i=N.shift();i!==void 0&&Z(i.id,i.message)};n.kind===`question`?(d=`waiting_question`,m(),A(n.questions??[],i)):(d=`waiting_approval`,m(),k({toolName:n.toolName,reason:n.reason,...n.displayReason===void 0?{}:{displayReason:n.displayReason}},i)),X()}t.petDesktop?.onInteraction(e=>{let t=e,n=t.id;if(typeof n==`string`){if(M!==void 0){N.push({id:n,message:t}),X();return}Z(n,t)}}),t.petDesktop?.onInteractionCancel(e=>{let t=N.findIndex(t=>t.id===e.id);if(t!==-1){N.splice(t,1),X();return}if(e.id!==M)return;j+=1,M=void 0,D(),d=null,m();let n=N.shift();n!==void 0&&Z(n.id,n.message)}),P();
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>dsh pet</title>
7
- <script type="module" crossorigin src="./assets/index-C61Q_dyJ.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-BMH7auTh.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="./assets/index-BHG2MI7t.css">
9
9
  </head>
10
10
  <body>
@@ -22,6 +22,7 @@ import { homedir } from 'node:os';
22
22
  import { dirname, join, normalize, resolve, sep } from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { createRequire } from 'node:module';
25
+ import { createSayTts } from "./tts.js";
25
26
  const HERE = dirname(fileURLToPath(import.meta.url));
26
27
  const PET_PROFILE_NAME = 'pet';
27
28
  const ASSET_SCHEME = 'pet-asset';
@@ -110,7 +111,7 @@ process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
110
111
  import { appendFileSync as __afs } from 'node:fs';
111
112
  const __bootLog = (line) => {
112
113
  try {
113
- __afs('C:/Users/beyondguo/.dsh/carrier-boot.log', `${new Date().toISOString()} ${line}\n`);
114
+ __afs('C:/Users/beyondguo/.dsh/whale598/carrier-boot.log', `${new Date().toISOString()} ${line}\n`);
114
115
  }
115
116
  catch { /* ignore */ }
116
117
  };
@@ -287,38 +288,44 @@ function spawnHostChild() {
287
288
  * The pet-surface locator: pet-app bundles inside a running runtime publish
288
289
  * their authenticated root here (the desktop shell keeps its URL in process
289
290
  * memory only). A published URL wins over spawning a dedicated profile, so the
290
- * pet follows whichever live runtime serves /pet/ — the user's desktop when it
291
+ * pet follows whichever live runtime serves /whale598/ — the user's desktop when it
291
292
  * runs, otherwise the last publisher still alive.
292
293
  */
293
294
  async function readLocatorUrl() {
294
- const path = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'pet-surface-url.json');
295
- try {
296
- const record = JSON.parse(await readFile(path, 'utf8'));
297
- if (typeof record.url !== 'string' || record.url === '')
298
- return undefined;
299
- if (typeof record.pid === 'number') {
300
- try {
301
- process.kill(record.pid, 0);
295
+ // whale598/ first (current publisher), then the legacy root copy — the
296
+ // locator moved with the whale598 consolidation (publishLocator writes the
297
+ // new path; old copies answer as fallback).
298
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
299
+ for (const path of [join(home, 'whale598', 'pet-surface-url.json'), join(home, 'pet-surface-url.json')]) {
300
+ try {
301
+ const record = JSON.parse(await readFile(path, 'utf8'));
302
+ if (typeof record.url !== 'string' || record.url === '')
303
+ continue;
304
+ if (typeof record.pid === 'number') {
305
+ try {
306
+ process.kill(record.pid, 0);
307
+ }
308
+ catch {
309
+ return undefined;
310
+ } // publisher gone
302
311
  }
303
- catch {
304
- return undefined;
305
- } // publisher gone
312
+ return record.url;
306
313
  }
307
- return record.url;
308
- }
309
- catch {
310
- return undefined; // absent or unreadable: no live publisher
314
+ catch { /* try the next location */ }
311
315
  }
316
+ return undefined;
312
317
  }
313
318
  /**
314
- * The pet surface URL for an authenticated web root: path `/pet/` with the
319
+ * The pet surface URL for an authenticated web root: path `/whale598/` with the
315
320
  * token **dropped** — authorizeIndex mints the root cookie from the token and
316
321
  * rejects any token-carrying request on a subpath, so the pet page itself is
317
322
  * entered cookie-only after the root mint.
318
323
  */
319
324
  function petSurfaceUrl(authenticated) {
320
325
  const parsed = new URL(authenticated);
321
- parsed.pathname = '/pet/';
326
+ // MUST stay in lockstep with the plugin's PET_PATH_PREFIX (/whale598) —
327
+ // separate builds, so the literal is duplicated here by design.
328
+ parsed.pathname = '/whale598/';
322
329
  parsed.search = '';
323
330
  parsed.hash = '';
324
331
  return parsed.toString();
@@ -439,6 +446,17 @@ async function main() {
439
446
  return;
440
447
  }
441
448
  const window = createPetWindow();
449
+ // Say-side TTS: the /say pipeline's audio companion (see tts.ts). It owns
450
+ // the DELIVERY to the page — bubble alone when synthesis is off, bubble +
451
+ // voice as one message when the mp3 lands in time. Failures never touch
452
+ // the bubble beyond showing it alone.
453
+ const sayTts = createSayTts({
454
+ home: DSH_HOME,
455
+ window,
456
+ deliver: (text, audio) => {
457
+ window.webContents.send('pet:say', audio === undefined ? { text } : { text, audio });
458
+ },
459
+ });
442
460
  window.on('resize', () => {
443
461
  console.log(`[pet] resized: ${JSON.stringify(window.getBounds())}`);
444
462
  });
@@ -465,7 +483,7 @@ async function main() {
465
483
  });
466
484
  // Connection's token exchange is root-path-only (authorizeIndex admits
467
485
  // `/?token=…`, never a subpath): load the authenticated root once to mint
468
- // the browser cookie, then enter /pet/ with it. The root load starts the
486
+ // the browser cookie, then enter /whale598/ with it. The root load starts the
469
487
  // Web GUI; the second navigation aborts it, so the web app is at most a
470
488
  // flash.
471
489
  await window.loadURL(authenticated).catch(() => { });
@@ -881,9 +899,12 @@ async function main() {
881
899
  }
882
900
  if (url.pathname === '/say') {
883
901
  // Fire-and-forget: the driver says something through the pet; the
884
- // bubble self-dismisses and never blocks a turn.
902
+ // bubble self-dismisses and never blocks a turn. The TTS module owns
903
+ // the delivery: immediate bubble when synthesis is off, otherwise
904
+ // bubble + voice as ONE message once the mp3 lands (held ≤ holdMs —
905
+ // text and voice appear together instead of the voice trailing).
885
906
  const text = typeof body.text === 'string' ? body.text.slice(0, 200) : '';
886
- window.webContents.send('pet:say', { text });
907
+ sayTts.deliver(text);
887
908
  send(200, { ok: true });
888
909
  return;
889
910
  }
@@ -979,7 +1000,9 @@ async function main() {
979
1000
  // swap blindness). The legacy single file keeps being written during the
980
1001
  // transition so pre-registry readers still see this carrier.
981
1002
  const manifest = `${JSON.stringify({ port, pid: process.pid, since: Date.now(), pet: 'electron' })}\n`;
982
- const registryDir = join(DSH_HOME, 'pet-http.d');
1003
+ // Namespaced under whale598/ (collision avoidance); the driver scans
1004
+ // the legacy pet-http.d/ as a read fallback for older pet builds.
1005
+ const registryDir = join(DSH_HOME, 'whale598', 'pet-http.d');
983
1006
  try {
984
1007
  mkdirSync(registryDir, { recursive: true });
985
1008
  }
@@ -989,8 +1012,7 @@ async function main() {
989
1012
  const writeManifest = () => {
990
1013
  void writeFile(join(registryDir, 'electron.json'), manifest, 'utf8')
991
1014
  .catch((error) => { console.error('[pet] could not write the registry entry:', String(error)); });
992
- void writeFile(join(DSH_HOME, 'pet-http-electron.json'), manifest, 'utf8')
993
- .catch(() => { });
1015
+ // legacy 根镜像(pet-http-electron.json)停写:读侧双目录扫描已覆盖
994
1016
  };
995
1017
  writeManifest();
996
1018
  // Re-assert on a cadence so a stale publisher cannot shadow a live pet.
@@ -1021,7 +1043,7 @@ async function main() {
1021
1043
  // find a dead port in pet-http.d/ — stale entries were the "旧端点残留"
1022
1044
  // incident class. Best effort: a killed carrier leaves the file, and the
1023
1045
  // readers' pid+probe validation is the backstop.
1024
- for (const file of ['pet-http.d/electron.json', 'pet-http-electron.json']) {
1046
+ for (const file of ['whale598/pet-http.d/electron.json', 'pet-http.d/electron.json', 'pet-http-electron.json']) {
1025
1047
  void rm(join(DSH_HOME, file), { force: true }).catch(() => { });
1026
1048
  }
1027
1049
  if (handle === undefined)
@@ -18,7 +18,9 @@ contextBridge.exposeInMainWorld('petDesktop', {
18
18
  dragStart(screenX, screenY) { ipcRenderer.send('pet:drag-start', screenX, screenY); },
19
19
  dragMove(screenX, screenY) { ipcRenderer.send('pet:drag-move', screenX, screenY); },
20
20
  dragEnd() { ipcRenderer.send('pet:drag-end'); },
21
- /** Control interface (driver's four operations) deliveries from the main process. */
21
+ /** Control interface (driver's four operations) deliveries from the main
22
+ * process. `audio` rides along when say-side TTS is configured and the
23
+ * synthesized mp3 landed in time — the bubble and its voice, one message. */
22
24
  onSay(listener) {
23
25
  ipcRenderer.on('pet:say', (_event, notice) => { listener(notice); });
24
26
  },
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Say-side TTS (BreezeBlue): speech synthesis as the /say bubble's voice.
3
+ * The control plane hands every spoken text here; this module owns the
4
+ * DELIVERY to the page — when synthesis is configured it holds the bubble
5
+ * until the mp3 lands (bounded by `holdMs`, default 3s) and forwards text +
6
+ * audio as ONE message, so the pet's mouth and voice start together. A
7
+ * timeout or failure shows the bubble alone and discards the late audio:
8
+ * the complaint to avoid is a voice trailing seconds behind its text. With
9
+ * synthesis off, delivery is the immediate bubble — yesterday's behavior.
10
+ *
11
+ * Any failure (no key, rate limit, network) degrades to visual-only and
12
+ * never surfaces to the driver: the /say HTTP response keeps its
13
+ * fire-and-forget budget, the delivery race runs entirely out-of-band.
14
+ *
15
+ * Configuration lives in the shared pet-settings.json (`tts` section, written
16
+ * by the plugin's settings page) and is read lazily per utterance with an
17
+ * mtime cache: a saved switch reaches the pet without any push or restart.
18
+ *
19
+ * The wire surface is a bare fetch against the BreezeBlue REST endpoint (no
20
+ * SDK — the carrier carries none): POST /v1/text-to-speech/{voice_id} with
21
+ * the `xi-api-key` header answers the mp3 bytes directly.
22
+ */
23
+ import { statSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ /** The BreezeBlue TTS endpoint (convert, synchronous mp3). */
26
+ const TTS_URL = 'https://api.breeze.blue/v1/text-to-speech';
27
+ /** Bounded wait for one synthesis round trip; the bubble shows regardless. */
28
+ const REQUEST_TIMEOUT_MS = 20_000;
29
+ /** The bubble's default patience for its voice: the mp3 lands earlier →
30
+ * bubble + voice go out together at once (this hold is a TIMEOUT ceiling,
31
+ * never a fixed wait); later → the bubble goes alone and the audio is
32
+ * discarded, so a voice never trails its text. */
33
+ const DEFAULT_HOLD_MS = 5_000;
34
+ /** A 200-char mp3 is at most ~1.5MB; anything larger is not a say track. */
35
+ const MAX_AUDIO_BYTES = 8_000_000;
36
+ /** Repeating the identical text within this window re-shows the bubble but
37
+ * does not re-speak (model double-calls should not double-talk). */
38
+ const DEDUPE_MS = 3_000;
39
+ /** After a 429, stay silent until the (capped) Retry-After lapses. */
40
+ const RATE_LIMIT_CAP_S = 300;
41
+ /** Defensive extraction of the settings section: unknown shapes degrade to
42
+ * "unset" and the pipeline treats the feature as off. */
43
+ function normalizeTts(raw) {
44
+ if (typeof raw !== 'object' || raw === null)
45
+ return {};
46
+ const record = raw;
47
+ const config = {};
48
+ if (typeof record.enabled === 'boolean')
49
+ config.enabled = record.enabled;
50
+ if (typeof record.apiKey === 'string')
51
+ config.apiKey = record.apiKey;
52
+ if (typeof record.voiceId === 'string')
53
+ config.voiceId = record.voiceId;
54
+ if (typeof record.modelId === 'string' && record.modelId !== '')
55
+ config.modelId = record.modelId;
56
+ if (typeof record.languageCode === 'string' && record.languageCode !== '')
57
+ config.languageCode = record.languageCode;
58
+ if (typeof record.speed === 'number' && Number.isFinite(record.speed)) {
59
+ config.speed = Math.min(2, Math.max(0.5, record.speed));
60
+ }
61
+ if (typeof record.volume === 'number' && Number.isFinite(record.volume)) {
62
+ config.volume = Math.min(2, Math.max(0.01, record.volume));
63
+ }
64
+ if (typeof record.holdMs === 'number' && Number.isFinite(record.holdMs)) {
65
+ config.holdMs = Math.min(8000, Math.max(0, Math.round(record.holdMs)));
66
+ }
67
+ return config;
68
+ }
69
+ export function createSayTts(deps) {
70
+ const log = deps.log ?? ((line) => { console.log(`[pet-tts] ${line}`); });
71
+ const home = deps.home;
72
+ // ── config: mtime-cached dual-path read ───────────────────────────────────
73
+ // whale598/ first, legacy root fallback — the newest of whichever exist.
74
+ // One stat pair per utterance; the file is re-read only when an mtime
75
+ // moves, so a settings save lands on the very next say without any push.
76
+ let cache;
77
+ let lastConfigEcho = '';
78
+ const loadConfig = () => {
79
+ let newest;
80
+ for (const path of [join(home, 'whale598', 'pet-settings.json'), join(home, 'pet-settings.json')]) {
81
+ try {
82
+ const stat = statSync(path);
83
+ if (newest === undefined || stat.mtimeMs > newest.mtimeMs)
84
+ newest = { path, mtimeMs: stat.mtimeMs };
85
+ }
86
+ catch { /* absent: try the next path */ }
87
+ }
88
+ if (newest === undefined)
89
+ return {};
90
+ if (cache !== undefined && cache.path === newest.path && cache.mtimeMs === newest.mtimeMs)
91
+ return cache.config;
92
+ try {
93
+ const parsed = JSON.parse(readFileSync(newest.path, 'utf8'));
94
+ const config = normalizeTts(parsed?.tts);
95
+ cache = { ...newest, config };
96
+ // One line per config CHANGE (never the key itself): the observable
97
+ // trace for "why is she silent" without a log per utterance.
98
+ const echo = `config from ${newest.path}: enabled=${String(config.enabled === true)}`
99
+ + ` key=${config.apiKey ? 'set' : 'unset'} voice=${config.voiceId ?? '-'}`
100
+ + ` model=${config.modelId ?? 'auto'} speed=${String(config.speed ?? 1)} volume=${String(config.volume ?? 1)}`
101
+ + ` hold=${String(config.holdMs ?? DEFAULT_HOLD_MS)}ms`;
102
+ if (echo !== lastConfigEcho) {
103
+ lastConfigEcho = echo;
104
+ log(echo);
105
+ }
106
+ return config;
107
+ }
108
+ catch (error) {
109
+ log(`pet-settings.json unreadable, TTS stays off: ${error instanceof Error ? error.message : String(error)}`);
110
+ return {};
111
+ }
112
+ };
113
+ // ── the single voice slot ─────────────────────────────────────────────────
114
+ // One delivery in flight: a newer deliver() abandons the older one (abort
115
+ // the fetch, disarm its hold), so bubble and voice always track the newest
116
+ // say. `settled` makes each delivery one-shot: whoever lands first — audio,
117
+ // error, or the hold timer — delivers and disarms the others.
118
+ let generation = 0;
119
+ let inflight;
120
+ let lastSpoken;
121
+ let mutedUntil = 0;
122
+ const deliver = (text) => {
123
+ generation += 1;
124
+ const gen = generation;
125
+ inflight?.abort();
126
+ inflight = undefined;
127
+ const trimmed = text.trim();
128
+ if (trimmed === '')
129
+ return;
130
+ const config = loadConfig();
131
+ const speakable = config.enabled === true
132
+ && config.apiKey !== undefined && config.apiKey !== ''
133
+ && config.voiceId !== undefined && config.voiceId !== ''
134
+ && Date.now() >= mutedUntil;
135
+ const dup = lastSpoken !== undefined
136
+ && lastSpoken.text === trimmed && Date.now() - lastSpoken.at < DEDUPE_MS;
137
+ if (!speakable || dup) {
138
+ deps.deliver(trimmed);
139
+ return;
140
+ }
141
+ lastSpoken = { text: trimmed, at: Date.now() };
142
+ holdAndDeliver(trimmed, config, gen);
143
+ };
144
+ /** Race the synthesis against the bubble's patience; the first to land
145
+ * delivers (audio or alone) and the loser is discarded, never late. */
146
+ const holdAndDeliver = (text, config, gen) => {
147
+ const abort = new AbortController();
148
+ inflight = abort;
149
+ const holdMs = config.holdMs ?? DEFAULT_HOLD_MS;
150
+ let settled = false;
151
+ const finish = (audio) => {
152
+ if (settled)
153
+ return;
154
+ settled = true;
155
+ clearTimeout(timer);
156
+ if (inflight === abort)
157
+ inflight = undefined;
158
+ if (gen === generation)
159
+ deps.deliver(text, audio);
160
+ };
161
+ const timer = setTimeout(() => finish(), holdMs);
162
+ void synthesize(text, config, abort).then((audio) => { finish(audio); }, (error) => {
163
+ // Superseded (our own abort): the newer delivery already owns the
164
+ // stage — stay silent. A real failure: deliver the bubble NOW
165
+ // instead of making it wait out the hold.
166
+ if (!abort.signal.aborted) {
167
+ log(`say TTS failed: ${error instanceof Error ? error.message : String(error)}`);
168
+ }
169
+ finish();
170
+ });
171
+ };
172
+ const synthesize = (text, config, abort) => {
173
+ const url = new URL(`${TTS_URL}/${encodeURIComponent(config.voiceId ?? '')}`);
174
+ url.searchParams.set('output_format', 'mp3');
175
+ const body = { text };
176
+ if (config.modelId !== undefined)
177
+ body.model_id = config.modelId;
178
+ if (config.languageCode !== undefined)
179
+ body.language_code = config.languageCode;
180
+ const voiceSettings = {};
181
+ if (config.speed !== undefined && config.speed !== 1)
182
+ voiceSettings.speed = config.speed;
183
+ if (config.volume !== undefined && config.volume !== 1)
184
+ voiceSettings.volume = config.volume;
185
+ if (Object.keys(voiceSettings).length > 0)
186
+ body.voice_settings = voiceSettings;
187
+ return fetch(url, {
188
+ method: 'POST',
189
+ headers: { 'xi-api-key': config.apiKey ?? '', 'content-type': 'application/json' },
190
+ body: JSON.stringify(body),
191
+ signal: AbortSignal.any([abort.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
192
+ }).then(async (response) => {
193
+ if (!response.ok) {
194
+ const detail = await response.text().catch(() => '');
195
+ if (response.status === 429) {
196
+ const retryAfter = Number(response.headers.get('retry-after'));
197
+ const seconds = Number.isFinite(retryAfter) ? Math.min(RATE_LIMIT_CAP_S, Math.max(0, retryAfter)) : 60;
198
+ mutedUntil = Date.now() + seconds * 1000;
199
+ }
200
+ throw new Error(`BreezeBlue HTTP ${String(response.status)}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
201
+ }
202
+ const audio = new Uint8Array(await response.arrayBuffer());
203
+ if (audio.length === 0)
204
+ throw new Error('empty audio response');
205
+ if (audio.length > MAX_AUDIO_BYTES)
206
+ throw new Error(`audio too large (${String(audio.length)} bytes)`);
207
+ return audio;
208
+ });
209
+ };
210
+ return { deliver };
211
+ }
212
+ //# sourceMappingURL=tts.js.map