@allmodels/dsh-speech 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/lib/client.js +415 -39
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -156,13 +156,13 @@ After all jobs for a `main` push pass, the Release workflow checks out the exact
156
156
 
157
157
  ## Harness compatibility monitoring
158
158
 
159
- GitHub Actions checks npm once per day for the most recently published `@deepseek-ai/dsh` version. It exits without installing browsers when that version is already tracked in `dsh-compatibility.json`, or when the same version already has an open compatibility issue or update pull request.
159
+ GitHub Actions checks npm once per day for the most recently published stable or `-rc` `@deepseek-ai/dsh` version. Alpha, beta, canary, development, and other prerelease channels are ignored. It exits without installing browsers when the newest eligible version is already tracked in `dsh-compatibility.json`, or when the same version already has an open compatibility issue or update pull request.
160
160
 
161
161
  For a new version, the workflow updates the Harness peer/development dependency pins in an isolated checkout, installs that dependency set, runs the static and unit gates, packs the plugin into a disposable profile, and runs the desktop/mobile Chromium suite. It never receives the live AllModels or DeepSeek credentials.
162
162
 
163
163
  - A failure creates one `dsh-compatibility` issue for that Harness version with a link to the failed run. Later daily checks do not duplicate it.
164
164
  - A pass opens a reviewable pull request updating the tested version, peer dependencies, development dependencies, README, and lockfile. Merging remains manual.
165
- - A manual run can target an explicit version or force a retest. When a forced retest fixes an open compatibility issue, the successful update pull request closes it.
165
+ - A manual run can target an explicit stable or `-rc` version or force a retest. Other prerelease channels are rejected by the same release policy used by the daily scheduler. When a forced retest fixes an open compatibility issue, the successful update pull request closes it.
166
166
 
167
167
  Workflow-created pull requests require the repository’s **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** option. The compatibility workflow requests only the exact `contents`, `pull-requests`, and `issues` write permissions needed by its update job and contains no approval operation.
168
168
 
