shadok-ai 0.16.28 β 0.16.30
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 +14 -0
- package/dist/download.d.ts +18 -0
- package/dist/download.js +48 -1
- package/dist/download.js.map +1 -1
- package/dist/server.js +80 -20
- package/dist/server.js.map +1 -1
- package/dist/whisper.d.ts +8 -0
- package/dist/whisper.js +24 -0
- package/dist/whisper.js.map +1 -1
- package/package.json +1 -1
- package/public/index.html +279 -36
package/README.md
CHANGED
|
@@ -191,6 +191,13 @@ Enter to skip; you can add it later from the web UI).
|
|
|
191
191
|
`SHADOK_WHISPER_MODEL` picks a bigger one if your recordings need it.
|
|
192
192
|
Set `SHADOK_WHISPER_DIR` to a directory shared between containers and one copy
|
|
193
193
|
serves them all.
|
|
194
|
+
- **Dictate a prompt on the web** β a π button beside *Send* records your
|
|
195
|
+
microphone and transcribes it with the **same local whisper.cpp** the voice
|
|
196
|
+
notes use (nothing leaves the machine). The text drops into the composer
|
|
197
|
+
**editable**, never auto-sent, so a misheard word is fixed before it goes β
|
|
198
|
+
click to start, click to stop, a red timer runs while recording. Needs a
|
|
199
|
+
secure context (localhost or HTTPS) for the browser to grant the mic; the
|
|
200
|
+
first use pays the same one-time whisper install as the voice notes above.
|
|
194
201
|
- **Videos on Telegram** β share a video (or a GIF, or a round video note) and
|
|
195
202
|
the agent actually sees it: shadok pulls a handful of stills out of the clip
|
|
196
203
|
for it to look at, and transcribes the sound when there is speech. Both run
|
|
@@ -242,6 +249,13 @@ Enter to skip; you can add it later from the web UI).
|
|
|
242
249
|
otherwise just print a local path you cannot open. **Artifacts are denied**
|
|
243
250
|
cockpit-wide for the same reason β a page on claude.ai is not the file you
|
|
244
251
|
asked for (`SHADOK_ALLOW_ARTIFACTS=1` lifts it).
|
|
252
|
+
- **Photos and videos, viewable** β an image or a **video** (mp4/webm/movβ¦) in
|
|
253
|
+
the chat renders inline (a video gets a real player), and clicking a photo
|
|
254
|
+
opens it full-size in a lightbox. The πΌ button in an agent's bar opens a
|
|
255
|
+
**media gallery**: a grid of every photo and video in that conversation β
|
|
256
|
+
click a thumbnail to view or play it full-size, then back to the grid. Videos
|
|
257
|
+
are served with HTTP range requests so they seek and stream instead of
|
|
258
|
+
downloading whole.
|
|
245
259
|
- **Compaction summaries, folded away** β when Claude Code runs out of context
|
|
246
260
|
it compacts the conversation and writes a long "This session is being
|
|
247
261
|
continuedβ¦" summary as the first message of what follows. Shown in full it
|
package/dist/download.d.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
export declare const SEND_FILE_TOOL = "SendUserFile";
|
|
3
3
|
/** Would the web render this inline as an <img>? */
|
|
4
4
|
export declare function isImageFile(name: string): boolean;
|
|
5
|
+
/** Would the web render this inline as a <video>? */
|
|
6
|
+
export declare function isVideoFile(name: string): boolean;
|
|
5
7
|
/** Should Telegram send this as a photo (inline) rather than a document? */
|
|
6
8
|
export declare function telegramPhotoable(name: string): boolean;
|
|
7
9
|
/** Best-effort Content-Type for GET /download. `image/svg+xml` is served with a
|
|
@@ -13,8 +15,24 @@ export interface FileCard {
|
|
|
13
15
|
path: string;
|
|
14
16
|
name: string;
|
|
15
17
|
image: boolean;
|
|
18
|
+
video: boolean;
|
|
16
19
|
}
|
|
17
20
|
export declare function fileCard(p: string): FileCard;
|
|
21
|
+
/**
|
|
22
|
+
* Pure: parse an HTTP `Range` header against a known file size, for the single
|
|
23
|
+
* `bytes=start-end` form a <video> issues (browsers never send multipart ranges
|
|
24
|
+
* to a plain file server, so one range is all we honour).
|
|
25
|
+
*
|
|
26
|
+
* Returns the inclusive byte slice, `null` when there is no range to honour (no
|
|
27
|
+
* header, a non-bytes unit, a syntactically broken value β serve the whole file,
|
|
28
|
+
* a 200), or `"unsatisfiable"` when the range is well-formed but out of bounds
|
|
29
|
+
* (β the caller answers 416). `bytes=N-` is "from N to the end"; `bytes=-N` is
|
|
30
|
+
* "the last N bytes".
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseByteRange(header: string | undefined, size: number): {
|
|
33
|
+
start: number;
|
|
34
|
+
end: number;
|
|
35
|
+
} | null | "unsatisfiable";
|
|
18
36
|
/** The `files` a SendUserFile tool_use delivered β its `input.files`, kept only
|
|
19
37
|
* when they are real string paths. Any other tool β none. */
|
|
20
38
|
export declare function toolFiles(name: unknown, input: unknown): string[];
|
package/dist/download.js
CHANGED
|
@@ -9,6 +9,10 @@ import path from "node:path";
|
|
|
9
9
|
/** The tool that delivers a file to the user. */
|
|
10
10
|
export const SEND_FILE_TOOL = "SendUserFile";
|
|
11
11
|
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".bmp", ".ico"]);
|
|
12
|
+
// What the web renders inline as a <video>. Kept to the formats a browser can
|
|
13
|
+
// actually play (mp4/webm/ogg, plus mov which Safari plays and Chrome often
|
|
14
|
+
// does too); an unplayable container still downloads via the octet-stream path.
|
|
15
|
+
const VIDEO_EXT = new Set([".mp4", ".m4v", ".webm", ".ogv", ".mov"]);
|
|
12
16
|
// What Telegram will accept as a PHOTO (inline preview). Narrower than the web's
|
|
13
17
|
// <img>: svg/gif/bmp/ico are not photos there, so they go as documents.
|
|
14
18
|
const TG_PHOTO_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
|
@@ -18,12 +22,18 @@ const CONTENT_TYPE = {
|
|
|
18
22
|
".ico": "image/x-icon", ".pdf": "application/pdf", ".html": "text/html", ".htm": "text/html",
|
|
19
23
|
".css": "text/css", ".js": "text/javascript", ".json": "application/json", ".csv": "text/csv",
|
|
20
24
|
".txt": "text/plain", ".md": "text/markdown", ".xml": "application/xml", ".zip": "application/zip",
|
|
25
|
+
".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg",
|
|
26
|
+
".mov": "video/quicktime",
|
|
21
27
|
};
|
|
22
28
|
const ext = (name) => path.extname(String(name || "")).toLowerCase();
|
|
23
29
|
/** Would the web render this inline as an <img>? */
|
|
24
30
|
export function isImageFile(name) {
|
|
25
31
|
return IMAGE_EXT.has(ext(name));
|
|
26
32
|
}
|
|
33
|
+
/** Would the web render this inline as a <video>? */
|
|
34
|
+
export function isVideoFile(name) {
|
|
35
|
+
return VIDEO_EXT.has(ext(name));
|
|
36
|
+
}
|
|
27
37
|
/** Should Telegram send this as a photo (inline) rather than a document? */
|
|
28
38
|
export function telegramPhotoable(name) {
|
|
29
39
|
return TG_PHOTO_EXT.has(ext(name));
|
|
@@ -35,7 +45,44 @@ export function contentTypeFor(name) {
|
|
|
35
45
|
}
|
|
36
46
|
export function fileCard(p) {
|
|
37
47
|
const name = path.basename(String(p || ""));
|
|
38
|
-
return { path: String(p || ""), name, image: isImageFile(name) };
|
|
48
|
+
return { path: String(p || ""), name, image: isImageFile(name), video: isVideoFile(name) };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Pure: parse an HTTP `Range` header against a known file size, for the single
|
|
52
|
+
* `bytes=start-end` form a <video> issues (browsers never send multipart ranges
|
|
53
|
+
* to a plain file server, so one range is all we honour).
|
|
54
|
+
*
|
|
55
|
+
* Returns the inclusive byte slice, `null` when there is no range to honour (no
|
|
56
|
+
* header, a non-bytes unit, a syntactically broken value β serve the whole file,
|
|
57
|
+
* a 200), or `"unsatisfiable"` when the range is well-formed but out of bounds
|
|
58
|
+
* (β the caller answers 416). `bytes=N-` is "from N to the end"; `bytes=-N` is
|
|
59
|
+
* "the last N bytes".
|
|
60
|
+
*/
|
|
61
|
+
export function parseByteRange(header, size) {
|
|
62
|
+
if (!header || size <= 0)
|
|
63
|
+
return null;
|
|
64
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
65
|
+
if (!m)
|
|
66
|
+
return null;
|
|
67
|
+
const [, rawStart, rawEnd] = m;
|
|
68
|
+
if (rawStart === "" && rawEnd === "")
|
|
69
|
+
return null;
|
|
70
|
+
let start, end;
|
|
71
|
+
if (rawStart === "") {
|
|
72
|
+
// Suffix: the last N bytes. N === 0 is unsatisfiable (no "last zero bytes").
|
|
73
|
+
const n = Number(rawEnd);
|
|
74
|
+
if (n === 0)
|
|
75
|
+
return "unsatisfiable";
|
|
76
|
+
start = Math.max(0, size - n);
|
|
77
|
+
end = size - 1;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
start = Number(rawStart);
|
|
81
|
+
end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
|
82
|
+
}
|
|
83
|
+
if (start > end || start >= size)
|
|
84
|
+
return "unsatisfiable";
|
|
85
|
+
return { start, end };
|
|
39
86
|
}
|
|
40
87
|
/** The `files` a SendUserFile tool_use delivered β its `input.files`, kept only
|
|
41
88
|
* when they are real string paths. Any other tool β none. */
|
package/dist/download.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"download.js","sourceRoot":"","sources":["../src/download.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,gFAAgF;AAChF,uEAAuE;AACvE,gFAAgF;AAChF,kFAAkF;AAClF,EAAE;AACF,iFAAiF;AAEjF,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,iDAAiD;AACjD,MAAM,CAAC,MAAM,cAAc,GAAG,cAAc,CAAC;AAE7C,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AACvG,iFAAiF;AACjF,wEAAwE;AACxE,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;AAEjE,MAAM,YAAY,GAA2B;IAC3C,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW;IACrF,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW;IAC1F,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,iBAAiB,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW;IAC5F,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,kBAAkB,EAAE,MAAM,EAAE,UAAU;IAC7F,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,iBAAiB;
|
|
1
|
+
{"version":3,"file":"download.js","sourceRoot":"","sources":["../src/download.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,gFAAgF;AAChF,uEAAuE;AACvE,gFAAgF;AAChF,kFAAkF;AAClF,EAAE;AACF,iFAAiF;AAEjF,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,iDAAiD;AACjD,MAAM,CAAC,MAAM,cAAc,GAAG,cAAc,CAAC;AAE7C,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AACvG,8EAA8E;AAC9E,4EAA4E;AAC5E,gFAAgF;AAChF,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AACrE,iFAAiF;AACjF,wEAAwE;AACxE,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;AAEjE,MAAM,YAAY,GAA2B;IAC3C,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW;IACrF,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW;IAC1F,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,iBAAiB,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW;IAC5F,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,kBAAkB,EAAE,MAAM,EAAE,UAAU;IAC7F,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,iBAAiB;IAClG,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW;IACpF,MAAM,EAAE,iBAAiB;CAC1B,CAAC;AAEF,MAAM,GAAG,GAAG,CAAC,IAAY,EAAU,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;AAErF,oDAAoD;AACpD,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,OAAO,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;AAClC,CAAC;AAED,qDAAqD;AACrD,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,OAAO,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;AAClC,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,OAAO,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;AACrC,CAAC;AAED;qEACqE;AACrE,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,OAAO,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,0BAA0B,CAAC;AAC/D,CAAC;AAWD,MAAM,UAAU,QAAQ,CAAC,CAAS;IAChC,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAC5C,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;AAC7F,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAC5B,MAA0B,EAC1B,IAAY;IAEZ,IAAI,CAAC,MAAM,IAAI,IAAI,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACtC,MAAM,CAAC,GAAG,qBAAqB,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACpD,IAAI,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACpB,MAAM,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,QAAQ,KAAK,EAAE,IAAI,MAAM,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAClD,IAAI,KAAa,EAAE,GAAW,CAAC;IAC/B,IAAI,QAAQ,KAAK,EAAE,EAAE,CAAC;QACpB,6EAA6E;QAC7E,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;QACzB,IAAI,CAAC,KAAK,CAAC;YAAE,OAAO,eAAe,CAAC;QACpC,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,GAAG,CAAC,CAAC,CAAC;QAC9B,GAAG,GAAG,IAAI,GAAG,CAAC,CAAC;IACjB,CAAC;SAAM,CAAC;QACN,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;QACzB,GAAG,GAAG,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,GAAG,CAAC,CAAC,CAAC;IACtE,CAAC;IACD,IAAI,KAAK,GAAG,GAAG,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,eAAe,CAAC;IACzD,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC;AACxB,CAAC;AAED;8DAC8D;AAC9D,MAAM,UAAU,SAAS,CAAC,IAAa,EAAE,KAAc;IACrD,IAAI,IAAI,KAAK,cAAc,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,EAAE,CAAC;IAC9E,MAAM,KAAK,GAAI,KAA6B,CAAC,KAAK,CAAC;IACnD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACrC,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAe,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACjF,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,UAAkB;IAC9C,MAAM,GAAG,GAAG,IAAI,GAAG,EAAU,CAAC;IAC9B,IAAI,OAAO,UAAU,KAAK,QAAQ,IAAI,CAAC,UAAU;QAAE,OAAO,GAAG,CAAC;IAC9D,KAAK,MAAM,IAAI,IAAI,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1C,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC;YAAE,SAAS;QAC7D,IAAI,CAAM,CAAC;QACX,IAAI,CAAC;YAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAAC,CAAC;QAAC,MAAM,CAAC;YAAC,SAAS;QAAC,CAAC;QACjD,MAAM,OAAO,GAAG,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC;QACpC,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;YAC3B,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;gBACxB,IAAI,CAAC,EAAE,IAAI,KAAK,UAAU;oBAAE,KAAK,MAAM,CAAC,IAAI,SAAS,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC;wBAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YACrF,CAAC;QACH,CAAC;QACD,MAAM,IAAI,GAAG,CAAC,EAAE,aAAa,EAAE,WAAW,CAAC;QAC3C,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YACxB,KAAK,MAAM,CAAC,IAAI,IAAI;gBAAE,IAAI,OAAO,CAAC,EAAE,IAAI,KAAK,QAAQ,IAAI,CAAC,CAAC,IAAI;oBAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QACnF,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
|
package/dist/server.js
CHANGED
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import express from "express";
|
|
12
12
|
import { WebSocketServer, WebSocket } from "ws";
|
|
13
13
|
import { detectDialog, dialogKey, isResumeSummaryDialog, findSessionId, lastPromptAt, listSessions, loadHistory, resumedTurnStart, transcriptFilePath, } from "./extract.js";
|
|
14
|
-
import { fileCard, sentFilePaths, contentTypeFor, isImageFile } from "./download.js";
|
|
14
|
+
import { fileCard, sentFilePaths, contentTypeFor, isImageFile, isVideoFile, parseByteRange } from "./download.js";
|
|
15
15
|
import { OFFER_TOOL, filesFileFor, loadOffers, saveOffers, offerVerdict, withOffer, offeredPaths, refusalMessage, statForOffer, } from "./files.js";
|
|
16
16
|
// Same implementation as the web client's preview (plain JS, loaded as is by
|
|
17
17
|
// the browser): one source for reading the in-flight text off the screen.
|
|
@@ -39,6 +39,7 @@ import { getUsage } from "./usage.js";
|
|
|
39
39
|
import { loadChannels, loadGroups, saveGroups, upsertChannel, removeChannel, mergeClientChannels, channelWriteAllowed, profilePatch, loadTgGroup, saveTgGroup, isHomeChannel, homeAdoptionTarget, resolveSessionTarget, resolveResumeId, resumeTarget, } from "./channels.js";
|
|
40
40
|
import { deliverWithRetry } from "./reload.js";
|
|
41
41
|
import { startTelegram, renameTelegramTopic, closeTelegramTopic, probeToken, isStalePreface, announceLoggedOut, resetLoggedOutNotice, attachmentPrompt, pasteFileName, MEDIA_DIR, } from "./telegram.js";
|
|
42
|
+
import { transcribe, audioClipExtension } from "./whisper.js";
|
|
42
43
|
import { cancelPairingCode, issuePairingCode, pairingStatus } from "./tg-pairing.js";
|
|
43
44
|
import { migrateTgBindings } from "./channels.js";
|
|
44
45
|
import { instanceKey } from "./paths.js";
|
|
@@ -1267,6 +1268,50 @@ app.post("/paste", express.raw({ type: () => true, limit: PASTE_LIMIT }), (req,
|
|
|
1267
1268
|
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
1268
1269
|
}
|
|
1269
1270
|
});
|
|
1271
|
+
/**
|
|
1272
|
+
* Speech-to-text for the composer's microphone button. The browser records a
|
|
1273
|
+
* short clip (MediaRecorder β webm/opus) and POSTs the raw bytes here; we run
|
|
1274
|
+
* the SAME local whisper.cpp pipeline the Telegram voice path uses
|
|
1275
|
+
* (`transcribe`) and return the text for the client to drop into the composer.
|
|
1276
|
+
*
|
|
1277
|
+
* The text is NEVER auto-sent β it lands in the editable composer so the user
|
|
1278
|
+
* reads it before acting, because a misheard instruction acted on in silence is
|
|
1279
|
+
* worse than a voice note that was ignored (the reason `heardNotice` exists).
|
|
1280
|
+
*
|
|
1281
|
+
* `requestFromBrowser` like `/paste`: this writes a file and records the mic, a
|
|
1282
|
+
* browser-only capability with no business being reachable by a script on
|
|
1283
|
+
* loopback. The clip is TRANSIENT β written to the OS temp dir and removed in
|
|
1284
|
+
* `finally`, never kept in `MEDIA_DIR`: it is spoken input, not a file to hand
|
|
1285
|
+
* the agent.
|
|
1286
|
+
*
|
|
1287
|
+
* The first call on a fresh instance provisions whisper (ffmpeg / model / build
|
|
1288
|
+
* via `ensureWhisper` inside `transcribe`), which can take minutes; the client
|
|
1289
|
+
* shows a working state and we simply wait. The French whisper notices are kept
|
|
1290
|
+
* server-side β the client maps `kind` to its own English UI copy, so the web
|
|
1291
|
+
* stays English while Telegram keeps its spoken-language notices.
|
|
1292
|
+
*/
|
|
1293
|
+
app.post("/transcribe", express.raw({ type: () => true, limit: PASTE_LIMIT }), async (req, res) => {
|
|
1294
|
+
if (!requestFromBrowser(req))
|
|
1295
|
+
return res.status(403).json({ error: "same-origin browser only" });
|
|
1296
|
+
const body = req.body;
|
|
1297
|
+
if (!Buffer.isBuffer(body) || !body.length)
|
|
1298
|
+
return res.status(400).json({ error: "empty body" });
|
|
1299
|
+
const ext = audioClipExtension(String(req.headers["content-type"] ?? ""));
|
|
1300
|
+
const tmp = path.join(os.tmpdir(), `shadok-stt-${randomUUID()}.${ext}`);
|
|
1301
|
+
try {
|
|
1302
|
+
await fs.promises.writeFile(tmp, body);
|
|
1303
|
+
const out = await transcribe(tmp);
|
|
1304
|
+
if (out.ok)
|
|
1305
|
+
return res.json({ ok: true, text: out.text });
|
|
1306
|
+
return res.json({ ok: false, kind: out.kind });
|
|
1307
|
+
}
|
|
1308
|
+
catch (e) {
|
|
1309
|
+
return res.status(500).json({ ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
1310
|
+
}
|
|
1311
|
+
finally {
|
|
1312
|
+
await fs.promises.rm(tmp, { force: true }).catch(() => { });
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1270
1315
|
app.use(express.static(path.join(__dirname, "..", "public")));
|
|
1271
1316
|
// Markdown parser served locally (history rendering on the client).
|
|
1272
1317
|
// Resolved through require.resolve, not a hardcoded path: in the npm install
|
|
@@ -3030,6 +3075,38 @@ function historyWithOffers(turns, sessionId) {
|
|
|
3030
3075
|
out.push(fileTurns[oi++]);
|
|
3031
3076
|
return out;
|
|
3032
3077
|
}
|
|
3078
|
+
// Stream a vetted file back to the browser, honouring a single `Range` so a
|
|
3079
|
+
// <video> can seek and so Safari plays one at all (it demands a 206 before it
|
|
3080
|
+
// will start). Images/PDF/etc. get the same path β Range is harmless there and
|
|
3081
|
+
// helps a resumed download. A raster image or a video is served `inline` (the
|
|
3082
|
+
// chat's <img>/<video> renders it, and opening the URL shows it); everything
|
|
3083
|
+
// else β including SVG/HTML β downloads, under the same nosniff + sandbox CSP.
|
|
3084
|
+
function serveStaticFile(req, res, filePath, stat, name) {
|
|
3085
|
+
const ct = contentTypeFor(name);
|
|
3086
|
+
const inline = (isImageFile(name) && ct !== "image/svg+xml") || isVideoFile(name);
|
|
3087
|
+
res.setHeader("Content-Type", ct);
|
|
3088
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
3089
|
+
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
|
|
3090
|
+
res.setHeader("Content-Disposition", `${inline ? "inline" : "attachment"}; filename="${name.replace(/["\\]/g, "_")}"`);
|
|
3091
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
3092
|
+
const range = parseByteRange(req.headers.range, stat.size);
|
|
3093
|
+
if (range === "unsatisfiable") {
|
|
3094
|
+
res.setHeader("Content-Range", `bytes */${stat.size}`);
|
|
3095
|
+
res.status(416).end();
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
const onErr = () => { if (!res.headersSent)
|
|
3099
|
+
res.status(500); res.end(); };
|
|
3100
|
+
if (range) {
|
|
3101
|
+
res.status(206);
|
|
3102
|
+
res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
|
|
3103
|
+
res.setHeader("Content-Length", String(range.end - range.start + 1));
|
|
3104
|
+
fs.createReadStream(filePath, { start: range.start, end: range.end }).on("error", onErr).pipe(res);
|
|
3105
|
+
return;
|
|
3106
|
+
}
|
|
3107
|
+
res.setHeader("Content-Length", String(stat.size));
|
|
3108
|
+
fs.createReadStream(filePath).on("error", onErr).pipe(res);
|
|
3109
|
+
}
|
|
3033
3110
|
app.get("/download", (req, res) => {
|
|
3034
3111
|
const session = String(req.query.session ?? "");
|
|
3035
3112
|
const wanted = String(req.query.path ?? "");
|
|
@@ -3066,17 +3143,7 @@ app.get("/download", (req, res) => {
|
|
|
3066
3143
|
catch {
|
|
3067
3144
|
return res.status(404).type("text").send("file no longer on disk");
|
|
3068
3145
|
}
|
|
3069
|
-
|
|
3070
|
-
res.setHeader("Content-Type", contentTypeFor(name));
|
|
3071
|
-
res.setHeader("Content-Length", String(stat.size));
|
|
3072
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
3073
|
-
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
|
|
3074
|
-
// A raster image is served inline (so opening it shows the image); everything
|
|
3075
|
-
// else β including SVG/HTML β downloads. The <img> in the chat renders either.
|
|
3076
|
-
const inline = isImageFile(name) && contentTypeFor(name) !== "image/svg+xml";
|
|
3077
|
-
res.setHeader("Content-Disposition", `${inline ? "inline" : "attachment"}; filename="${name.replace(/["\\]/g, "_")}"`);
|
|
3078
|
-
fs.createReadStream(wanted).on("error", () => { if (!res.headersSent)
|
|
3079
|
-
res.status(500); res.end(); }).pipe(res);
|
|
3146
|
+
serveStaticFile(req, res, wanted, stat, path.basename(wanted));
|
|
3080
3147
|
});
|
|
3081
3148
|
// Serve a file the USER dropped / pasted (or a Telegram attachment) β they all
|
|
3082
3149
|
// land in MEDIA_DIR, referenced in the chat as `[Attached image: <path>]`. The
|
|
@@ -3100,14 +3167,7 @@ app.get("/media/:name", (req, res) => {
|
|
|
3100
3167
|
catch {
|
|
3101
3168
|
return res.status(404).type("text").send("no such media");
|
|
3102
3169
|
}
|
|
3103
|
-
|
|
3104
|
-
res.setHeader("Content-Length", String(stat.size));
|
|
3105
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
3106
|
-
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
|
|
3107
|
-
const inline = isImageFile(base) && contentTypeFor(base) !== "image/svg+xml";
|
|
3108
|
-
res.setHeader("Content-Disposition", `${inline ? "inline" : "attachment"}; filename="${base.replace(/["\\]/g, "_")}"`);
|
|
3109
|
-
fs.createReadStream(file).on("error", () => { if (!res.headersSent)
|
|
3110
|
-
res.status(500); res.end(); }).pipe(res);
|
|
3170
|
+
serveStaticFile(req, res, file, stat, base);
|
|
3111
3171
|
});
|
|
3112
3172
|
// Install (or repair) the global Claude Code CLI on demand β `npm i -g
|
|
3113
3173
|
// @anthropic-ai/claude-code`. The cockpit shows this button when an agent's TUI
|