mlx-bun 0.3.0 → 0.5.0
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 +57 -32
- package/docs/README.md +58 -42
- package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
- package/docs/archive/investigations/openjarvis-fit.md +198 -0
- package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
- package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
- package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
- package/docs/design/batching.md +1535 -50
- package/docs/design/decode-speed-program.md +394 -1171
- package/docs/design/generic-model-support.md +195 -3
- package/docs/design/kv-cache.md +538 -225
- package/docs/design/speculative-decoding.md +552 -50
- package/docs/design/turboquant.md +140 -17
- package/docs/design/unified-engine-frontier-plan.md +259 -150
- package/docs/planning/PRODUCT_ROADMAP.md +21 -6
- package/docs/planning/public-readiness-handoff.md +244 -0
- package/docs/planning/public-readiness.md +149 -0
- package/docs/reference/benchmarks.md +3537 -1112
- package/docs/reference/cli.md +102 -2
- package/docs/reference/distribution.md +45 -13
- package/docs/reference/environment.md +159 -13
- package/docs/reference/library-api.md +18 -4
- package/docs/reference/memory.md +1 -1
- package/docs/reference/models.md +28 -7
- package/docs/reference/server-api.md +257 -33
- package/docs/reference/server-config.md +372 -151
- package/docs/reference/troubleshooting.md +13 -21
- package/package.json +2 -2
- package/src/audio/audiotoolbox.ts +112 -0
- package/src/audio/conformer.ts +8 -0
- package/src/audio/mic-capture.ts +98 -0
- package/src/audio/silero-vad.ts +379 -0
- package/src/audio/whisper-decode.ts +774 -0
- package/src/audio/whisper-fast.ts +732 -0
- package/src/audio/whisper-format.ts +73 -0
- package/src/audio/whisper-mel.ts +185 -0
- package/src/audio/whisper-timing.ts +274 -0
- package/src/audio/whisper-tokenizer.ts +239 -0
- package/src/audio/whisper-transcribe.ts +410 -0
- package/src/backends/mlx/assistant-target.ts +46 -0
- package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
- package/src/backends/mlx/autoregressive.ts +22 -1
- package/src/backends/mlx/batch-group.ts +464 -236
- package/src/backends/mlx/cache-attachment.ts +67 -0
- package/src/backends/mlx/cache-layout.ts +31 -0
- package/src/backends/mlx/checkpoint-state.ts +53 -0
- package/src/backends/mlx/continuation-persistence.ts +107 -0
- package/src/backends/mlx/continuation-request.ts +43 -0
- package/src/backends/mlx/continuation.ts +77 -0
- package/src/backends/mlx/draft-target.ts +7 -22
- package/src/backends/mlx/encoder-cache.ts +25 -0
- package/src/backends/mlx/fill-append.ts +21 -8
- package/src/backends/mlx/fill-group.ts +497 -0
- package/src/backends/mlx/full-kv-row-append.ts +32 -0
- package/src/backends/mlx/full-kv-row-donor.ts +54 -0
- package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
- package/src/backends/mlx/gateway-binding.ts +122 -19
- package/src/backends/mlx/kv-codec.ts +9 -0
- package/src/backends/mlx/kv-maintenance.ts +161 -0
- package/src/backends/mlx/kv-tensor-rows.ts +212 -0
- package/src/backends/mlx/mixed-iteration.ts +62 -0
- package/src/backends/mlx/model-prompt.ts +46 -32
- package/src/backends/mlx/model-serving.ts +5 -4
- package/src/backends/mlx/paged-cache-codec.ts +27 -0
- package/src/backends/mlx/prefill-cohort.ts +100 -0
- package/src/backends/mlx/prefill-policy.ts +26 -0
- package/src/backends/mlx/prefill-rows.ts +244 -0
- package/src/backends/mlx/prefill.ts +1 -1
- package/src/backends/mlx/projected-context-rows.ts +72 -0
- package/src/backends/mlx/prompt-input.ts +29 -0
- package/src/backends/mlx/qwen-prompt-input.ts +40 -0
- package/src/backends/mlx/request-state-policy.ts +54 -0
- package/src/backends/mlx/rollback.ts +45 -4
- package/src/backends/mlx/row-sampling.ts +49 -0
- package/src/backends/mlx/serial-executor.ts +51 -73
- package/src/backends/mlx/speculative-group.ts +438 -0
- package/src/backends/mlx/speculative-round.ts +161 -0
- package/src/backends/mlx/speculative.ts +24 -7
- package/src/backends/mlx/state-rows.ts +104 -0
- package/src/backends/mlx/state-views.ts +21 -1
- package/src/backends/mlx/target-layout-capability.ts +35 -0
- package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
- package/src/cli.ts +333 -36
- package/src/contracts/execution.ts +21 -0
- package/src/contracts/object-cache.ts +8 -0
- package/src/contracts/pi-provider.ts +1 -1
- package/src/contracts/pi.ts +3 -1
- package/src/contracts/prefix-cache.ts +33 -0
- package/src/contracts/resources.ts +7 -0
- package/src/contracts/sampling.ts +23 -0
- package/src/contracts/scheduling.ts +27 -0
- package/src/engine/execution-plan.ts +38 -25
- package/src/engine/execution-tasks.ts +44 -0
- package/src/engine/namespaced-cache.ts +13 -0
- package/src/engine/resources.ts +12 -1
- package/src/engine/scheduler.ts +32 -7
- package/src/fill/fill-session.ts +14 -3
- package/src/fit.ts +29 -15
- package/src/generate.ts +105 -195
- package/src/grammar.ts +13 -0
- package/src/inference/draft-acceptance.ts +41 -0
- package/src/inference/draft-output.ts +49 -0
- package/src/inference/prefill.ts +6 -0
- package/src/inference/rollback.ts +2 -2
- package/src/kv-scheme.ts +92 -12
- package/src/kv-store.ts +183 -93
- package/src/lab/README.md +5 -1
- package/src/lab/paged-kv/paged-attention.ts +117 -0
- package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
- package/src/lab/paged-kv/paged-kv.ts +126 -46
- package/src/md-modules.d.ts +5 -0
- package/src/mlx/array.ts +39 -1
- package/src/mlx/ffi.ts +19 -0
- package/src/mlx/normalized-argmax.ts +116 -0
- package/src/mlx/ops.ts +97 -10
- package/src/model/artifact-identity.ts +73 -0
- package/src/model/batched-kv.ts +235 -0
- package/src/model/batched-mask.ts +12 -4
- package/src/model/batched-quant.ts +8 -2
- package/src/model/batched-quantized-kv.ts +144 -0
- package/src/model/batched-rotating-quant.ts +76 -15
- package/src/model/batched-rotating-state.ts +76 -0
- package/src/model/batched-rotating.ts +64 -22
- package/src/model/batched-row-storage.ts +52 -0
- package/src/model/batched-ssm.ts +52 -0
- package/src/model/batched-turboquant-kv.ts +94 -0
- package/src/model/compiled-decode.ts +7 -1
- package/src/model/decoded-kv-donor.ts +12 -0
- package/src/model/delayed-quantized-kv.ts +72 -0
- package/src/model/delayed-rotating-quantized-kv.ts +93 -0
- package/src/model/delayed-turboquant-kv.ts +57 -0
- package/src/model/factory.ts +26 -0
- package/src/model/fingerprint.ts +12 -0
- package/src/model/full-prefill-padding.ts +56 -0
- package/src/model/full-prefill-row.ts +105 -0
- package/src/model/gemma4-base.ts +183 -82
- package/src/model/gemma4.ts +116 -28
- package/src/model/generated/gemma4-12b.ts +4 -1
- package/src/model/generated/gemma4-26b.ts +4 -1
- package/src/model/generated/gemma4-e4b.ts +4 -1
- package/src/model/glm52-cache.ts +80 -4
- package/src/model/glm52-mla.ts +12 -6
- package/src/model/glm52.ts +19 -40
- package/src/model/kv-attention-view.ts +70 -0
- package/src/model/minicpm5.ts +11 -7
- package/src/model/profile.ts +29 -4
- package/src/model/quantized-append-attention.ts +31 -0
- package/src/model/qwen3-delta-state.ts +74 -0
- package/src/model/qwen3-delta.ts +59 -36
- package/src/model/qwen3-mrope.ts +7 -9
- package/src/model/qwen3_5.ts +177 -75
- package/src/model/rotating-kv-donor.ts +35 -0
- package/src/model/rotating-kv-layout.ts +262 -0
- package/src/model/rotating-row-transaction.ts +35 -0
- package/src/model/speculative-rotating-kv.ts +101 -0
- package/src/model/ssm-prefill-padding.ts +39 -0
- package/src/model/support.ts +8 -0
- package/src/model/token-groups.ts +66 -0
- package/src/model/trellis-linear.ts +4 -4
- package/src/model/turboquant-codec.ts +89 -0
- package/src/model/universal/dense.ts +14 -13
- package/src/model/whisper.ts +435 -0
- package/src/native/mic_capture.swift +92 -0
- package/src/native-pack.ts +6 -5
- package/src/pi-web.ts +9 -1
- package/src/preflight.ts +31 -15
- package/src/prompt-cache.ts +306 -86
- package/src/runtime-config.ts +1 -5
- package/src/safetensors.ts +9 -2
- package/src/sampler.ts +37 -25
- package/src/serve/audio-routes.ts +323 -0
- package/src/serve/chat-request.ts +2 -0
- package/src/serve/chat-stage.ts +19 -14
- package/src/serve/completion-executor.ts +9 -1
- package/src/serve/discovery-routes.ts +17 -1
- package/src/serve/generated-token-history.ts +57 -0
- package/src/serve/generation-gateway.ts +88 -38
- package/src/serve/model-binding.ts +7 -1
- package/src/serve/model-host.ts +3 -4
- package/src/serve/preparation.ts +3 -3
- package/src/serve/prompt-response-trace.ts +14 -0
- package/src/serve/request-plan.ts +6 -3
- package/src/serve/request-prep.ts +40 -25
- package/src/serve/text-completion-stage.ts +2 -2
- package/src/serve/transcription-server.ts +64 -0
- package/src/serve/transcription-service.ts +502 -0
- package/src/server.ts +185 -261
- package/src/spec/assistant-rows.ts +93 -0
- package/src/spec/assistant-source.ts +28 -18
- package/src/spec/cache-identity.ts +22 -0
- package/src/spec/deepspec-rows.ts +29 -0
- package/src/spec/deepspec-source.ts +12 -10
- package/src/spec/dflash-source.ts +28 -4
- package/src/spec/drafter.ts +153 -142
- package/src/spec/dspark/deepspec-module.ts +95 -120
- package/src/spec/dspark/module-dflash.ts +126 -170
- package/src/spec/glm52-mtp-graph.ts +29 -0
- package/src/spec/glm52-mtp-rows.ts +132 -0
- package/src/spec/glm52-mtp-source.ts +54 -136
- package/src/spec/glm52-mtp-state.ts +33 -0
- package/src/spec/ngram-source.ts +115 -21
- package/src/spec/projected-draft-rows.ts +73 -0
- package/src/spec/qwen-mtp-module.ts +171 -0
- package/src/spec/qwen-mtp-rows.ts +230 -0
- package/src/spec/qwen-mtp-source.ts +84 -226
- package/src/spec/qwen-mtp-state.ts +30 -0
- package/src/spec/serve-loop.ts +73 -69
- package/src/spec/source.ts +93 -15
- package/src/spec/two-model-rows.ts +62 -0
- package/src/spec/two-model.ts +45 -103
- package/src/ssd-cache.ts +107 -36
- package/src/ssd-durability.ts +29 -27
- package/src/storage/host-buffer.ts +24 -0
- package/src/storage/kv-writer.ts +83 -0
- package/src/storage/kv-writer.worker.js +264 -0
- package/src/storage/retention-policy.ts +37 -0
- package/src/tiered-prompt-cache.ts +45 -0
- package/src/vision/embedder.ts +8 -0
- package/src/vision/prompt.ts +101 -65
- package/src/vision/qwen3vl-prompt.ts +101 -64
- package/src/vision/qwen3vl-tower.ts +11 -0
- package/src/vision/siglip.ts +8 -0
- package/src/web/app.html +12 -0
- package/src/web/app.js +217 -57
- package/src/web/src/chat.ts +4 -1
- package/src/web/src/composer.ts +3 -0
- package/src/web/src/voice.ts +141 -0
- package/src/web/sw.js +1 -1
- package/src/backends/mlx/memory-guard.ts +0 -40
- package/src/spec/prefix-state.ts +0 -45
- /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
package/src/web/app.js
CHANGED
|
@@ -994,14 +994,14 @@ function highlightIn(container) {
|
|
|
994
994
|
return;
|
|
995
995
|
const blocks = container.querySelectorAll("pre code");
|
|
996
996
|
for (const block of blocks) {
|
|
997
|
-
const
|
|
998
|
-
if (
|
|
997
|
+
const el = block;
|
|
998
|
+
if (el.dataset.highlighted)
|
|
999
999
|
continue;
|
|
1000
|
-
const hasLang = /\blanguage-/.test(
|
|
1001
|
-
if (!hasLang && (
|
|
1000
|
+
const hasLang = /\blanguage-/.test(el.className);
|
|
1001
|
+
if (!hasLang && (el.textContent || "").length > 20000)
|
|
1002
1002
|
continue;
|
|
1003
1003
|
try {
|
|
1004
|
-
hljs.highlightElement(
|
|
1004
|
+
hljs.highlightElement(el);
|
|
1005
1005
|
} catch {}
|
|
1006
1006
|
}
|
|
1007
1007
|
}
|
|
@@ -1209,6 +1209,7 @@ function readText(file) {
|
|
|
1209
1209
|
class ComposerState {
|
|
1210
1210
|
attachments = [];
|
|
1211
1211
|
visionCapable = false;
|
|
1212
|
+
transcriptionCapable = false;
|
|
1212
1213
|
thinkingCapable = false;
|
|
1213
1214
|
thinkingOn = true;
|
|
1214
1215
|
attachSeq = 0;
|
|
@@ -1321,6 +1322,9 @@ function updateAttachHint(state) {
|
|
|
1321
1322
|
const btn = $("chat-attach-btn");
|
|
1322
1323
|
if (btn)
|
|
1323
1324
|
btn.title = state.visionCapable ? "Attach files or images" : "Attach files (this model can't see images)";
|
|
1325
|
+
const mic = $("chat-mic-btn");
|
|
1326
|
+
if (mic)
|
|
1327
|
+
mic.style.display = state.transcriptionCapable ? "" : "none";
|
|
1324
1328
|
}
|
|
1325
1329
|
function escHtml2(s) {
|
|
1326
1330
|
return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
@@ -1983,6 +1987,160 @@ function renderQueue(q) {
|
|
|
1983
1987
|
bar.style.display = parts.length ? "flex" : "none";
|
|
1984
1988
|
}
|
|
1985
1989
|
|
|
1990
|
+
// src/web/src/voice.ts
|
|
1991
|
+
var SAMPLE_RATE = 16000;
|
|
1992
|
+
var CHUNK_SECONDS = 0.25;
|
|
1993
|
+
var take = null;
|
|
1994
|
+
var starting = false;
|
|
1995
|
+
async function postChunk(id, pcm) {
|
|
1996
|
+
const r = await fetch(`/v1/audio/sessions/${id}/audio`, {
|
|
1997
|
+
method: "POST",
|
|
1998
|
+
headers: { "content-type": "audio/pcm;rate=16000" },
|
|
1999
|
+
body: new Uint8Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength))
|
|
2000
|
+
});
|
|
2001
|
+
if (!r.ok)
|
|
2002
|
+
throw new Error(`audio chunk rejected (${r.status})`);
|
|
2003
|
+
}
|
|
2004
|
+
function flush(t) {
|
|
2005
|
+
if (t.pendingLen === 0)
|
|
2006
|
+
return;
|
|
2007
|
+
const buf = new Float32Array(t.pendingLen);
|
|
2008
|
+
let o = 0;
|
|
2009
|
+
for (const p of t.pending) {
|
|
2010
|
+
buf.set(p, o);
|
|
2011
|
+
o += p.length;
|
|
2012
|
+
}
|
|
2013
|
+
t.pending = [];
|
|
2014
|
+
t.pendingLen = 0;
|
|
2015
|
+
t.sending = t.sending.then(() => postChunk(t.id, buf)).catch((e) => {
|
|
2016
|
+
t.failed = e.message;
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
async function begin(btn) {
|
|
2020
|
+
if (take || starting)
|
|
2021
|
+
return;
|
|
2022
|
+
starting = true;
|
|
2023
|
+
btn.classList.add("busy");
|
|
2024
|
+
try {
|
|
2025
|
+
const r = await fetch("/v1/audio/sessions", {
|
|
2026
|
+
method: "POST",
|
|
2027
|
+
headers: { "content-type": "application/json" },
|
|
2028
|
+
body: JSON.stringify({ vad: true, vad_min_speech_ms: 120, temperature: 0 })
|
|
2029
|
+
});
|
|
2030
|
+
if (!r.ok)
|
|
2031
|
+
throw new Error(r.status === 503 ? "no Whisper model on this server" : `session failed (${r.status})`);
|
|
2032
|
+
const { id } = await r.json();
|
|
2033
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
|
2034
|
+
const ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
2035
|
+
const src = ctx.createMediaStreamSource(stream);
|
|
2036
|
+
const node = ctx.createScriptProcessor(4096, 1, 1);
|
|
2037
|
+
const t = { id, ctx, stream, node, pending: [], pendingLen: 0, sending: Promise.resolve(), failed: null };
|
|
2038
|
+
node.onaudioprocess = (e) => {
|
|
2039
|
+
if (take !== t)
|
|
2040
|
+
return;
|
|
2041
|
+
const data = e.inputBuffer.getChannelData(0);
|
|
2042
|
+
t.pending.push(new Float32Array(data));
|
|
2043
|
+
t.pendingLen += data.length;
|
|
2044
|
+
if (t.pendingLen >= SAMPLE_RATE * CHUNK_SECONDS)
|
|
2045
|
+
flush(t);
|
|
2046
|
+
};
|
|
2047
|
+
src.connect(node);
|
|
2048
|
+
node.connect(ctx.destination);
|
|
2049
|
+
take = t;
|
|
2050
|
+
btn.classList.add("recording");
|
|
2051
|
+
} catch (e) {
|
|
2052
|
+
toast(`Voice input failed: ${e.message}`, "err");
|
|
2053
|
+
} finally {
|
|
2054
|
+
starting = false;
|
|
2055
|
+
btn.classList.remove("busy");
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
async function end(btn, box) {
|
|
2059
|
+
const t = take;
|
|
2060
|
+
if (!t)
|
|
2061
|
+
return;
|
|
2062
|
+
take = null;
|
|
2063
|
+
btn.classList.remove("recording");
|
|
2064
|
+
btn.classList.add("busy");
|
|
2065
|
+
try {
|
|
2066
|
+
t.node.disconnect();
|
|
2067
|
+
for (const track of t.stream.getTracks())
|
|
2068
|
+
track.stop();
|
|
2069
|
+
t.ctx.close();
|
|
2070
|
+
flush(t);
|
|
2071
|
+
await t.sending;
|
|
2072
|
+
if (t.failed)
|
|
2073
|
+
throw new Error(t.failed);
|
|
2074
|
+
const r = await fetch(`/v1/audio/sessions/${t.id}/finish`, { method: "POST" });
|
|
2075
|
+
if (!r.ok)
|
|
2076
|
+
throw new Error(`transcription failed (${r.status})`);
|
|
2077
|
+
const body = await r.json();
|
|
2078
|
+
const text = (body.text || "").trim();
|
|
2079
|
+
if (!text) {
|
|
2080
|
+
toast(body.mlx_bun?.vad?.speech === false ? "No speech detected" : "Nothing transcribed", "");
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
insertAtCaret(box, text);
|
|
2084
|
+
} catch (e) {
|
|
2085
|
+
toast(`Voice input failed: ${e.message}`, "err");
|
|
2086
|
+
} finally {
|
|
2087
|
+
btn.classList.remove("busy");
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
function insertAtCaret(box, text) {
|
|
2091
|
+
const start = box.selectionStart ?? box.value.length;
|
|
2092
|
+
const endPos = box.selectionEnd ?? start;
|
|
2093
|
+
const before = box.value.slice(0, start);
|
|
2094
|
+
const after = box.value.slice(endPos);
|
|
2095
|
+
const lead = before.length && !/\s$/.test(before) ? " " : "";
|
|
2096
|
+
const trail = after.length && !/^\s/.test(after) ? " " : "";
|
|
2097
|
+
box.value = before + lead + text + trail + after;
|
|
2098
|
+
const caret = (before + lead + text + trail).length;
|
|
2099
|
+
box.setSelectionRange(caret, caret);
|
|
2100
|
+
box.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2101
|
+
box.focus();
|
|
2102
|
+
}
|
|
2103
|
+
function initVoiceInput(btn, box) {
|
|
2104
|
+
if (!btn)
|
|
2105
|
+
return;
|
|
2106
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
2107
|
+
btn.title = "Microphone capture needs a secure context (https or localhost)";
|
|
2108
|
+
}
|
|
2109
|
+
btn.addEventListener("pointerdown", (e) => {
|
|
2110
|
+
e.preventDefault();
|
|
2111
|
+
btn.setPointerCapture(e.pointerId);
|
|
2112
|
+
begin(btn);
|
|
2113
|
+
});
|
|
2114
|
+
const release = () => {
|
|
2115
|
+
end(btn, box);
|
|
2116
|
+
};
|
|
2117
|
+
btn.addEventListener("pointerup", release);
|
|
2118
|
+
btn.addEventListener("pointercancel", release);
|
|
2119
|
+
btn.addEventListener("lostpointercapture", release);
|
|
2120
|
+
let keyHeld = false;
|
|
2121
|
+
btn.addEventListener("keydown", (e) => {
|
|
2122
|
+
if ((e.key === " " || e.key === "Enter") && !keyHeld) {
|
|
2123
|
+
e.preventDefault();
|
|
2124
|
+
keyHeld = true;
|
|
2125
|
+
begin(btn);
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
btn.addEventListener("keyup", (e) => {
|
|
2129
|
+
if ((e.key === " " || e.key === "Enter") && keyHeld) {
|
|
2130
|
+
e.preventDefault();
|
|
2131
|
+
keyHeld = false;
|
|
2132
|
+
release();
|
|
2133
|
+
}
|
|
2134
|
+
});
|
|
2135
|
+
btn.addEventListener("blur", () => {
|
|
2136
|
+
if (keyHeld) {
|
|
2137
|
+
keyHeld = false;
|
|
2138
|
+
release();
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
$.voice = { begin: () => begin(btn), end: () => end(btn, box) };
|
|
2142
|
+
}
|
|
2143
|
+
|
|
1986
2144
|
// src/web/src/sessions.ts
|
|
1987
2145
|
var SESSIONS_STYLE_ID = "mlxbun-sessions-style";
|
|
1988
2146
|
function injectSessionsStyles() {
|
|
@@ -2611,12 +2769,12 @@ async function renderListBody() {
|
|
|
2611
2769
|
async function renderArticleLists(query) {
|
|
2612
2770
|
const listEl = $("mem-list");
|
|
2613
2771
|
if (!query) {
|
|
2614
|
-
const
|
|
2615
|
-
if (!
|
|
2772
|
+
const d = await getJson("/api/memory/list").catch(() => ({ ok: false }));
|
|
2773
|
+
if (!d.ok) {
|
|
2616
2774
|
listEl.innerHTML = `<div class="mem-list-empty">Couldn't load the vault contents.</div>`;
|
|
2617
2775
|
return;
|
|
2618
2776
|
}
|
|
2619
|
-
const articles =
|
|
2777
|
+
const articles = d.articles || [], reference = d.reference || [];
|
|
2620
2778
|
listEl.innerHTML = '<div class="mem-sec"><div class="mem-sec-title">Articles</div>' + (articles.length ? articles.map((a) => articleRowHtml(a)).join("") : '<div class="mem-list-empty">No articles yet — nightly synthesis writes here after a few conversations.</div>') + "</div>" + (reference.length ? '<div class="mem-sec"><div class="mem-sec-title">Reference</div>' + reference.map((r) => articleRowHtml(r, undefined, "\uD83D\uDCD8")).join("") + "</div>" : "");
|
|
2621
2779
|
wireArticleRows(listEl);
|
|
2622
2780
|
return;
|
|
@@ -2953,11 +3111,11 @@ var INTERACTIVE_SELECTOR = [
|
|
|
2953
3111
|
"[data-spotlight]"
|
|
2954
3112
|
].join(", ");
|
|
2955
3113
|
var MAX_ELEMENTS = 120;
|
|
2956
|
-
function isAgentChrome(
|
|
2957
|
-
return !!
|
|
3114
|
+
function isAgentChrome(el) {
|
|
3115
|
+
return !!el.closest("[data-ui-chrome]") || !!el.closest("#toasts");
|
|
2958
3116
|
}
|
|
2959
|
-
function isHiddenSelf(
|
|
2960
|
-
const html =
|
|
3117
|
+
function isHiddenSelf(el) {
|
|
3118
|
+
const html = el;
|
|
2961
3119
|
if (html.hidden)
|
|
2962
3120
|
return true;
|
|
2963
3121
|
const inline = html.style?.display;
|
|
@@ -2968,21 +3126,21 @@ function isHiddenSelf(el2) {
|
|
|
2968
3126
|
return true;
|
|
2969
3127
|
return false;
|
|
2970
3128
|
}
|
|
2971
|
-
function isVisible(
|
|
2972
|
-
const html =
|
|
3129
|
+
function isVisible(el) {
|
|
3130
|
+
const html = el;
|
|
2973
3131
|
if (html.offsetParent === null) {
|
|
2974
3132
|
const style = globalThis.getComputedStyle?.(html);
|
|
2975
3133
|
if (style && style.position === "fixed")
|
|
2976
3134
|
return true;
|
|
2977
3135
|
}
|
|
2978
|
-
for (let node =
|
|
3136
|
+
for (let node = el;node; node = node.parentElement) {
|
|
2979
3137
|
if (isHiddenSelf(node))
|
|
2980
3138
|
return false;
|
|
2981
3139
|
}
|
|
2982
3140
|
return true;
|
|
2983
3141
|
}
|
|
2984
|
-
function elementLabel(
|
|
2985
|
-
const html =
|
|
3142
|
+
function elementLabel(el) {
|
|
3143
|
+
const html = el;
|
|
2986
3144
|
const uiLabel = html.getAttribute("data-ui-label")?.trim();
|
|
2987
3145
|
if (uiLabel)
|
|
2988
3146
|
return uiLabel;
|
|
@@ -3017,13 +3175,13 @@ function captureUiSnapshot(route) {
|
|
|
3017
3175
|
const elements = [];
|
|
3018
3176
|
const seenRefs = new Set;
|
|
3019
3177
|
let index = 0;
|
|
3020
|
-
for (const
|
|
3021
|
-
if (isAgentChrome(
|
|
3178
|
+
for (const el of nodes) {
|
|
3179
|
+
if (isAgentChrome(el) || !isVisible(el))
|
|
3022
3180
|
continue;
|
|
3023
|
-
const label = elementLabel(
|
|
3181
|
+
const label = elementLabel(el);
|
|
3024
3182
|
if (!label)
|
|
3025
3183
|
continue;
|
|
3026
|
-
const html =
|
|
3184
|
+
const html = el;
|
|
3027
3185
|
const spotlightId = html.getAttribute("data-spotlight")?.trim() || undefined;
|
|
3028
3186
|
const existingRef = html.getAttribute("data-ui-ref");
|
|
3029
3187
|
const ref = existingRef ?? `ui-${route.replace(/[^a-zA-Z0-9]/g, "_") || "root"}-${index}`;
|
|
@@ -3067,12 +3225,12 @@ function findByLabel(snapshot, label) {
|
|
|
3067
3225
|
return;
|
|
3068
3226
|
let best;
|
|
3069
3227
|
let bestScore = 0;
|
|
3070
|
-
for (const
|
|
3071
|
-
const hay = [
|
|
3228
|
+
for (const el of snapshot.elements) {
|
|
3229
|
+
const hay = [el.label, el.spotlightId].filter(Boolean).join(" ");
|
|
3072
3230
|
const score = matchScore(hay, label);
|
|
3073
3231
|
if (score > bestScore) {
|
|
3074
3232
|
bestScore = score;
|
|
3075
|
-
best =
|
|
3233
|
+
best = el;
|
|
3076
3234
|
}
|
|
3077
3235
|
}
|
|
3078
3236
|
return bestScore >= 50 ? best : undefined;
|
|
@@ -3089,9 +3247,9 @@ function safeQuery(selector) {
|
|
|
3089
3247
|
}
|
|
3090
3248
|
function resolveSpotlightTarget(request, snapshot) {
|
|
3091
3249
|
if (request.ref) {
|
|
3092
|
-
const
|
|
3093
|
-
if (
|
|
3094
|
-
return { selector:
|
|
3250
|
+
const el = snapshot?.elements.find((e) => e.ref === request.ref);
|
|
3251
|
+
if (el)
|
|
3252
|
+
return { selector: el.selector, title: el.label, message: request.message };
|
|
3095
3253
|
const selector = `[data-ui-ref="${attrEscape(request.ref)}"]`;
|
|
3096
3254
|
if (safeQuery(selector)) {
|
|
3097
3255
|
return { selector, title: request.label ?? request.ref, message: request.message };
|
|
@@ -3101,9 +3259,9 @@ function resolveSpotlightTarget(request, snapshot) {
|
|
|
3101
3259
|
return { selector: request.selector, title: request.label ?? "Here", message: request.message };
|
|
3102
3260
|
}
|
|
3103
3261
|
if (request.label) {
|
|
3104
|
-
const
|
|
3105
|
-
if (
|
|
3106
|
-
return { selector:
|
|
3262
|
+
const el = findByLabel(snapshot, request.label);
|
|
3263
|
+
if (el)
|
|
3264
|
+
return { selector: el.selector, title: el.label, message: request.message };
|
|
3107
3265
|
let bestEl = null;
|
|
3108
3266
|
let bestScore = 0;
|
|
3109
3267
|
for (const node of document.querySelectorAll("[data-ui-label]")) {
|
|
@@ -3144,15 +3302,15 @@ var AUTO_DISMISS_MS = 3000;
|
|
|
3144
3302
|
var dismissTimer = null;
|
|
3145
3303
|
var dismissListeners = null;
|
|
3146
3304
|
function ensureOverlayEl() {
|
|
3147
|
-
let
|
|
3148
|
-
if (!
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
document.body.appendChild(
|
|
3305
|
+
let el = document.getElementById("assistant-spotlight");
|
|
3306
|
+
if (!el) {
|
|
3307
|
+
el = document.createElement("div");
|
|
3308
|
+
el.id = "assistant-spotlight";
|
|
3309
|
+
el.setAttribute("data-ui-chrome", "assistant");
|
|
3310
|
+
el.innerHTML = '<div class="asr-ring"></div><div class="asr-pop"><div class="asr-pop-text"></div></div>';
|
|
3311
|
+
document.body.appendChild(el);
|
|
3154
3312
|
}
|
|
3155
|
-
return
|
|
3313
|
+
return el;
|
|
3156
3314
|
}
|
|
3157
3315
|
function dismissSpotlight() {
|
|
3158
3316
|
if (dismissTimer) {
|
|
@@ -3163,9 +3321,9 @@ function dismissSpotlight() {
|
|
|
3163
3321
|
dismissListeners();
|
|
3164
3322
|
dismissListeners = null;
|
|
3165
3323
|
}
|
|
3166
|
-
const
|
|
3167
|
-
if (
|
|
3168
|
-
|
|
3324
|
+
const el = document.getElementById("assistant-spotlight");
|
|
3325
|
+
if (el)
|
|
3326
|
+
el.classList.remove("show");
|
|
3169
3327
|
}
|
|
3170
3328
|
function showSpotlight(resolved) {
|
|
3171
3329
|
const target = safeQuery(resolved.selector);
|
|
@@ -3332,7 +3490,7 @@ function createChatController() {
|
|
|
3332
3490
|
}
|
|
3333
3491
|
return false;
|
|
3334
3492
|
}
|
|
3335
|
-
function
|
|
3493
|
+
function currentView() {
|
|
3336
3494
|
if ($("mem-overlay")?.classList.contains("open"))
|
|
3337
3495
|
return "memory-panel";
|
|
3338
3496
|
if ($("hub-overlay")?.classList.contains("open"))
|
|
@@ -3345,16 +3503,16 @@ function createChatController() {
|
|
|
3345
3503
|
const route = currentRoute();
|
|
3346
3504
|
if (!isRouteId(route))
|
|
3347
3505
|
return;
|
|
3348
|
-
const ctx = buildAppContext(route,
|
|
3506
|
+
const ctx = buildAppContext(route, currentView());
|
|
3349
3507
|
lastSnapshot = ctx.snapshot;
|
|
3350
3508
|
send({ type: "context", context: ctx });
|
|
3351
3509
|
}
|
|
3352
3510
|
function watchWizardSteps() {
|
|
3353
3511
|
const mo = new MutationObserver(() => pushAppContext());
|
|
3354
3512
|
for (const id of ["q-steps", "f-steps", "d-steps"]) {
|
|
3355
|
-
const
|
|
3356
|
-
if (
|
|
3357
|
-
mo.observe(
|
|
3513
|
+
const el = $(id);
|
|
3514
|
+
if (el)
|
|
3515
|
+
mo.observe(el, { childList: true, subtree: true });
|
|
3358
3516
|
}
|
|
3359
3517
|
}
|
|
3360
3518
|
function setChatStatus(s) {
|
|
@@ -3548,8 +3706,8 @@ function createChatController() {
|
|
|
3548
3706
|
if (t)
|
|
3549
3707
|
return t;
|
|
3550
3708
|
if (isMemoryToolName(tool || "")) {
|
|
3551
|
-
const
|
|
3552
|
-
t = { kind: "memchip", wrap:
|
|
3709
|
+
const handle = memoryToolChip(a.bubble, tool, args);
|
|
3710
|
+
t = { kind: "memchip", wrap: handle.wrap, handle, chunks: "" };
|
|
3553
3711
|
a.tools.set(callId, t);
|
|
3554
3712
|
stick();
|
|
3555
3713
|
return t;
|
|
@@ -3763,9 +3921,9 @@ function createChatController() {
|
|
|
3763
3921
|
}
|
|
3764
3922
|
for (const t of item.tools || []) {
|
|
3765
3923
|
if (isMemoryToolName(t.name || "")) {
|
|
3766
|
-
const
|
|
3924
|
+
const handle = memoryToolChip(bubble, t.name, t.args);
|
|
3767
3925
|
if (t.result)
|
|
3768
|
-
|
|
3926
|
+
handle.setResult(t.result);
|
|
3769
3927
|
continue;
|
|
3770
3928
|
}
|
|
3771
3929
|
const wrap = el("div", "tool ok", bubble);
|
|
@@ -3809,6 +3967,7 @@ function createChatController() {
|
|
|
3809
3967
|
case "ready":
|
|
3810
3968
|
$("nav-model").textContent = m.model || $("nav-model").textContent || "no model";
|
|
3811
3969
|
composer.visionCapable = !!m.vision;
|
|
3970
|
+
composer.transcriptionCapable = !!m.transcription;
|
|
3812
3971
|
updateAttachHint(composer);
|
|
3813
3972
|
composer.thinkingCapable = !!m.thinking;
|
|
3814
3973
|
updateThinkingToggle(composer);
|
|
@@ -4026,6 +4185,7 @@ function createChatController() {
|
|
|
4026
4185
|
});
|
|
4027
4186
|
initMemoryPanel();
|
|
4028
4187
|
$("chat-attach-btn").onclick = () => $("chat-file-input").click();
|
|
4188
|
+
initVoiceInput($("chat-mic-btn"), box);
|
|
4029
4189
|
$("chat-file-input").addEventListener("change", (e) => {
|
|
4030
4190
|
addFiles(composer, [...e.target.files || []]);
|
|
4031
4191
|
e.target.value = "";
|
|
@@ -4081,9 +4241,9 @@ function createChatController() {
|
|
|
4081
4241
|
watchWizardSteps();
|
|
4082
4242
|
const overlayMo = new MutationObserver(() => pushAppContext());
|
|
4083
4243
|
for (const id of ["mem-overlay", "hub-overlay", "adapters-overlay"]) {
|
|
4084
|
-
const
|
|
4085
|
-
if (
|
|
4086
|
-
overlayMo.observe(
|
|
4244
|
+
const el = $(id);
|
|
4245
|
+
if (el)
|
|
4246
|
+
overlayMo.observe(el, { attributes: true, attributeFilter: ["class"] });
|
|
4087
4247
|
}
|
|
4088
4248
|
},
|
|
4089
4249
|
enter() {
|
|
@@ -4893,7 +5053,7 @@ function createStatusController() {
|
|
|
4893
5053
|
async function loadFit() {
|
|
4894
5054
|
let f;
|
|
4895
5055
|
try {
|
|
4896
|
-
f = await fetch("/fit").then((
|
|
5056
|
+
f = await fetch("/fit").then((r) => r.json());
|
|
4897
5057
|
} catch {
|
|
4898
5058
|
return;
|
|
4899
5059
|
}
|
|
@@ -5319,9 +5479,9 @@ async function openHubPanel() {
|
|
|
5319
5479
|
if (inFlightDownloads.size)
|
|
5320
5480
|
ensureDownloadPolling();
|
|
5321
5481
|
setTimeout(() => {
|
|
5322
|
-
const
|
|
5323
|
-
if (
|
|
5324
|
-
|
|
5482
|
+
const el2 = $("hub-search-input");
|
|
5483
|
+
if (el2)
|
|
5484
|
+
el2.focus();
|
|
5325
5485
|
}, 30);
|
|
5326
5486
|
}
|
|
5327
5487
|
function setOfflineFooter(offline) {
|
package/src/web/src/chat.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
renderAttachments, renderContext, renderLane, renderQueue, updateAttachHint,
|
|
31
31
|
updateThinkingToggle, type ContextFrame,
|
|
32
32
|
} from "./composer";
|
|
33
|
+
import { initVoiceInput } from "./voice";
|
|
33
34
|
import {
|
|
34
35
|
addMsgActions, newSiblingInfo, newSidebarState, renderSessions, renderSiblingToggle,
|
|
35
36
|
switchSiblingTarget, applySessSearch, type SidebarState, type SiblingInfo,
|
|
@@ -764,7 +765,7 @@ export function createChatController() {
|
|
|
764
765
|
switch (m.type) {
|
|
765
766
|
case "ready":
|
|
766
767
|
$("nav-model").textContent = m.model || ($("nav-model").textContent as string) || "no model";
|
|
767
|
-
composer.visionCapable = !!m.vision; updateAttachHint(composer);
|
|
768
|
+
composer.visionCapable = !!m.vision; composer.transcriptionCapable = !!m.transcription; updateAttachHint(composer);
|
|
768
769
|
composer.thinkingCapable = !!m.thinking; updateThinkingToggle(composer);
|
|
769
770
|
// Per-model recommended sampling (generation_config.json, server-CLI
|
|
770
771
|
// overrides applied) — replaces the old hardcoded SAMP_REC shape.
|
|
@@ -979,6 +980,8 @@ export function createChatController() {
|
|
|
979
980
|
initMemoryPanel();
|
|
980
981
|
// Attachments: + button, file picker, clipboard paste, drag-and-drop.
|
|
981
982
|
($("chat-attach-btn") as HTMLButtonElement).onclick = () => ($("chat-file-input") as HTMLInputElement).click();
|
|
983
|
+
// Hold-to-talk mic → /v1/audio/sessions → text into the composer (voice.ts).
|
|
984
|
+
initVoiceInput($("chat-mic-btn") as HTMLButtonElement, box as HTMLTextAreaElement);
|
|
982
985
|
$("chat-file-input").addEventListener("change", (e) => { addFiles(composer, [...(e.target as HTMLInputElement).files || []]); (e.target as HTMLInputElement).value = ""; });
|
|
983
986
|
box.addEventListener("paste", (e) => {
|
|
984
987
|
const items = e.clipboardData && e.clipboardData.items;
|
package/src/web/src/composer.ts
CHANGED
|
@@ -55,6 +55,7 @@ function readText(file: File): Promise<string> {
|
|
|
55
55
|
export class ComposerState {
|
|
56
56
|
attachments: Attachment[] = [];
|
|
57
57
|
visionCapable = false; // from the `ready` frame; gates image attachments
|
|
58
|
+
transcriptionCapable = false; // from the `ready` frame; shows the hold-to-talk mic
|
|
58
59
|
thinkingCapable = false; // from `ready`; gates the reasoning toggle
|
|
59
60
|
// default on: with tools (read/web_search) the reasoning step gives the
|
|
60
61
|
// small model far better tool judgment (answers math/writing itself,
|
|
@@ -179,6 +180,8 @@ export function buildMessageText(state: ComposerState, userText: string): BuiltM
|
|
|
179
180
|
export function updateAttachHint(state: ComposerState): void {
|
|
180
181
|
const btn = $("chat-attach-btn");
|
|
181
182
|
if (btn) btn.title = state.visionCapable ? "Attach files or images" : "Attach files (this model can't see images)";
|
|
183
|
+
const mic = $("chat-mic-btn");
|
|
184
|
+
if (mic) mic.style.display = state.transcriptionCapable ? "" : "none";
|
|
182
185
|
}
|
|
183
186
|
|
|
184
187
|
/* ────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Hold-to-talk voice input for the composer: microphone → 16 kHz float32
|
|
2
|
+
// (an AudioContext opened at 16 kHz lets the browser resample the mic) →
|
|
3
|
+
// 250 ms chunks streamed to /v1/audio/sessions/<id>/audio while the button
|
|
4
|
+
// is held → finish on release → transcript inserted at the caret. Works
|
|
5
|
+
// with ANY served chat model: transcription is the Whisper companion, not
|
|
6
|
+
// the chat model's audio input. Shown only when `ready.transcription` is
|
|
7
|
+
// true (composer.ts updateAttachHint).
|
|
8
|
+
|
|
9
|
+
import { $, toast } from "./shell";
|
|
10
|
+
|
|
11
|
+
const SAMPLE_RATE = 16_000;
|
|
12
|
+
const CHUNK_SECONDS = 0.25;
|
|
13
|
+
|
|
14
|
+
interface Take {
|
|
15
|
+
id: string;
|
|
16
|
+
ctx: AudioContext;
|
|
17
|
+
stream: MediaStream;
|
|
18
|
+
node: ScriptProcessorNode;
|
|
19
|
+
pending: Float32Array[];
|
|
20
|
+
pendingLen: number;
|
|
21
|
+
sending: Promise<void>;
|
|
22
|
+
failed: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let take: Take | null = null;
|
|
26
|
+
let starting = false;
|
|
27
|
+
|
|
28
|
+
async function postChunk(id: string, pcm: Float32Array): Promise<void> {
|
|
29
|
+
const r = await fetch(`/v1/audio/sessions/${id}/audio`, {
|
|
30
|
+
method: "POST", headers: { "content-type": "audio/pcm;rate=16000" },
|
|
31
|
+
body: new Uint8Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength) as ArrayBuffer),
|
|
32
|
+
});
|
|
33
|
+
if (!r.ok) throw new Error(`audio chunk rejected (${r.status})`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function flush(t: Take): void {
|
|
37
|
+
if (t.pendingLen === 0) return;
|
|
38
|
+
const buf = new Float32Array(t.pendingLen);
|
|
39
|
+
let o = 0;
|
|
40
|
+
for (const p of t.pending) { buf.set(p, o); o += p.length; }
|
|
41
|
+
t.pending = []; t.pendingLen = 0;
|
|
42
|
+
t.sending = t.sending.then(() => postChunk(t.id, buf)).catch((e) => { t.failed = (e as Error).message; });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function begin(btn: HTMLButtonElement): Promise<void> {
|
|
46
|
+
if (take || starting) return;
|
|
47
|
+
starting = true;
|
|
48
|
+
btn.classList.add("busy");
|
|
49
|
+
try {
|
|
50
|
+
const r = await fetch("/v1/audio/sessions", {
|
|
51
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ vad: true, vad_min_speech_ms: 120, temperature: 0 }),
|
|
53
|
+
});
|
|
54
|
+
if (!r.ok) throw new Error(r.status === 503 ? "no Whisper model on this server" : `session failed (${r.status})`);
|
|
55
|
+
const { id } = await r.json() as { id: string };
|
|
56
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
|
57
|
+
const ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
58
|
+
const src = ctx.createMediaStreamSource(stream);
|
|
59
|
+
// ScriptProcessorNode is deprecated but universally available and enough
|
|
60
|
+
// for 16 kHz mono capture; an AudioWorklet needs a separately served file.
|
|
61
|
+
const node = ctx.createScriptProcessor(4096, 1, 1);
|
|
62
|
+
const t: Take = { id, ctx, stream, node, pending: [], pendingLen: 0, sending: Promise.resolve(), failed: null };
|
|
63
|
+
node.onaudioprocess = (e) => {
|
|
64
|
+
if (take !== t) return;
|
|
65
|
+
const data = e.inputBuffer.getChannelData(0);
|
|
66
|
+
t.pending.push(new Float32Array(data));
|
|
67
|
+
t.pendingLen += data.length;
|
|
68
|
+
if (t.pendingLen >= SAMPLE_RATE * CHUNK_SECONDS) flush(t);
|
|
69
|
+
};
|
|
70
|
+
src.connect(node);
|
|
71
|
+
node.connect(ctx.destination); // required for onaudioprocess to fire in some browsers
|
|
72
|
+
take = t;
|
|
73
|
+
btn.classList.add("recording");
|
|
74
|
+
} catch (e) {
|
|
75
|
+
toast(`Voice input failed: ${(e as Error).message}`, "err");
|
|
76
|
+
} finally {
|
|
77
|
+
starting = false;
|
|
78
|
+
btn.classList.remove("busy");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function end(btn: HTMLButtonElement, box: HTMLTextAreaElement): Promise<void> {
|
|
83
|
+
const t = take;
|
|
84
|
+
if (!t) return;
|
|
85
|
+
take = null;
|
|
86
|
+
btn.classList.remove("recording");
|
|
87
|
+
btn.classList.add("busy");
|
|
88
|
+
try {
|
|
89
|
+
t.node.disconnect();
|
|
90
|
+
for (const track of t.stream.getTracks()) track.stop();
|
|
91
|
+
void t.ctx.close();
|
|
92
|
+
flush(t);
|
|
93
|
+
await t.sending;
|
|
94
|
+
if (t.failed) throw new Error(t.failed);
|
|
95
|
+
const r = await fetch(`/v1/audio/sessions/${t.id}/finish`, { method: "POST" });
|
|
96
|
+
if (!r.ok) throw new Error(`transcription failed (${r.status})`);
|
|
97
|
+
const body = await r.json() as { text: string; mlx_bun?: { vad?: { speech: boolean } } };
|
|
98
|
+
const text = (body.text || "").trim();
|
|
99
|
+
if (!text) { toast(body.mlx_bun?.vad?.speech === false ? "No speech detected" : "Nothing transcribed", ""); return; }
|
|
100
|
+
insertAtCaret(box, text);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
toast(`Voice input failed: ${(e as Error).message}`, "err");
|
|
103
|
+
} finally {
|
|
104
|
+
btn.classList.remove("busy");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function insertAtCaret(box: HTMLTextAreaElement, text: string): void {
|
|
109
|
+
const start = box.selectionStart ?? box.value.length;
|
|
110
|
+
const endPos = box.selectionEnd ?? start;
|
|
111
|
+
const before = box.value.slice(0, start);
|
|
112
|
+
const after = box.value.slice(endPos);
|
|
113
|
+
const lead = before.length && !/\s$/.test(before) ? " " : "";
|
|
114
|
+
const trail = after.length && !/^\s/.test(after) ? " " : "";
|
|
115
|
+
box.value = before + lead + text + trail + after;
|
|
116
|
+
const caret = (before + lead + text + trail).length;
|
|
117
|
+
box.setSelectionRange(caret, caret);
|
|
118
|
+
box.dispatchEvent(new Event("input", { bubbles: true }));
|
|
119
|
+
box.focus();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Wire hold-to-talk on `btn`: pointer hold, or Space/Enter held while focused. */
|
|
123
|
+
export function initVoiceInput(btn: HTMLButtonElement, box: HTMLTextAreaElement): void {
|
|
124
|
+
if (!btn) return;
|
|
125
|
+
if (!navigator.mediaDevices?.getUserMedia) { btn.title = "Microphone capture needs a secure context (https or localhost)"; }
|
|
126
|
+
btn.addEventListener("pointerdown", (e) => { e.preventDefault(); btn.setPointerCapture(e.pointerId); void begin(btn); });
|
|
127
|
+
const release = () => { void end(btn, box); };
|
|
128
|
+
btn.addEventListener("pointerup", release);
|
|
129
|
+
btn.addEventListener("pointercancel", release);
|
|
130
|
+
btn.addEventListener("lostpointercapture", release);
|
|
131
|
+
let keyHeld = false;
|
|
132
|
+
btn.addEventListener("keydown", (e) => {
|
|
133
|
+
if ((e.key === " " || e.key === "Enter") && !keyHeld) { e.preventDefault(); keyHeld = true; void begin(btn); }
|
|
134
|
+
});
|
|
135
|
+
btn.addEventListener("keyup", (e) => {
|
|
136
|
+
if ((e.key === " " || e.key === "Enter") && keyHeld) { e.preventDefault(); keyHeld = false; release(); }
|
|
137
|
+
});
|
|
138
|
+
btn.addEventListener("blur", () => { if (keyHeld) { keyHeld = false; release(); } });
|
|
139
|
+
// expose for tests / the palette
|
|
140
|
+
($ as unknown as { voice?: unknown }).voice = { begin: () => begin(btn), end: () => end(btn, box) };
|
|
141
|
+
}
|
package/src/web/sw.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
//
|
|
19
19
|
// Versioned by CACHE_NAME so a deploy invalidates old shells; bump the
|
|
20
20
|
// suffix here if the shell file set changes.
|
|
21
|
-
const CACHE_NAME = "mlxbun-shell-
|
|
21
|
+
const CACHE_NAME = "mlxbun-shell-v2";
|
|
22
22
|
const SHELL_FILES = [
|
|
23
23
|
"/",
|
|
24
24
|
"/assets/app.js",
|