package/lib/client.js CHANGED
@@ -303,8 +303,10 @@ function writePreferredMicrophone(deviceId) {
303
303
  //#region src/shared.ts
304
304
  const CATALOG_TTL_MS = 300 * 1e3;
305
305
  const AUDIO_FORMAT = "pcm_16000";
306
+ const MAX_TTS_CHARACTERS = 4096;
306
307
  const MAX_SUMMARY_REQUEST_CHARACTERS = 16e3;
307
308
  const MAX_SUMMARY_ANSWER_CHARACTERS = 64e3;
309
+ const SUMMARY_PROMPT_VERSION = 1;
308
310
  const DEFAULT_ENGLISH_TTS = {
309
311
  model: "fish/s2.1-pro",
310
312
  provider: "fish",
@@ -779,7 +781,9 @@ const styles = {
779
781
  summaryWaveform: "dsh-speech-summary-waveform",
780
782
  summaryLabel: "dsh-speech-summary-label",
781
783
  autoplayToggle: "dsh-speech-autoplay-toggle",
784
+ sidebarPlaying: "dsh-speech-sidebar-playing",
782
785
  summaryToggles: "dsh-speech-summary-toggles",
786
+ summaryCache: "dsh-speech-summary-cache",
783
787
  voicePicker: "dsh-speech-voice-picker",
784
788
  voiceSelected: "dsh-speech-voice-selected",
785
789
  voiceMenu: "dsh-speech-voice-menu",
@@ -801,15 +805,18 @@ const STYLE_TEXT = String.raw`
801
805
  .dsh-speech-primary,.dsh-speech-secondary,.dsh-speech-checkout{border:1px solid transparent;border-radius:8px;padding:8px 12px;font:inherit;font-size:13px;font-weight:600;cursor:pointer;text-decoration:none;text-align:center}.dsh-speech-primary{background:#e8e8e8;color:#151515}.dsh-speech-secondary,.dsh-speech-checkout{border-color:var(--color-border,#4a4a4a);background:transparent;color:inherit}.dsh-speech-primary:disabled,.dsh-speech-secondary:disabled{cursor:default;opacity:.45}.dsh-speech-good{color:#65c88a}.dsh-speech-warning{margin:0;color:#efb85c}.dsh-speech-danger,.dsh-speech-dock-error{margin:0;color:#f07878}
802
806
  .dsh-speech-top-up{justify-content:flex-start;align-items:end;flex-wrap:wrap}.dsh-speech-top-up .dsh-speech-field{max-width:180px}
803
807
  .dsh-speech-summary-toggles{display:flex;align-items:center;justify-content:flex-end;gap:18px;flex-wrap:wrap}.dsh-speech-switch-label{position:relative;display:flex;align-items:center;gap:8px;color:var(--color-text-secondary,#aaa);font-size:12px;cursor:pointer;white-space:nowrap}.dsh-speech-switch-label input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}.dsh-speech-switch-track{width:32px;height:18px;display:inline-flex;align-items:center;flex:none;box-sizing:border-box;padding:2px;border:1px solid var(--color-border,#555);border-radius:999px;background:var(--color-background,#242424);transition:background .15s ease,border-color .15s ease}.dsh-speech-switch-track i{width:12px;height:12px;display:block;border-radius:50%;background:var(--color-text-secondary,#aaa);transform:translateX(0);transition:transform .15s ease,background .15s ease}.dsh-speech-switch-label input:checked+.dsh-speech-switch-track,.dsh-speech-autoplay-toggle[aria-checked="true"] .dsh-speech-switch-track{border-color:var(--dsw-alias-brand-primary,#68a8ff);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#68a8ff) 32%,transparent)}.dsh-speech-switch-label input:checked+.dsh-speech-switch-track i,.dsh-speech-autoplay-toggle[aria-checked="true"] .dsh-speech-switch-track i{background:#fff;transform:translateX(14px)}.dsh-speech-switch-label:focus-within .dsh-speech-switch-track{outline:2px solid color-mix(in srgb,#68a8ff 60%,transparent);outline-offset:2px}.dsh-speech-switch-label:has(input:disabled){cursor:default;opacity:.5}
808
+ .dsh-speech-summary-cache{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:12px;border-top:1px solid var(--color-border,#343434)}.dsh-speech-summary-cache>div{display:grid;gap:4px}.dsh-speech-summary-cache>div>strong{font-size:13px}.dsh-speech-summary-cache>.dsh-speech-secondary{flex:none}
804
809
  .dsh-speech-summary-player{width:min(397px,100%);height:34px;display:flex;align-items:center;gap:9px;margin-left:-2px;color:var(--color-text-secondary,#9b9b9b)}.dsh-speech-summary-control{width:194px;height:34px;display:flex;align-items:center;gap:5px;box-sizing:border-box;padding:4px 12px 4px 7px;border:1px solid var(--color-border,#343434);border-radius:999px;background:var(--color-background-secondary,rgba(255,255,255,.025))}.dsh-speech-summary-button{width:24px;height:24px;display:inline-grid;place-items:center;flex:none;padding:0;border:0;border-radius:50%;background:transparent;color:inherit;cursor:pointer}.dsh-speech-summary-button:hover:not(:disabled),.dsh-speech-summary-button:focus-visible{outline:0;background:var(--color-background-hover,rgba(255,255,255,.08));color:var(--color-text,#eee)}.dsh-speech-summary-button:disabled{cursor:wait;opacity:.6}.dsh-speech-summary-button svg{width:15px;height:15px;fill:currentColor}.dsh-speech-summary-waveform{width:144px;height:22px;display:flex;align-items:center;gap:1px;flex:none;overflow:hidden}.dsh-speech-summary-waveform i{width:2px;height:calc(3px + var(--dsh-speech-peak)*16px);display:block;flex:none;border-radius:2px;background:currentColor;opacity:.28;transition:height 45ms linear,opacity .12s ease,color .12s ease}.dsh-speech-summary-waveform[data-playing="true"] i{color:var(--dsw-alias-brand-primary,#68a8ff);opacity:.82}.dsh-speech-summary-waveform[data-preparing="true"] i{animation:dsh-speech-wave-pulse 1s ease-in-out infinite}.dsh-speech-summary-waveform[data-preparing="true"] i:nth-child(4n+2){animation-delay:.12s}.dsh-speech-summary-waveform[data-preparing="true"] i:nth-child(4n+3){animation-delay:.24s}.dsh-speech-summary-waveform[data-preparing="true"] i:nth-child(4n){animation-delay:.36s}.dsh-speech-summary-label{min-width:0;max-width:190px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}.dsh-speech-autoplay-toggle{height:28px;display:inline-flex;align-items:center;gap:7px;padding:0 8px;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--color-text-secondary,#aaa);font:inherit;font-size:11px;cursor:pointer}.dsh-speech-autoplay-toggle .dsh-speech-switch-track{width:28px;height:16px}.dsh-speech-autoplay-toggle .dsh-speech-switch-track i{width:10px;height:10px}.dsh-speech-autoplay-toggle[aria-checked="true"] .dsh-speech-switch-track i{transform:translateX(12px)}.dsh-speech-autoplay-toggle:hover:not(:disabled),.dsh-speech-autoplay-toggle:focus-visible{outline:0;border-color:var(--color-border,#444);background:var(--color-background-hover,rgba(255,255,255,.06));color:var(--color-text,#eee)}.dsh-speech-autoplay-toggle:disabled{opacity:.45;cursor:default}@keyframes dsh-speech-wave-pulse{0%,100%{opacity:.2;transform:scaleY(.55)}50%{opacity:.78;transform:scaleY(1)}}
805
810
  .dsh-speech-mic-wrap{position:relative;display:inline-flex;align-items:center;gap:5px;order:1}[data-slot="conversation.input.right"]:has(>.dsh-speech-mic-wrap)~button:last-child{order:2}.dsh-speech-mic{width:30px;height:30px;display:inline-grid;place-items:center;padding:0;border:0;border-radius:7px;background:transparent;color:var(--color-text-secondary,#a8a8a8);cursor:pointer}.dsh-speech-mic:hover:not(:disabled),.dsh-speech-mic:focus-visible{background:var(--color-background-hover,rgba(255,255,255,.08));color:var(--color-text,#eee)}.dsh-speech-mic[aria-disabled="true"]{opacity:.48}.dsh-speech-mic:disabled{cursor:default}.dsh-speech-mic svg{width:18px;height:18px;fill:currentColor}.dsh-speech-mic-active{color:#ef6666;background:rgba(239,102,102,.12)}.dsh-speech-mic-tooltip{position:absolute;right:0;bottom:calc(100% + 8px);z-index:40;width:max-content;max-width:260px;padding:6px 9px;border:1px solid var(--color-border,#343434);border-radius:7px;background:var(--color-background-elevated,#202020);color:var(--color-text,#eee);font-size:12px;line-height:1.35;box-shadow:0 6px 18px rgba(0,0,0,.24);opacity:0;visibility:hidden;transform:translateY(2px);pointer-events:none;transition:opacity .12s ease,transform .12s ease,visibility .12s}.dsh-speech-mic-wrap:hover .dsh-speech-mic-tooltip,.dsh-speech-mic-wrap:focus-within .dsh-speech-mic-tooltip,.dsh-speech-mic-wrap[data-explanation-open="true"] .dsh-speech-mic-tooltip{opacity:1;visibility:visible;transform:translateY(0)}
806
811
  :where(div):has(>:where(div)>[data-slot="conversation.input.right"]>.dsh-speech-recording-takeover){position:relative}:where(div):has(>:where(div)>[data-slot="conversation.input.right"]>.dsh-speech-recording-takeover)>*{visibility:hidden}.dsh-speech-recording-takeover{position:absolute;inset:0;z-index:8;visibility:visible;display:flex;align-items:center;gap:10px;padding:4px 8px;box-sizing:border-box;color:var(--color-text-secondary,#a8a8a8)}.dsh-speech-recording-track{height:30px;min-width:0;flex:1;display:flex;align-items:center}.dsh-speech-recording-canvas{display:block;width:100%;height:30px;color:var(--color-text-secondary,#a8a8a8)}.dsh-speech-recording-cancel,.dsh-speech-recording-stop{flex:none;border-radius:999px}.dsh-speech-recording-cancel{color:var(--color-text-secondary,#a8a8a8);background:transparent}.dsh-speech-recording-cancel:hover:not(:disabled),.dsh-speech-recording-cancel:focus-visible{background:var(--color-background-hover,rgba(255,255,255,.08));color:var(--color-text,#eee)}.dsh-speech-recording-actions{display:flex;align-items:center;gap:6px;flex:none}.dsh-speech-recording-stop{color:#ef6666;background:rgba(239,102,102,.12)}.dsh-speech-recording-stop:hover:not(:disabled),.dsh-speech-recording-stop:focus-visible{color:#ff7777;background:rgba(239,102,102,.2)}.dsh-speech-recording-send{width:34px;height:34px;display:inline-grid;place-items:center;flex:none;padding:0;border:0;border-radius:999px;background:var(--dsw-alias-button-info-fill,#679efe);color:#fff;cursor:pointer}.dsh-speech-recording-send:hover:not(:disabled),.dsh-speech-recording-send:focus-visible{outline:0;background:var(--dsw-alias-button-info-hover,#4176e6)}.dsh-speech-recording-send:disabled{cursor:default;opacity:.4}.dsh-speech-recording-send svg{width:16px;height:16px}.dsh-speech-recording-progress{display:flex;align-items:center;justify-content:flex-end;gap:8px;width:100%;color:var(--color-text-secondary,#a8a8a8);font-size:12px}.dsh-speech-recording-takeover[data-phase="starting"] .dsh-speech-recording-progress{justify-content:center}.dsh-speech-recording-progress i{width:13px;height:13px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:dsh-speech-spin .8s linear infinite}.dsh-speech-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@keyframes dsh-speech-spin{to{transform:rotate(360deg)}}
807
812
  .dsh-speech-device-marker{display:none}[data-slot="conversation.composer.dock"]>:has(>.dsh-speech-device-dock){overflow:visible}.dsh-speech-device-fallback{min-height:22px;display:flex;align-items:center;justify-content:flex-start;padding:0 8px;overflow:visible}.dsh-speech-device-fallback .dsh-speech-device-dock{margin-left:0}.dsh-speech-device-separator{margin-left:4px;color:var(--dsw-alias-label-tertiary,var(--color-text-secondary,#888))}.dsh-speech-device-dock{position:relative;display:inline-flex;vertical-align:middle;margin-left:5px;color:var(--dsw-alias-label-secondary,var(--color-text-secondary,#a8a8a8));font:inherit}.dsh-speech-device-trigger{max-width:126px;height:20px;display:inline-flex;align-items:center;gap:4px;padding:0 5px;border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:11px;line-height:20px;cursor:pointer}.dsh-speech-device-trigger:hover,.dsh-speech-device-trigger:focus-visible,.dsh-speech-device-trigger[aria-expanded="true"]{outline:0;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));color:var(--dsw-alias-label-primary,var(--color-text,#eee))}.dsh-speech-device-trigger:disabled{cursor:wait;opacity:.6}.dsh-speech-device-dock[data-error="true"] .dsh-speech-device-trigger{color:var(--dsw-alias-state-error-primary,#f07878)}.dsh-speech-device-icon,.dsh-speech-device-chevron{display:inline-flex;flex:none}.dsh-speech-device-icon svg{width:12px;height:12px}.dsh-speech-device-chevron svg{width:10px;height:10px}.dsh-speech-device-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsh-speech-device-menu{position:absolute;right:0;bottom:calc(100% + 6px);z-index:80;min-width:240px;max-width:min(320px,calc(100vw - 32px));display:grid;box-sizing:border-box;padding:4px;border:1px solid var(--dsw-alias-border-l2-darkmode-thin,rgba(255,255,255,.06));border-radius:12px;background:var(--dsw-alias-bg-layer-3,#353638);color:var(--dsw-alias-label-primary,#f9fafb);box-shadow:0 0 1px rgba(0,0,0,.2),0 0 4px rgba(0,0,0,.02),0 12px 32px rgba(0,0,0,.08);font:13px/20px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.dsh-speech-device-menu-item{width:100%;min-width:0;height:38px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 10px;border:0;border-radius:10px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}.dsh-speech-device-menu-item:hover,.dsh-speech-device-menu-item:focus-visible{outline:0;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08))}.dsh-speech-device-menu-item>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsh-speech-device-check{width:14px;height:14px;display:inline-flex;flex:none;color:var(--dsw-alias-brand-primary,var(--dsw-alias-label-primary,#f9fafb))}.dsh-speech-device-check svg{width:14px;height:14px}.dsh-speech-device-dock[data-variant="hero"]{margin-left:0;color:var(--dsw-alias-label-primary,var(--color-text,#f9fafb));font:500 13px/20px -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei","Helvetica Neue",Helvetica,Arial,sans-serif}.dsh-speech-device-dock[data-variant="hero"] .dsh-speech-device-trigger{max-width:220px;height:28px;padding:0 8px;gap:4px;border-radius:16px;font:inherit;line-height:20px}.dsh-speech-device-dock[data-variant="hero"] .dsh-speech-device-icon svg{width:16px;height:16px}.dsh-speech-device-dock[data-variant="hero"] .dsh-speech-device-chevron svg{width:14px;height:14px}.dsh-speech-device-dock[data-variant="hero"] .dsh-speech-device-menu{left:0;right:auto;top:calc(100% + 6px);bottom:auto;min-width:218px}
808
813
  [data-dsh-speech-nav]>svg{display:none}[data-dsh-speech-nav]::before{content:"";width:16px;height:16px;flex:none;background:currentColor;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='7' y='2' width='6' height='11' rx='3' fill='none' stroke='black' stroke-width='1.7'/%3E%3Cpath d='M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v3M7 18h6' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E") center/contain no-repeat}
814
+ .dsh-speech-sidebar-playing{width:16px;height:20px;display:inline-flex;align-items:center;justify-content:center;flex:none;margin-left:1px;color:var(--dsw-alias-state-business-primary,#679efe);pointer-events:none}.dsh-speech-sidebar-playing svg{width:13px;height:13px;fill:currentColor}[data-dsh-speech-playing-session]>.dsh-speech-sidebar-playing{animation:dsh-speech-sidebar-playing-in .16s ease-out}@keyframes dsh-speech-sidebar-playing-in{from{opacity:0;transform:scale(.72)}to{opacity:1;transform:scale(1)}}
809
815
  .dsh-speech-dock,.dsh-speech-dock-error{min-height:22px;display:flex;align-items:center;gap:9px;padding:4px 8px 0;font-size:12px}.dsh-speech-dock-detail{color:var(--color-text-secondary,#8d8d8d)}
810
816
  .dsh-speech-summary-player{width:100%}.dsh-speech-summary-player>.dsh-speech-autoplay-toggle{margin-left:auto}
817
+ :where(div):has(>.dsh-speech-summary-player){height:auto;flex-wrap:wrap}:where(div):has(>.dsh-speech-summary-player)>.dsh-speech-summary-player{order:-1;flex:0 0 100%;margin-bottom:4px}
811
818
  @media(max-width:680px){.dsh-speech-columns,.dsh-speech-grid,.dsh-speech-balance-grid{grid-template-columns:1fr}.dsh-speech-header{align-items:flex-start}.dsh-speech-device-separator,.dsh-speech-device-dock{display:none}}
812
- @media(prefers-reduced-motion:reduce){.dsh-speech-mic-tooltip{transition:none}.dsh-speech-recording-progress i{animation:none;border-right-color:currentColor}.dsh-speech-summary-waveform i{transition:none}.dsh-speech-summary-waveform[data-preparing="true"] i{animation:none}}
819
+ @media(prefers-reduced-motion:reduce){.dsh-speech-mic-tooltip{transition:none}.dsh-speech-recording-progress i{animation:none;border-right-color:currentColor}.dsh-speech-summary-waveform i{transition:none}.dsh-speech-summary-waveform[data-preparing="true"] i,.dsh-speech-sidebar-playing{animation:none}}
813
820
  `;
814
821
 
815
822
  //#endregion
@@ -1616,7 +1623,7 @@ function ModelPicker({ label, placeholder, emptyLabel, options, value, disabled,
1616
1623
  ]
1617
1624
  });
1618
1625
  }
1619
- function SpeechSettings({ controller, scope, getLocale, t }) {
1626
+ function SpeechSettings({ controller, scope, getLocale, summaryCache, t }) {
1620
1627
  const client = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
1621
1628
  const settings = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
1622
1629
  const value = settings.value;
@@ -2113,7 +2120,25 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
2113
2120
  ttsCatalog.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2114
2121
  className: styles.muted,
2115
2122
  children: t("ttsUnavailable")
2116
- }) : null
2123
+ }) : null,
2124
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2125
+ className: styles.summaryCache,
2126
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("summaryCache") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2127
+ className: styles.hint,
2128
+ children: t("summaryCacheHint")
2129
+ })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2130
+ className: styles.secondary,
2131
+ type: "button",
2132
+ disabled: busy !== null,
2133
+ onClick: () => {
2134
+ run("clear-summary-cache", async () => {
2135
+ await summaryCache.clear();
2136
+ setMessage(t("summaryCacheCleared"));
2137
+ });
2138
+ },
2139
+ children: busy === "clear-summary-cache" ? t("loading") : t("clearSummaryCache")
2140
+ })]
2141
+ })
2117
2142
  ]
