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 +17 -2
- package/README.zh.md +17 -2
- package/carrier/dist-renderer/assets/{index-C61Q_dyJ.js → index-BMH7auTh.js} +1 -1
- package/carrier/dist-renderer/index.html +1 -1
- package/carrier/lib/main.js +48 -26
- package/carrier/lib/preload.js +3 -1
- package/carrier/lib/tts.js +212 -0
- package/client/pet-settings.js +145 -10
- package/lib/index.js +126 -27
- package/lib/{service-Vj-lLpIv.js → service-BjTawwUn.js} +574 -86
- package/lib/tools.js +2 -2
- package/lib/types/index.d.ts +2 -2
- package/lib/types/inner-voice.d.ts +14 -0
- package/lib/types/petdata.d.ts +59 -0
- package/lib/types/registry.d.ts +2 -1
- package/package.json +3 -3
- package/presets/pet-inner-voice.patch.yml +3 -3
- package/presets/pet.patch.yml +3 -3
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
|
|
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
|
|
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.
|
|
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-
|
|
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>
|
package/carrier/lib/main.js
CHANGED
|
@@ -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 /
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
return undefined;
|
|
305
|
-
} // publisher gone
|
|
312
|
+
return record.url;
|
|
306
313
|
}
|
|
307
|
-
|
|
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 `/
|
|
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
|
-
|
|
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 /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/carrier/lib/preload.js
CHANGED
|
@@ -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
|
|
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
|