dsh-aha-tutor 1.4.0 → 1.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/AGENTS.md +2 -1
- package/CHANGELOG.md +7 -0
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/docs/guide.zh-CN.md +17 -2
- package/lib/client.js +48 -23
- package/lib/index.js +109 -2
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1640,11 +1640,74 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
|
|
|
1640
1640
|
if (!chunks.length) throw new MaterialError("page-no-text", "No readable text on this page");
|
|
1641
1641
|
return finish("url", title, chunks, { url: url.href });
|
|
1642
1642
|
}
|
|
1643
|
+
const BYTES_PER_SECOND = 32e3;
|
|
1644
|
+
const MAX_SPEECH_BYTES = 3840044;
|
|
1645
|
+
const MAX_TEXT = 2e3;
|
|
1646
|
+
const USABLE = /* @__PURE__ */ new Set(["ready", "standby"]);
|
|
1647
|
+
const PREPARING = /* @__PURE__ */ new Set([
|
|
1648
|
+
"checking",
|
|
1649
|
+
"downloading",
|
|
1650
|
+
"cancelling"
|
|
1651
|
+
]);
|
|
1652
|
+
function wavSeconds(bytes) {
|
|
1653
|
+
const data = bytes instanceof Uint8Array ? bytes : null;
|
|
1654
|
+
if (!data || data.length < 46) return null;
|
|
1655
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1656
|
+
const tag = (at) => String.fromCharCode(...data.subarray(at, at + 4));
|
|
1657
|
+
return tag(0) === "RIFF" && tag(8) === "WAVE" && tag(12) === "fmt " && tag(36) === "data" && view.getUint32(4, true) === data.length - 8 && view.getUint32(16, true) === 16 && view.getUint16(20, true) === 1 && view.getUint16(22, true) === 1 && view.getUint32(24, true) === 16e3 && view.getUint32(28, true) === BYTES_PER_SECOND && view.getUint16(32, true) === 2 && view.getUint16(34, true) === 16 && view.getUint32(40, true) === data.length - 44 && (data.length - 44) % 2 === 0 ? (data.length - 44) / BYTES_PER_SECOND : null;
|
|
1658
|
+
}
|
|
1659
|
+
function createSpeechInput({ getSpeech, logger }) {
|
|
1660
|
+
function status() {
|
|
1661
|
+
const speech = getSpeech();
|
|
1662
|
+
let snapshot = null;
|
|
1663
|
+
try {
|
|
1664
|
+
snapshot = speech?.snapshot?.() ?? null;
|
|
1665
|
+
} catch {
|
|
1666
|
+
snapshot = null;
|
|
1667
|
+
}
|
|
1668
|
+
const chosen = snapshot?.selection?.providerId;
|
|
1669
|
+
const provider = snapshot?.providers?.find((entry) => entry.id === chosen);
|
|
1670
|
+
if (!provider) return {
|
|
1671
|
+
state: "off",
|
|
1672
|
+
maxSeconds: 120
|
|
1673
|
+
};
|
|
1674
|
+
const phase = provider.preparation?.phase ?? "ready";
|
|
1675
|
+
return {
|
|
1676
|
+
state: USABLE.has(phase) ? "ready" : PREPARING.has(phase) ? "preparing" : phase === "failed" ? "failed" : "unprepared",
|
|
1677
|
+
maxSeconds: 120
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
async function transcribe({ audio, signal }) {
|
|
1681
|
+
const { state } = status();
|
|
1682
|
+
if (state === "off") throw new TutorError("speech-off", "Voice input is not enabled in DSH");
|
|
1683
|
+
if (state !== "ready") throw new TutorError("speech-unprepared", "The speech recognizer is not ready yet");
|
|
1684
|
+
const seconds = wavSeconds(audio);
|
|
1685
|
+
if (seconds === null) throw new TutorError("speech-audio", "The recording could not be read");
|
|
1686
|
+
if (seconds > 120) throw new TutorError("speech-too-long", `Recordings are limited to 120 seconds`);
|
|
1687
|
+
const speech = getSpeech();
|
|
1688
|
+
let result;
|
|
1689
|
+
try {
|
|
1690
|
+
result = await speech.transcribe(speech.resolve({ audio: Buffer.from(audio.buffer, audio.byteOffset, audio.byteLength) }), signal ?? new AbortController().signal);
|
|
1691
|
+
} catch (error) {
|
|
1692
|
+
if (signal?.aborted) throw error;
|
|
1693
|
+
logger?.debug?.("aha-tutor speech recognition failed: %s", error?.message);
|
|
1694
|
+
throw new TutorError("speech-failed", "Speech recognition failed");
|
|
1695
|
+
}
|
|
1696
|
+
const text = String(typeof result === "string" ? result : result?.text ?? "").replace(/\s+/gu, " ").trim().slice(0, MAX_TEXT);
|
|
1697
|
+
if (!text) throw new TutorError("speech-empty", "No speech was heard");
|
|
1698
|
+
return { text };
|
|
1699
|
+
}
|
|
1700
|
+
return Object.freeze({
|
|
1701
|
+
status,
|
|
1702
|
+
transcribe
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1643
1705
|
//#endregion
|
|
1644
1706
|
//#region src/host/http.js
|
|
1645
1707
|
const UPLOAD_PATH = "/api/aha-tutor/upload";
|
|
1646
1708
|
const TTS_PATH = "/api/aha-tutor/tts";
|
|
1647
1709
|
const FONT_PATH = "/api/aha-tutor/font";
|
|
1710
|
+
const SPEECH_PATH = "/api/aha-tutor/speech";
|
|
1648
1711
|
async function readCapped(stream, max, signal) {
|
|
1649
1712
|
const parts = [];
|
|
1650
1713
|
let size = 0;
|
|
@@ -1697,6 +1760,34 @@ function uploadRoute({ studio }) {
|
|
|
1697
1760
|
}
|
|
1698
1761
|
};
|
|
1699
1762
|
}
|
|
1763
|
+
function speechRoute({ speechInput }) {
|
|
1764
|
+
return {
|
|
1765
|
+
path: SPEECH_PATH,
|
|
1766
|
+
methods: ["POST"],
|
|
1767
|
+
requestBody: "streaming",
|
|
1768
|
+
fetch: async (request) => {
|
|
1769
|
+
let audio;
|
|
1770
|
+
try {
|
|
1771
|
+
audio = await readCapped(request.body, MAX_SPEECH_BYTES, request.signal);
|
|
1772
|
+
} catch {
|
|
1773
|
+
return failure$1("aborted", "Upload interrupted", 400);
|
|
1774
|
+
}
|
|
1775
|
+
if (audio === null) return failure$1("speech-too-long", "The recording is too long", 413);
|
|
1776
|
+
try {
|
|
1777
|
+
return json({
|
|
1778
|
+
ok: true,
|
|
1779
|
+
value: await speechInput.transcribe({
|
|
1780
|
+
audio: new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength),
|
|
1781
|
+
signal: request.signal
|
|
1782
|
+
})
|
|
1783
|
+
});
|
|
1784
|
+
} catch (error) {
|
|
1785
|
+
const known = error?.name === "TutorError";
|
|
1786
|
+
return failure$1(known ? publicCode(error) : "speech-failed", known ? error.message : "Speech recognition failed", known && error.code !== "speech-failed" ? 400 : 502);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1700
1791
|
function ttsRoute({ speak }) {
|
|
1701
1792
|
return {
|
|
1702
1793
|
path: TTS_PATH,
|
|
@@ -4215,7 +4306,7 @@ const STYLES = [
|
|
|
4215
4306
|
"simpler",
|
|
4216
4307
|
"deeper"
|
|
4217
4308
|
];
|
|
4218
|
-
function createRoutes({ courses, lessons, learner, studio, projects, jobs, settings, tts, plugin, info }) {
|
|
4309
|
+
function createRoutes({ courses, lessons, learner, studio, projects, jobs, settings, tts, plugin, info, speechInput }) {
|
|
4219
4310
|
return {
|
|
4220
4311
|
"app/info": () => info(),
|
|
4221
4312
|
"job/get": (payload) => jobs.get(need(payload, "id", "job"), { since: Math.max(0, Number(payload.since) || 0) }) ?? { status: "missing" },
|
|
@@ -4491,6 +4582,14 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
4491
4582
|
cached: result.cached === true
|
|
4492
4583
|
};
|
|
4493
4584
|
},
|
|
4585
|
+
"speech/status": () => speechInput.status(),
|
|
4586
|
+
"speech/transcribe": async (payload) => {
|
|
4587
|
+
const encoded = typeof payload.base64 === "string" ? payload.base64 : "";
|
|
4588
|
+
if (encoded.length > Math.ceil(3840044 / 3) * 4) throw new TutorError("speech-too-long", "The recording is too long");
|
|
4589
|
+
const audio = Buffer.from(encoded, "base64");
|
|
4590
|
+
if (!encoded || audio.toString("base64") !== encoded) throw new TutorError("speech-audio", "The recording could not be read");
|
|
4591
|
+
return speechInput.transcribe({ audio: new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength) });
|
|
4592
|
+
},
|
|
4494
4593
|
"settings/get": () => settings.get(),
|
|
4495
4594
|
"settings/save": (payload) => settings.save(payload.settings ?? {}),
|
|
4496
4595
|
"settings/set-key": (payload) => settings.setKey({
|
|
@@ -4998,6 +5097,7 @@ const PREF_DEFAULTS = Object.freeze({
|
|
|
4998
5097
|
voiceRate: 1,
|
|
4999
5098
|
boardTheme: "auto",
|
|
5000
5099
|
boardFont: "hand",
|
|
5100
|
+
boardView: "canvas",
|
|
5001
5101
|
ttsProvider: "openai",
|
|
5002
5102
|
tts: Object.fromEntries(TTS_PROVIDERS.map((provider) => [provider, providerDefaults(provider)]))
|
|
5003
5103
|
});
|
|
@@ -5028,6 +5128,7 @@ function cleanPrefs(input = {}, base = PREF_DEFAULTS) {
|
|
|
5028
5128
|
"dark"
|
|
5029
5129
|
].includes(input.boardTheme)) next.boardTheme = input.boardTheme;
|
|
5030
5130
|
if (["hand", "standard"].includes(input.boardFont)) next.boardFont = input.boardFont;
|
|
5131
|
+
if (["canvas", "pages"].includes(input.boardView)) next.boardView = input.boardView;
|
|
5031
5132
|
if (TTS_PROVIDERS.includes(input.ttsProvider)) next.ttsProvider = input.ttsProvider;
|
|
5032
5133
|
for (const provider of TTS_PROVIDERS) {
|
|
5033
5134
|
const patch = input.tts?.[provider];
|
|
@@ -5802,6 +5903,10 @@ function apply(ctx) {
|
|
|
5802
5903
|
keys,
|
|
5803
5904
|
llmPort: llm
|
|
5804
5905
|
});
|
|
5906
|
+
const speechInput = createSpeechInput({
|
|
5907
|
+
getSpeech: () => ctx.get?.("speechToText"),
|
|
5908
|
+
logger
|
|
5909
|
+
});
|
|
5805
5910
|
async function speak({ text, provider, signal }) {
|
|
5806
5911
|
await ready;
|
|
5807
5912
|
const current = prefs.read();
|
|
@@ -5838,7 +5943,8 @@ function apply(ctx) {
|
|
|
5838
5943
|
settings,
|
|
5839
5944
|
tts: { speak },
|
|
5840
5945
|
plugin,
|
|
5841
|
-
info
|
|
5946
|
+
info,
|
|
5947
|
+
speechInput
|
|
5842
5948
|
}),
|
|
5843
5949
|
"app/info": (payload) => info(payload),
|
|
5844
5950
|
"samples/list": () => samples.list(),
|
|
@@ -5853,6 +5959,7 @@ function apply(ctx) {
|
|
|
5853
5959
|
if (typeof fetchRoutes?.register !== "function") return;
|
|
5854
5960
|
connectionCtx.effect(() => fetchRoutes.register(uploadRoute({ studio })), "aha-tutor: upload route");
|
|
5855
5961
|
connectionCtx.effect(() => fetchRoutes.register(ttsRoute({ speak })), "aha-tutor: tts route");
|
|
5962
|
+
connectionCtx.effect(() => fetchRoutes.register(speechRoute({ speechInput })), "aha-tutor: speech route");
|
|
5856
5963
|
connectionCtx.effect(() => fetchRoutes.register(fontRoute()), "aha-tutor: font route");
|
|
5857
5964
|
});
|
|
5858
5965
|
ctx.effect(() => publishRuntime({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-aha-tutor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "开窍 Aha Tutor: a personal AI tutor for DeepSeek Harness that builds a course around you and teaches it on a narrated handwriting whiteboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|