2118
2143
  }),
2119
2144
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
@@ -2187,6 +2212,205 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
2187
2212
  });
2188
2213
  }
2189
2214
 
2215
+ //#endregion
2216
+ //#region src/client/summary-cache.ts
2217
+ const SUMMARY_CACHE_TTL_MS = 720 * 60 * 60 * 1e3;
2218
+ const SUMMARY_CACHE_MAX_ENTRIES = 500;
2219
+ const DATABASE_NAME = "dsh-speech-summary-cache";
2220
+ const DATABASE_VERSION = 1;
2221
+ const STORE_NAME = "summaries";
2222
+ var IndexedDbSummaryCacheStore = class {
2223
+ database;
2224
+ async get(key) {
2225
+ const database = await this.open();
2226
+ if (database === void 0) return void 0;
2227
+ return await new Promise((resolve) => {
2228
+ const request$1 = database.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(key);
2229
+ request$1.onsuccess = () => {
2230
+ resolve(request$1.result);
2231
+ };
2232
+ request$1.onerror = () => {
2233
+ resolve(void 0);
2234
+ };
2235
+ });
2236
+ }
2237
+ async put(record) {
2238
+ const database = await this.open();
2239
+ if (database === void 0) return;
2240
+ await new Promise((resolve) => {
2241
+ const transaction = database.transaction(STORE_NAME, "readwrite");
2242
+ transaction.objectStore(STORE_NAME).put(record);
2243
+ transaction.oncomplete = () => {
2244
+ resolve();
2245
+ };
2246
+ transaction.onerror = () => {
2247
+ resolve();
2248
+ };
2249
+ transaction.onabort = () => {
2250
+ resolve();
2251
+ };
2252
+ });
2253
+ }
2254
+ async delete(key) {
2255
+ const database = await this.open();
2256
+ if (database === void 0) return;
2257
+ await new Promise((resolve) => {
2258
+ const transaction = database.transaction(STORE_NAME, "readwrite");
2259
+ transaction.objectStore(STORE_NAME).delete(key);
2260
+ transaction.oncomplete = () => {
2261
+ resolve();
2262
+ };
2263
+ transaction.onerror = () => {
2264
+ resolve();
2265
+ };
2266
+ transaction.onabort = () => {
2267
+ resolve();
2268
+ };
2269
+ });
2270
+ }
2271
+ async all() {
2272
+ const database = await this.open();
2273
+ if (database === void 0) return [];
2274
+ return await new Promise((resolve) => {
2275
+ const request$1 = database.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).getAll();
2276
+ request$1.onsuccess = () => {
2277
+ resolve(request$1.result);
2278
+ };
2279
+ request$1.onerror = () => {
2280
+ resolve([]);
2281
+ };
2282
+ });
2283
+ }
2284
+ async clear() {
2285
+ const database = await this.open();
2286
+ if (database === void 0) return;
2287
+ await new Promise((resolve) => {
2288
+ const transaction = database.transaction(STORE_NAME, "readwrite");
2289
+ transaction.objectStore(STORE_NAME).clear();
2290
+ transaction.oncomplete = () => {
2291
+ resolve();
2292
+ };
2293
+ transaction.onerror = () => {
2294
+ resolve();
2295
+ };
2296
+ transaction.onabort = () => {
2297
+ resolve();
2298
+ };
2299
+ });
2300
+ }
2301
+ close() {
2302
+ this.database?.then((database) => {
2303
+ database?.close();
2304
+ });
2305
+ this.database = void 0;
2306
+ }
2307
+ open() {
2308
+ if (this.database !== void 0) return this.database;
2309
+ if (typeof indexedDB === "undefined") return Promise.resolve(void 0);
2310
+ this.database = new Promise((resolve) => {
2311
+ let settled = false;
2312
+ const finish = (database) => {
2313
+ if (settled) {
2314
+ database?.close();
2315
+ return;
2316
+ }
2317
+ settled = true;
2318
+ resolve(database);
2319
+ };
2320
+ const request$1 = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
2321
+ request$1.onupgradeneeded = () => {
2322
+ if (!request$1.result.objectStoreNames.contains(STORE_NAME)) request$1.result.createObjectStore(STORE_NAME, { keyPath: "key" });
2323
+ };
2324
+ request$1.onsuccess = () => {
2325
+ finish(request$1.result);
2326
+ };
2327
+ request$1.onerror = () => {
2328
+ finish(void 0);
2329
+ };
2330
+ request$1.onblocked = () => {
2331
+ finish(void 0);
2332
+ };
2333
+ });
2334
+ return this.database;
2335
+ }
2336
+ };
2337
+ async function summaryCacheKey(input) {
2338
+ if (globalThis.crypto?.subtle === void 0) return void 0;
2339
+ const canonical = JSON.stringify({
2340
+ version: SUMMARY_PROMPT_VERSION,
2341
+ request: input.request,
2342
+ answer: input.answer,
2343
+ locale: input.locale,
2344
+ route: {
2345
+ provider: input.route.provider,
2346
+ model: input.route.model,
2347
+ reasoningEffort: input.route.reasoningEffort ?? null
2348
+ }
2349
+ });
2350
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
2351
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
2352
+ }
2353
+ /** Browser-local, fail-open cache. It never stores session or message identity. */
2354
+ var SummaryCache = class {
2355
+ constructor(store = new IndexedDbSummaryCacheStore(), now = Date.now, ttlMs = SUMMARY_CACHE_TTL_MS, maximumEntries = SUMMARY_CACHE_MAX_ENTRIES) {
2356
+ this.store = store;
2357
+ this.now = now;
2358
+ this.ttlMs = ttlMs;
2359
+ this.maximumEntries = maximumEntries;
2360
+ }
2361
+ async get(input) {
2362
+ try {
2363
+ const key = await summaryCacheKey(input);
2364
+ if (key === void 0) return void 0;
2365
+ const record = await this.store.get(key);
2366
+ if (record === void 0) return void 0;
2367
+ const now = this.now();
2368
+ if (record.expiresAt <= now || record.summary.length === 0 || record.summary.length > MAX_TTS_CHARACTERS) {
2369
+ await this.store.delete(key);
2370
+ return;
2371
+ }
2372
+ await this.store.put({
2373
+ ...record,
2374
+ accessedAt: now
2375
+ });
2376
+ return record.summary;
2377
+ } catch {
2378
+ return;
2379
+ }
2380
+ }
2381
+ async set(input, summary) {
2382
+ if (summary.length === 0 || summary.length > MAX_TTS_CHARACTERS) return;
2383
+ try {
2384
+ const key = await summaryCacheKey(input);
2385
+ if (key === void 0) return;
2386
+ const now = this.now();
2387
+ await this.store.put({
2388
+ key,
2389
+ summary,
2390
+ expiresAt: now + this.ttlMs,
2391
+ accessedAt: now
2392
+ });
2393
+ await this.prune(now);
2394
+ } catch {}
2395
+ }
2396
+ async clear() {
2397
+ try {
2398
+ await this.store.clear();
2399
+ } catch {}
2400
+ }
2401
+ dispose() {
2402
+ this.store.close();
2403
+ }
2404
+ async prune(now) {
2405
+ const records = await this.store.all();
2406
+ const live = [];
2407
+ for (const record of records) if (record.expiresAt <= now) await this.store.delete(record.key);
2408
+ else live.push(record);
2409
+ live.sort((a, b) => b.accessedAt - a.accessedAt);
2410
+ for (const record of live.slice(this.maximumEntries)) await this.store.delete(record.key);
2411
+ }
2412
+ };
2413
+
2190
2414
  //#endregion
2191
2415
  //#region src/client/spoken-controller.ts
2192
2416
  const EMPTY_PEAKS = Object.freeze(Array.from({ length: 48 }, (_, index) => .15 + .08 * Math.sin(index * .73) ** 2));
@@ -2206,10 +2430,12 @@ function boundSource(text, maximum) {
2206
2430
  return `${text.slice(0, beginning)}${marker}${text.slice(text.length - (remaining - beginning))}`;
2207
2431
  }
2208
2432
  /** Derive only the closing finalized assistant message for each completed turn. */
2209
- function spokenSources(nodes, locale, requests = []) {
2433
+ function spokenSources(nodes, locale, turnEnds, requests = []) {
2210
2434
  const closings = /* @__PURE__ */ new Map();
2211
2435
  for (const node of nodes) {
2212
2436
  if (node.kind !== "assistant" || node.messageId === void 0 || node.interrupted === true) continue;
2437
+ if (!turnEnds.has(node.turn)) continue;
2438
+ if (node.blocks.filter((block) => block.kind === "text").map((block) => block.text).join("\n").trim().length === 0) continue;
2213
2439
  const current = closings.get(node.turn);
2214
2440
  if (current === void 0 || node.seq > current.seq) closings.set(node.turn, node);
2215
2441
  }
@@ -2235,6 +2461,7 @@ function spokenSources(nodes, locale, requests = []) {
2235
2461
  };
2236
2462
  result.push({
2237
2463
  messageId: String(assistant.messageId),
2464
+ seq: assistant.seq,
2238
2465
  request: boundSource(request$1, MAX_SUMMARY_REQUEST_CHARACTERS),
2239
2466
  answer: boundSource(answer, MAX_SUMMARY_ANSWER_CHARACTERS),
2240
2467
  locale,
@@ -2275,9 +2502,13 @@ var SpokenSummaryController = class {
2275
2502
  listeners = /* @__PURE__ */ new Set();
2276
2503
  states = /* @__PURE__ */ new Map();
2277
2504
  observed = /* @__PURE__ */ new Map();
2505
+ messageSessions = /* @__PURE__ */ new Map();
2278
2506
  observedInteractions = /* @__PURE__ */ new Set();
2507
+ observedInteractionSessions = /* @__PURE__ */ new Set();
2508
+ autoplayConsumed = /* @__PURE__ */ new Set();
2279
2509
  voiceCache = /* @__PURE__ */ new Map();
2280
2510
  activeMessageId;
2511
+ activeSessionKey;
2281
2512
  loadedMessageId;
2282
2513
  playbackGeneration = 0;
2283
2514
  snapshot = { messages: /* @__PURE__ */ new Map() };
@@ -2290,7 +2521,8 @@ var SpokenSummaryController = class {
2290
2521
  analysisFrame;
2291
2522
  analysisTick = 0;
2292
2523
  pauseResolutionTimer;
2293
- constructor(audioFactory = browserAudio) {
2524
+ constructor(audioFactory = browserAudio, summaryCache = new SummaryCache()) {
2525
+ this.summaryCache = summaryCache;
2294
2526
  this.audio = audioFactory();
2295
2527
  }
2296
2528
  subscribe = (listener) => {
@@ -2345,30 +2577,45 @@ var SpokenSummaryController = class {
2345
2577
  if (this.disposed) return;
2346
2578
  this.setEnabled(settings.ttsEnabled);
2347
2579
  const ids = new Set(sources.map((source) => source.messageId));
2580
+ const maxSeq = sources.reduce((maximum, source) => Math.max(maximum, source.seq), -1);
2581
+ for (const id of ids) this.messageSessions.set(id, sessionKey);
2348
2582
  const previous = this.observed.get(sessionKey);
2349
2583
  if (previous === void 0) {
2350
- this.observed.set(sessionKey, ids);
2584
+ this.observed.set(sessionKey, {
2585
+ ids,
2586
+ maxSeq
2587
+ });
2351
2588
  for (const id of ids) this.ensure(id);
2352
2589
  this.publish();
2353
2590
  return;
2354
2591
  }
2355
- this.observed.set(sessionKey, new Set([...previous, ...ids]));
2592
+ const previousMaxSeq = previous.maxSeq;
2593
+ let changed = false;
2356
2594
  for (const source of sources) {
2357
- if (previous.has(source.messageId)) continue;
2358
- previous.add(source.messageId);
2359
- if (settings.ttsEnabled) this.prepare(source, settings, settings.autoPlay);
2595
+ if (previous.ids.has(source.messageId)) continue;
2596
+ previous.ids.add(source.messageId);
2597
+ changed = true;
2598
+ if (settings.ttsEnabled && source.seq > previousMaxSeq) this.prepare(source, settings, settings.autoPlay);
2360
2599
  else this.ensure(source.messageId);
2361
2600
  }
2362
- if (!settings.ttsEnabled) this.publish();
2601
+ previous.maxSeq = Math.max(previous.maxSeq, maxSeq);
2602
+ if (changed && (!settings.ttsEnabled || sources.some((source) => source.seq <= previousMaxSeq))) this.publish();
2363
2603
  }
2364
2604
  observeInteractions(sessionKey, interactionKeys, locale, settings) {
2365
2605
  if (this.disposed) return;
2366
2606
  this.setEnabled(settings.ttsEnabled);
2607
+ if (!this.observedInteractionSessions.has(sessionKey)) {
2608
+ this.observedInteractionSessions.add(sessionKey);
2609
+ for (const key of interactionKeys) this.observedInteractions.add(`${sessionKey}\n${key}`);
2610
+ return;
2611
+ }
2367
2612
  for (const key of interactionKeys) {
2368
2613
  const identity = `${sessionKey}\n${key}`;
2369
2614
  if (this.observedInteractions.has(identity)) continue;
2370
2615
  this.observedInteractions.add(identity);
2371
- if (settings.ttsEnabled && settings.autoPlay) this.prepareCue(`interaction:${identity}`, interactionCue(locale), settings);
2616
+ const messageId = `interaction:${identity}`;
2617
+ this.messageSessions.set(messageId, sessionKey);
2618
+ if (settings.ttsEnabled && settings.autoPlay) this.prepareCue(messageId, interactionCue(locale), settings);
2372
2619
  }
2373
2620
  }
2374
2621
  async prepare(source, settings, autoPlay = false) {
@@ -2402,12 +2649,19 @@ var SpokenSummaryController = class {
2402
2649
  try {
2403
2650
  const voice = await this.voiceFor(binding, settings.ttsVoice);
2404
2651
  if (voice === void 0) throw new Error("No compatible voice is available for this text-to-speech route.");
2405
- const { summary } = await speechApi.summarize({
2652
+ const summaryInput = {
2406
2653
  request: source.request || "No preceding user prose was available.",
2407
2654
  answer: source.answer,
2408
2655
  locale: source.locale,
2409
2656
  route: source.route
2410
- }, abort.signal);
2657
+ };
2658
+ let summary = await this.summaryCache.get(summaryInput);
2659
+ if (this.disposed || abort.signal.aborted || state.requestGeneration !== requestGeneration) return;
2660
+ if (summary === void 0) {
2661
+ summary = (await speechApi.summarize(summaryInput, abort.signal)).summary;
2662
+ if (this.disposed || abort.signal.aborted || state.requestGeneration !== requestGeneration) return;
2663
+ await this.summaryCache.set(summaryInput, summary);
2664
+ }
2411
2665
  const prepared = await speechApi.prepareTts({
2412
2666
  text: summary,
2413
2667
  model: binding.model,
@@ -2426,7 +2680,7 @@ var SpokenSummaryController = class {
2426
2680
  error: void 0,
2427
2681
  ended: false
2428
2682
  });
2429
- if (autoPlay && this.activeMessageId === void 0) await this.play(source.messageId, false);
2683
+ if (autoPlay && this.activeMessageId === void 0 && !this.autoplayConsumed.has(source.messageId)) await this.play(source.messageId, false);
2430
2684
  } catch (error) {
2431
2685
  if (abort.signal.aborted || state.requestGeneration !== requestGeneration) return;
2432
2686
  state.abort = void 0;
@@ -2516,6 +2770,10 @@ var SpokenSummaryController = class {
2516
2770
  duration: Math.max(0, duration)
2517
2771
  });
2518
2772
  };
2773
+ this.audio.onplaying = () => {
2774
+ if (!this.owns(generation, messageId)) return;
2775
+ this.autoplayConsumed.add(messageId);
2776
+ };
2519
2777
  this.patch(messageId, {
2520
2778
  phase: "playing",
2521
2779
  ended: false
@@ -2523,6 +2781,9 @@ var SpokenSummaryController = class {
2523
2781
  try {
2524
2782
  await this.audio.play();
2525
2783
  if (!this.owns(generation, messageId)) return;
2784
+ this.autoplayConsumed.add(messageId);
2785
+ this.activeSessionKey = this.messageSessions.get(messageId);
2786
+ this.publish();
2526
2787
  this.startAnalysis(messageId, generation);
2527
2788
  } catch {
2528
2789
  if (!this.owns(generation, messageId)) return;
@@ -2572,7 +2833,10 @@ var SpokenSummaryController = class {
2572
2833
  }
2573
2834
  this.states.clear();
2574
2835
  this.observed.clear();
2836
+ this.messageSessions.clear();
2575
2837
  this.observedInteractions.clear();
2838
+ this.observedInteractionSessions.clear();
2839
+ this.autoplayConsumed.clear();
2576
2840
  this.voiceCache.clear();
2577
2841
  this.analyserContext?.close().catch(() => {});
2578
2842
  this.analyserContext = void 0;
@@ -2743,7 +3007,9 @@ var SpokenSummaryController = class {
2743
3007
  this.stopAnalysis();
2744
3008
  this.clearPauseResolution();
2745
3009
  this.activeMessageId = void 0;
3010
+ this.activeSessionKey = void 0;
2746
3011
  this.audio.onended = null;
3012
+ this.audio.onplaying = null;
2747
3013
  this.audio.onerror = null;
2748
3014
  this.audio.onpause = null;
2749
3015
  this.audio.ontimeupdate = null;
@@ -2777,7 +3043,8 @@ var SpokenSummaryController = class {
2777
3043
  ...state.error === void 0 ? {} : { error: state.error },
2778
3044
  ...state.ended === void 0 ? {} : { ended: state.ended }
2779
3045
  }])),
2780
- ...this.activeMessageId === void 0 ? {} : { activeMessageId: this.activeMessageId }
3046
+ ...this.activeMessageId === void 0 ? {} : { activeMessageId: this.activeMessageId },
3047
+ ...this.activeSessionKey === void 0 ? {} : { activeSessionKey: this.activeSessionKey }
2781
3048
  };
2782
3049
  for (const listener of this.listeners) listener();
2783
3050
  }
@@ -2822,8 +3089,9 @@ function Waveform({ peaks, preparing, playing }) {
2822
3089
  children: peaks.map((peak, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", { style: { "--dsh-speech-peak": String(peak) } }, index))
2823
3090
  });
2824
3091
  }
2825
- function SpokenSummaryTail({ controller, spoken, scope, getLocale, seq, useSession, t }) {
3092
+ function SpokenSummaryTail({ controller, spoken, scope, getLocale, messageId, useSession, t }) {
2826
3093
  const nodes = useSession((snapshot) => snapshot.nodes);
3094
+ const turnEnds = useSession((snapshot) => snapshot.turnEnds);
2827
3095
  const requests = useSession((snapshot) => {
2828
3096
  const views = snapshot.views;
2829
3097
  if (views?.get === void 0) return EMPTY_REQUESTS;
@@ -2833,13 +3101,13 @@ function SpokenSummaryTail({ controller, spoken, scope, getLocale, seq, useSessi
2833
3101
  const scopeState = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
2834
3102
  const spokenState = (0, react.useSyncExternalStore)(spoken.subscribe, spoken.getSnapshot);
2835
3103
  const locale = getLocale();
2836
- const sources = (0, react.useMemo)(() => spokenSources(nodes, locale, requests), [
3104
+ const sources = (0, react.useMemo)(() => spokenSources(nodes, locale, turnEnds, requests), [
2837
3105
  nodes,
2838
3106
  locale,
2839
- requests
3107
+ requests,
3108
+ turnEnds
2840
3109
  ]);
2841
- const closing = nodes.find((node) => node.kind === "assistant" && node.seq === seq);
2842
- const id = closing?.kind === "assistant" && closing.messageId !== void 0 ? String(closing.messageId) : `turn-tail:${String(seq)}`;
3110
+ const id = String(messageId);
2843
3111
  const source = sources.find((candidate) => candidate.messageId === id);
2844
3112
  const settings = (0, react.useMemo)(() => preparationSettings(controller, scopeState.value, locale), [
2845
3113
  controller,
@@ -2855,7 +3123,7 @@ function SpokenSummaryTail({ controller, spoken, scope, getLocale, seq, useSessi
2855
3123
  };
2856
3124
  const latestAudioMessageId = [...sources].reverse().find((candidate) => spokenState.messages.get(candidate.messageId)?.audioUrl !== void 0)?.messageId;
2857
3125
  const latestSourceMessageId = sources.at(-1)?.messageId;
2858
- const legacyAutoplayWasUsed = scopeState.user.autoPlay !== void 0;
3126
+ const legacyAutoplayWasUsed = scopeState.user?.autoPlay !== void 0;
2859
3127
  const autoplayInlineRevealed = scopeState.value?.autoplayInlineRevealed === true || legacyAutoplayWasUsed;
2860
3128
  const autoplayHostMessageId = latestAudioMessageId ?? (autoplayInlineRevealed ? latestSourceMessageId : void 0);
2861
3129
  (0, react.useEffect)(() => spoken.mount(id), [spoken, id]);
@@ -2942,11 +3210,13 @@ function SpokenSummaryTail({ controller, spoken, scope, getLocale, seq, useSessi
2942
3210
  ]
2943
3211
  });
2944
3212
  }
2945
- function SpokenSessionObserver({ controller, spoken, scope, getLocale, sessionId, useSession }) {
3213
+ function SpokenSessionObserver({ controller, spoken, scope, getLocale, sidebarPlayback, sessionId, useSession }) {
2946
3214
  const state = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
2947
3215
  const client = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
3216
+ const openState = useSession((snapshot) => snapshot.openState);
2948
3217
  const pending = useSession((snapshot) => snapshot.pending);
2949
3218
  const nodes = useSession((snapshot) => snapshot.nodes);
3219
+ const turnEnds = useSession((snapshot) => snapshot.turnEnds);
2950
3220
  const requests = useSession((snapshot) => {
2951
3221
  const views = snapshot.views;
2952
3222
  if (views?.get === void 0) return EMPTY_REQUESTS;
@@ -2959,32 +3229,38 @@ function SpokenSessionObserver({ controller, spoken, scope, getLocale, sessionId
2959
3229
  speechLocale,
2960
3230
  state.value
2961
3231
  ]);
2962
- const sources = (0, react.useMemo)(() => spokenSources(nodes, speechLocale, requests), [
3232
+ const sources = (0, react.useMemo)(() => spokenSources(nodes, speechLocale, turnEnds, requests), [
2963
3233
  nodes,
2964
3234
  requests,
2965
- speechLocale
3235
+ speechLocale,
3236
+ turnEnds
2966
3237
  ]);
2967
3238
  (0, react.useEffect)(() => {
2968
3239
  controller.ensureMetadata();
2969
3240
  }, [controller]);
3241
+ (0, react.useEffect)(() => {
3242
+ sidebarPlayback.observeCurrentSession(String(sessionId));
3243
+ }, [sessionId, sidebarPlayback]);
2970
3244
  (0, react.useEffect)(() => {
2971
3245
  spoken.setEnabled(settings.ttsEnabled);
2972
3246
  }, [settings.ttsEnabled, spoken]);
2973
3247
  (0, react.useEffect)(() => {
2974
- if (client.catalog === void 0) return;
3248
+ if (client.catalog === void 0 || openState !== "open") return;
2975
3249
  spoken.observeSession(String(sessionId), sources, settings);
2976
3250
  }, [
2977
3251
  client.catalog,
3252
+ openState,
2978
3253
  sessionId,
2979
3254
  settings,
2980
3255
  sources,
2981
3256
  spoken
2982
3257
  ]);
2983
3258
  (0, react.useEffect)(() => {
2984
- if (client.catalog === void 0) return;
3259
+ if (client.catalog === void 0 || openState !== "open") return;
2985
3260
  spoken.observeInteractions(String(sessionId), pending.map((item) => item.key), speechLocale, settings);
2986
3261
  }, [
2987
3262
  client.catalog,
3263
+ openState,
2988
3264
  pending,
2989
3265
  sessionId,
2990
3266
  settings,
@@ -2994,6 +3270,94 @@ function SpokenSessionObserver({ controller, spoken, scope, getLocale, sessionId
2994
3270
  return null;
2995
3271
  }
2996
3272
 
3273
+ //#endregion
3274
+ //#region src/client/sidebar-playback-indicator.ts
3275
+ const INDICATOR_CLASS = "dsh-speech-sidebar-playing";
3276
+ const ROW_ATTRIBUTE = "data-dsh-speech-playing-session";
3277
+ function selectedSessionRow() {
3278
+ if (typeof document === "undefined") return void 0;
3279
+ return document.querySelector("[role=\"treeitem\"][aria-selected=\"true\"]") ?? void 0;
3280
+ }
3281
+ /**
3282
+ * Adds a memory-only playback marker to the Harness session row that owned
3283
+ * the selected conversation when audio began. Harness RC does not expose a
3284
+ * per-session-row slot, so this bridge relies only on its ARIA tree contract.
3285
+ */
3286
+ var SidebarPlaybackIndicator = class {
3287
+ rows = /* @__PURE__ */ new Map();
3288
+ unsubscribe;
3289
+ observer;
3290
+ currentSessionKey;
3291
+ activeSessionKey;
3292
+ disposed = false;
3293
+ constructor(spoken) {
3294
+ this.spoken = spoken;
3295
+ this.unsubscribe = spoken.subscribe(() => {
3296
+ const activeSessionKey = spoken.getSnapshot().activeSessionKey;
3297
+ if (activeSessionKey === this.activeSessionKey) return;
3298
+ this.activeSessionKey = activeSessionKey;
3299
+ this.refresh();
3300
+ });
3301
+ this.activeSessionKey = spoken.getSnapshot().activeSessionKey;
3302
+ this.observer = typeof MutationObserver === "undefined" || typeof document === "undefined" ? void 0 : new MutationObserver(() => {
3303
+ this.refresh();
3304
+ });
3305
+ this.observer?.observe(document.body, {
3306
+ childList: true,
3307
+ subtree: true,
3308
+ attributes: true,
3309
+ attributeFilter: ["aria-selected"]
3310
+ });
3311
+ }
3312
+ observeCurrentSession(sessionKey) {
3313
+ if (this.disposed) return;
3314
+ this.currentSessionKey = sessionKey;
3315
+ this.refresh();
3316
+ }
3317
+ dispose() {
3318
+ if (this.disposed) return;
3319
+ this.disposed = true;
3320
+ this.unsubscribe();
3321
+ this.observer?.disconnect();
3322
+ this.removeIndicators();
3323
+ this.rows.clear();
3324
+ }
3325
+ refresh() {
3326
+ if (this.disposed) return;
3327
+ const selected = selectedSessionRow();
3328
+ if (selected !== void 0 && this.currentSessionKey !== void 0) this.rows.set(this.currentSessionKey, selected);
3329
+ if (this.activeSessionKey === void 0) {
3330
+ this.removeIndicators();
3331
+ return;
3332
+ }
3333
+ let row = this.rows.get(this.activeSessionKey);
3334
+ if (row?.isConnected !== true) {
3335
+ this.rows.delete(this.activeSessionKey);
3336
+ row = this.currentSessionKey === this.activeSessionKey ? selected : void 0;
3337
+ if (row !== void 0) this.rows.set(this.activeSessionKey, row);
3338
+ }
3339
+ if (row === void 0) {
3340
+ this.removeIndicators();
3341
+ return;
3342
+ }
3343
+ if (document.querySelector(`.${INDICATOR_CLASS}`)?.parentElement === row) return;
3344
+ this.removeIndicators();
3345
+ row.setAttribute(ROW_ATTRIBUTE, "");
3346
+ const indicator = document.createElement("span");
3347
+ indicator.className = INDICATOR_CLASS;
3348
+ indicator.setAttribute("role", "img");
3349
+ indicator.setAttribute("aria-label", "Playing spoken summary");
3350
+ indicator.setAttribute("title", "Playing spoken summary");
3351
+ indicator.innerHTML = "<svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M5 3.25v9.5L12.5 8 5 3.25Z\"/></svg>";
3352
+ row.append(indicator);
3353
+ }
3354
+ removeIndicators() {
3355
+ if (typeof document === "undefined") return;
3356
+ for (const row of document.querySelectorAll(`[${ROW_ATTRIBUTE}]`)) row.removeAttribute(ROW_ATTRIBUTE);
3357
+ for (const indicator of document.querySelectorAll(`.${INDICATOR_CLASS}`)) indicator.remove();
3358
+ }
3359
+ };
3360
+
2997
3361
  //#endregion
2998
3362
  //#region src/client/locales.ts
2999
3363
  const en = {
@@ -3073,6 +3437,10 @@ const en = {
3073
3437
  modelVoiceSearchPlaceholder: "Type to search voices for this model",
3074
3438
  voiceNoResults: "No matching voices",
3075
3439
  ttsUnavailable: "No compatible synchronous MP3 text-to-speech route is currently advertised.",
3440
+ summaryCache: "Summary cache",
3441
+ summaryCacheHint: "Summaries are stored only in this browser for up to 30 days. The cache is limited to 500 entries.",
3442
+ clearSummaryCache: "Clear cache",
3443
+ summaryCacheCleared: "Summary cache cleared",
3076
3444
  autoplayGlobal: "Autoplay",
3077
3445
  autoplayShort: "Spoken summaries",
3078
3446
  autoplayInline: "Auto play",
@@ -3165,6 +3533,10 @@ const zh = {
3165
3533
  modelVoiceSearchPlaceholder: "输入关键词搜索此模型的声音",
3166
3534
  voiceNoResults: "没有匹配的声音",
3167
3535
  ttsUnavailable: "目前没有兼容的同步 MP3 文本转语音路由。",
3536
+ summaryCache: "摘要缓存",
3537
+ summaryCacheHint: "摘要仅在此浏览器中保存最多 30 天,缓存上限为 500 条。",
3538
+ clearSummaryCache: "清除缓存",
3539
+ summaryCacheCleared: "摘要缓存已清除",
3168
3540
  autoplayGlobal: "自动播放",
3169
3541
  autoplayShort: "语音摘要",
3170
3542
  autoplayInline: "自动播放",
@@ -3190,7 +3562,9 @@ const inject = [
3190
3562
  ];
3191
3563
  function apply(ctx) {
3192
3564
  const controller = new SpeechController();
3193
- const spoken = new SpokenSummaryController();
3565
+ const summaryCache = new SummaryCache();
3566
+ const spoken = new SpokenSummaryController(void 0, summaryCache);
3567
+ const sidebarPlayback = new SidebarPlaybackIndicator(spoken);
3194
3568
  const scope = ctx.settingsScope.bind({ namespace: "dsh-speech" });
3195
3569
  const getLocale = () => ctx.locale.getLocale().active;
3196
3570
  ctx.effect(() => ctx.locale.register("speech", {
@@ -3228,8 +3602,10 @@ function apply(ctx) {
3228
3602
  };
3229
3603
  }, "dsh-speech: settings navigation icon");
3230
3604
  ctx.effect(() => () => {
3605
+ sidebarPlayback.dispose();
3231
3606
  controller.dispose();
3232
3607
  spoken.dispose();
3608
+ summaryCache.dispose();
3233
3609
  }, "dsh-speech: browser controllers");
3234
3610
  controller.ensureMetadata();
3235
3611
  ctx.slots.inject("settings.section", () => ctx.slots.register({
@@ -3241,7 +3617,8 @@ function apply(ctx) {
3241
3617
  inject: () => ({
3242
3618
  controller,
3243
3619
  scope,
3244
- getLocale
3620
+ getLocale,
3621
+ summaryCache
3245
3622
  })
3246
3623
  }, SpeechSettings));
3247
3624
  ctx.inject(["conversation"], (scoped) => {
@@ -3277,19 +3654,17 @@ function apply(ctx) {
3277
3654
  getLocale
3278
3655
  })
3279
3656
  }, SpeechInputDock);
3280
- const spokenTail = scoped.slots.register({
3281
- name: "conversation.chat.turnTail",
3282
- priority: 20,
3283
- select: (owner) => ({
3284
- turn: owner.turn.turn,
3285
- seq: owner.seq
3286
- }),
3657
+ const spokenAction = scoped.slots.register({
3658
+ name: "conversation.chat.assistant-actions",
3659
+ id: "spoken-summary",
3660
+ order: -100,
3287
3661
  locale: "speech",
3288
3662
  inject: () => ({
3289
3663
  controller,
3290
3664
  spoken,
3291
3665
  scope,
3292
- getLocale
3666
+ getLocale,
3667
+ sidebarPlayback
3293
3668
  })
3294
3669
  }, SpokenSummaryTail);
3295
3670
  const spokenObserver = scoped.slots.register({
@@ -3300,7 +3675,8 @@ function apply(ctx) {
3300
3675
  controller,
3301
3676
  spoken,
3302
3677
  scope,
3303
- getLocale
3678
+ getLocale,
3679
+ sidebarPlayback
3304
3680
  })
3305
3681
  }, SpokenSessionObserver);
3306
3682
  return () => {
@@ -3308,7 +3684,7 @@ function apply(ctx) {
3308
3684
  mic();
3309
3685
  dock();
3310
3686
  inputDock();
3311
- spokenTail();
3687
+ spokenAction();
3312
3688
  spokenObserver();
3313
3689
  };
3314
3690
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allmodels/dsh-speech",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Speech-to-text and spoken answer summaries for DeepSeek Harness using AllModels.io.",
5
5
  "keywords": [
6
6
  "dsh-plugin",