@tiens.nguyen/gonext-local-worker 1.0.99 → 1.0.101
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/gonext-local-worker.mjs +232 -0
- package/gonext_transcribe.py +130 -0
- package/package.json +2 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1309,6 +1309,171 @@ async function runOcrJob(job) {
|
|
|
1309
1309
|
}
|
|
1310
1310
|
}
|
|
1311
1311
|
|
|
1312
|
+
const TRANSCRIBE_TIMEOUT_MS =
|
|
1313
|
+
Number(process.env.GONEXT_TRANSCRIBE_TIMEOUT_MS ?? "300000") || 300000;
|
|
1314
|
+
|
|
1315
|
+
function resolveWorkerPython() {
|
|
1316
|
+
return (
|
|
1317
|
+
(process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
|
|
1318
|
+
"python3"
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Spawn a Python script, write `input` to stdin, capture stdout/stderr. */
|
|
1323
|
+
function runPythonCapture(python, args, input, timeoutMs) {
|
|
1324
|
+
return new Promise((resolve) => {
|
|
1325
|
+
const child = spawn(python, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
1326
|
+
let stdout = "";
|
|
1327
|
+
let stderr = "";
|
|
1328
|
+
let settled = false;
|
|
1329
|
+
const timer = setTimeout(() => {
|
|
1330
|
+
if (settled) return;
|
|
1331
|
+
settled = true;
|
|
1332
|
+
try {
|
|
1333
|
+
child.kill("SIGKILL");
|
|
1334
|
+
} catch {
|
|
1335
|
+
/* ignore */
|
|
1336
|
+
}
|
|
1337
|
+
resolve({ code: null, stdout, stderr, timedOut: true });
|
|
1338
|
+
}, timeoutMs);
|
|
1339
|
+
child.stdout.on("data", (d) => {
|
|
1340
|
+
stdout += d.toString();
|
|
1341
|
+
});
|
|
1342
|
+
child.stderr.on("data", (d) => {
|
|
1343
|
+
stderr += d.toString();
|
|
1344
|
+
});
|
|
1345
|
+
child.on("error", (e) => {
|
|
1346
|
+
if (settled) return;
|
|
1347
|
+
settled = true;
|
|
1348
|
+
clearTimeout(timer);
|
|
1349
|
+
resolve({ code: null, stdout, stderr: stderr + String(e), timedOut: false });
|
|
1350
|
+
});
|
|
1351
|
+
child.on("close", (code) => {
|
|
1352
|
+
if (settled) return;
|
|
1353
|
+
settled = true;
|
|
1354
|
+
clearTimeout(timer);
|
|
1355
|
+
resolve({ code, stdout, stderr, timedOut: false });
|
|
1356
|
+
});
|
|
1357
|
+
try {
|
|
1358
|
+
child.stdin.write(input);
|
|
1359
|
+
child.stdin.end();
|
|
1360
|
+
} catch {
|
|
1361
|
+
/* the error/close handler will settle */
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function runTranscribeJob(job) {
|
|
1367
|
+
const { jobId, payload } = job;
|
|
1368
|
+
const start = Date.now();
|
|
1369
|
+
const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
1370
|
+
method: "PATCH",
|
|
1371
|
+
body: JSON.stringify({ jobStatus: "running" }),
|
|
1372
|
+
});
|
|
1373
|
+
await ensureWorkerOk(runRes, `mark running transcribe jobId=${jobId}`);
|
|
1374
|
+
const tempDir = await mkdtemp(join(tmpdir(), "gonext-transcribe-"));
|
|
1375
|
+
try {
|
|
1376
|
+
if (!payload || typeof payload !== "object") {
|
|
1377
|
+
throw new Error("Invalid transcribe payload.");
|
|
1378
|
+
}
|
|
1379
|
+
const audio = payload.audio;
|
|
1380
|
+
const mimeType = typeof audio?.mimeType === "string" ? audio.mimeType : "";
|
|
1381
|
+
const s3Object = audio && typeof audio === "object" ? audio.s3Object : undefined;
|
|
1382
|
+
const s3GetUrl =
|
|
1383
|
+
s3Object && typeof s3Object.getUrl === "string" ? s3Object.getUrl.trim() : "";
|
|
1384
|
+
const model =
|
|
1385
|
+
typeof payload.model === "string" && payload.model.trim()
|
|
1386
|
+
? payload.model.trim()
|
|
1387
|
+
: "whisper-large-v3-turbo";
|
|
1388
|
+
const language =
|
|
1389
|
+
typeof payload.language === "string" ? payload.language.trim() : "";
|
|
1390
|
+
if (!mimeType.startsWith("audio/")) {
|
|
1391
|
+
throw new Error("Transcribe job attachment must be audio.");
|
|
1392
|
+
}
|
|
1393
|
+
if (!s3GetUrl) {
|
|
1394
|
+
throw new Error("Transcribe job is missing the audio download URL.");
|
|
1395
|
+
}
|
|
1396
|
+
const dlRes = await fetch(s3GetUrl, { method: "GET" });
|
|
1397
|
+
if (!dlRes.ok) {
|
|
1398
|
+
throw new Error(`Failed to download audio from S3 (${dlRes.status}).`);
|
|
1399
|
+
}
|
|
1400
|
+
const bytes = Buffer.from(await dlRes.arrayBuffer());
|
|
1401
|
+
if (!bytes.length) {
|
|
1402
|
+
throw new Error("Downloaded audio is empty.");
|
|
1403
|
+
}
|
|
1404
|
+
const audioPath = join(tempDir, "input.wav");
|
|
1405
|
+
await writeFile(audioPath, bytes);
|
|
1406
|
+
|
|
1407
|
+
const python = resolveWorkerPython();
|
|
1408
|
+
const scriptPath = join(WORKER_DIR, "gonext_transcribe.py");
|
|
1409
|
+
const { code, stdout, stderr, timedOut } = await runPythonCapture(
|
|
1410
|
+
python,
|
|
1411
|
+
[scriptPath],
|
|
1412
|
+
JSON.stringify({ audioPath, model, language: language || undefined }),
|
|
1413
|
+
TRANSCRIBE_TIMEOUT_MS
|
|
1414
|
+
);
|
|
1415
|
+
if (timedOut) {
|
|
1416
|
+
throw new Error("Transcription timed out.");
|
|
1417
|
+
}
|
|
1418
|
+
if (code !== 0) {
|
|
1419
|
+
const reason = String(stderr ?? "").split("\n")[0]?.trim() || "error";
|
|
1420
|
+
const hint =
|
|
1421
|
+
reason === "lib-missing"
|
|
1422
|
+
? "Whisper library not installed. Run: pip install mlx-whisper"
|
|
1423
|
+
: reason === "model-missing"
|
|
1424
|
+
? `Whisper model not downloaded. Run: hf download mlx-community/${model}`
|
|
1425
|
+
: reason === "audio-error"
|
|
1426
|
+
? "Could not decode the recorded audio."
|
|
1427
|
+
: "Transcription failed.";
|
|
1428
|
+
throw new Error(`${hint} (${compactPreview(stderr)})`);
|
|
1429
|
+
}
|
|
1430
|
+
let text = "";
|
|
1431
|
+
try {
|
|
1432
|
+
const parsed = JSON.parse(String(stdout ?? "").trim() || "{}");
|
|
1433
|
+
text = typeof parsed.text === "string" ? parsed.text.trim() : "";
|
|
1434
|
+
} catch {
|
|
1435
|
+
throw new Error(`Transcription returned malformed output: ${compactPreview(stdout)}`);
|
|
1436
|
+
}
|
|
1437
|
+
if (!text) {
|
|
1438
|
+
throw new Error("Transcription returned empty text.");
|
|
1439
|
+
}
|
|
1440
|
+
const totalTimeSeconds = (Date.now() - start) / 1000;
|
|
1441
|
+
const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
1442
|
+
method: "PATCH",
|
|
1443
|
+
body: JSON.stringify({
|
|
1444
|
+
jobStatus: "completed",
|
|
1445
|
+
resultText: text,
|
|
1446
|
+
tokenCount: Math.max(1, Math.ceil(text.length / 4)),
|
|
1447
|
+
totalTimeSeconds,
|
|
1448
|
+
}),
|
|
1449
|
+
});
|
|
1450
|
+
await ensureWorkerOk(doneRes, `complete transcribe jobId=${jobId}`);
|
|
1451
|
+
console.log(
|
|
1452
|
+
`[gonext-worker] completed transcribe ${jobId} (${totalTimeSeconds.toFixed(1)}s) model=${model}`
|
|
1453
|
+
);
|
|
1454
|
+
} catch (e) {
|
|
1455
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1456
|
+
const failRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
1457
|
+
method: "PATCH",
|
|
1458
|
+
body: JSON.stringify({
|
|
1459
|
+
jobStatus: "failed",
|
|
1460
|
+
errorMessage: message,
|
|
1461
|
+
totalTimeSeconds: (Date.now() - start) / 1000,
|
|
1462
|
+
}),
|
|
1463
|
+
});
|
|
1464
|
+
if (!failRes.ok) {
|
|
1465
|
+
const snippet = (await failRes.text().catch(() => "")).trim().slice(0, 500);
|
|
1466
|
+
console.error(
|
|
1467
|
+
`[gonext-worker] transcribe fail PATCH also failed ${failRes.status} jobId=${jobId}` +
|
|
1468
|
+
(snippet ? ` response=${snippet}` : "")
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
console.error(`[gonext-worker] failed transcribe ${jobId}:`, message);
|
|
1472
|
+
} finally {
|
|
1473
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1312
1477
|
async function runAgentChatJob(job) {
|
|
1313
1478
|
const { jobId, payload } = job;
|
|
1314
1479
|
const start = Date.now();
|
|
@@ -1618,6 +1783,55 @@ async function checkMlxLmNativeImport() {
|
|
|
1618
1783
|
};
|
|
1619
1784
|
}
|
|
1620
1785
|
|
|
1786
|
+
// Probe whether voice transcription is ready (Audio support, task #21):
|
|
1787
|
+
// mlx-whisper importable AND the requested model present in the HF hub cache
|
|
1788
|
+
// (~/.cache/huggingface/hub, honoring HF_HOME/HF_HUB_CACHE). Returns
|
|
1789
|
+
// { available, model, reason } where reason is "ok" | "lib-missing" | "model-missing".
|
|
1790
|
+
async function checkWhisperAvailability(model) {
|
|
1791
|
+
const preferred =
|
|
1792
|
+
(process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
|
|
1793
|
+
"python3";
|
|
1794
|
+
const repoName = `models--mlx-community--${model}`;
|
|
1795
|
+
const code = [
|
|
1796
|
+
"import os, sys, json, glob",
|
|
1797
|
+
"model = sys.argv[1] if len(sys.argv) > 1 else 'whisper-large-v3-turbo'",
|
|
1798
|
+
"try:",
|
|
1799
|
+
" import mlx_whisper # noqa: F401",
|
|
1800
|
+
"except Exception:",
|
|
1801
|
+
" print(json.dumps({'available': False, 'reason': 'lib-missing'})); sys.exit(0)",
|
|
1802
|
+
"home = os.environ.get('HF_HOME') or os.path.join(os.path.expanduser('~'), '.cache', 'huggingface')",
|
|
1803
|
+
"hub = os.environ.get('HF_HUB_CACHE') or os.path.join(home, 'hub')",
|
|
1804
|
+
`repo = os.path.join(hub, ${JSON.stringify(repoName)})`,
|
|
1805
|
+
"snaps = glob.glob(os.path.join(repo, 'snapshots', '*'))",
|
|
1806
|
+
"ok = False",
|
|
1807
|
+
"for s in snaps:",
|
|
1808
|
+
" if glob.glob(os.path.join(s, '*.safetensors')) or glob.glob(os.path.join(s, '*.npz')) or os.path.exists(os.path.join(s, 'weights.npz')):",
|
|
1809
|
+
" ok = True; break",
|
|
1810
|
+
"print(json.dumps({'available': ok, 'reason': 'ok' if ok else 'model-missing', 'cache': hub}))",
|
|
1811
|
+
].join("\n");
|
|
1812
|
+
const candidates = [preferred];
|
|
1813
|
+
if (preferred === "python3") candidates.push("python");
|
|
1814
|
+
for (const exe of [...new Set(candidates)]) {
|
|
1815
|
+
try {
|
|
1816
|
+
const { stdout } = await execFile(exe, ["-c", code, model], {
|
|
1817
|
+
timeout: 15000,
|
|
1818
|
+
maxBuffer: 65536,
|
|
1819
|
+
windowsHide: true,
|
|
1820
|
+
});
|
|
1821
|
+
const parsed = JSON.parse(String(stdout ?? "").trim() || "{}");
|
|
1822
|
+
return {
|
|
1823
|
+
available: parsed.available === true,
|
|
1824
|
+
model,
|
|
1825
|
+
reason:
|
|
1826
|
+
typeof parsed.reason === "string" ? parsed.reason : "model-missing",
|
|
1827
|
+
};
|
|
1828
|
+
} catch {
|
|
1829
|
+
/* try next candidate */
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
return { available: false, model, reason: "lib-missing" };
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1621
1835
|
async function runLocalHealthJob(job) {
|
|
1622
1836
|
const { jobId, payload } = job;
|
|
1623
1837
|
const start = Date.now();
|
|
@@ -1746,6 +1960,19 @@ async function runLocalHealthJob(job) {
|
|
|
1746
1960
|
modelPathStatus.push(check);
|
|
1747
1961
|
}
|
|
1748
1962
|
|
|
1963
|
+
const whisperModel =
|
|
1964
|
+
typeof payload?.voiceModel === "string" && payload.voiceModel.trim()
|
|
1965
|
+
? payload.voiceModel.trim()
|
|
1966
|
+
: "whisper-large-v3-turbo";
|
|
1967
|
+
let whisper = null;
|
|
1968
|
+
if (platform() === "darwin") {
|
|
1969
|
+
const tW = Date.now();
|
|
1970
|
+
whisper = await checkWhisperAvailability(whisperModel);
|
|
1971
|
+
console.log(
|
|
1972
|
+
`[gonext-worker] local_health ${jobId} whisper available=${whisper.available} reason=${whisper.reason} took=${((Date.now() - tW) / 1000).toFixed(2)}s`
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1749
1976
|
let mlx = null;
|
|
1750
1977
|
if (mlxRoot || mlxNative?.available) {
|
|
1751
1978
|
const httpOk = Boolean(mlxHttp?.online && (mlxHttp?.models?.length ?? 0) > 0);
|
|
@@ -1800,6 +2027,7 @@ async function runLocalHealthJob(job) {
|
|
|
1800
2027
|
}
|
|
1801
2028
|
: undefined,
|
|
1802
2029
|
mlx,
|
|
2030
|
+
whisper: whisper ?? undefined,
|
|
1803
2031
|
setup:
|
|
1804
2032
|
modelPathStatus.length > 0
|
|
1805
2033
|
? {
|
|
@@ -1905,6 +2133,10 @@ async function pollOnce() {
|
|
|
1905
2133
|
await runAgentChatJob(job);
|
|
1906
2134
|
return;
|
|
1907
2135
|
}
|
|
2136
|
+
if (job.jobType === "transcribe") {
|
|
2137
|
+
await runTranscribeJob(job);
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
1908
2140
|
const isOcrByType = job.jobType === "ocr";
|
|
1909
2141
|
const isOcrByPayload =
|
|
1910
2142
|
job.payload &&
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Voice transcription for the GoNext local worker (Audio support, task #21).
|
|
3
|
+
|
|
4
|
+
Reads a JSON job on stdin: {"audioPath": str, "model": str, "language": str?}.
|
|
5
|
+
Transcribes with mlx-whisper and prints {"text": "..."} on stdout.
|
|
6
|
+
|
|
7
|
+
Design notes:
|
|
8
|
+
- The browser sends a 16 kHz mono 16-bit PCM WAV, so we decode it with the
|
|
9
|
+
stdlib `wave` module + numpy and hand mlx-whisper a float32 array. Passing an
|
|
10
|
+
array (not a file path) skips mlx-whisper's ffmpeg-based loader, so NO ffmpeg
|
|
11
|
+
install is required on the worker.
|
|
12
|
+
- The model is resolved by HF repo id ("mlx-community/<model>") from the HF hub
|
|
13
|
+
cache (~/.cache/huggingface/hub, honoring HF_HOME/HF_HUB_CACHE). This is the
|
|
14
|
+
same location the wizard installs to and the readiness probe checks.
|
|
15
|
+
- On failure we exit non-zero and print a machine-parseable reason token as the
|
|
16
|
+
first stderr line: "lib-missing" | "model-missing" | "audio-error" | "error".
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import wave
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
TARGET_SR = 16000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def fail(reason: str, detail: str = "") -> None:
|
|
28
|
+
sys.stderr.write(reason + "\n")
|
|
29
|
+
if detail:
|
|
30
|
+
sys.stderr.write(detail + "\n")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_wav_mono_16k(path: str):
|
|
35
|
+
"""Decode a PCM WAV to a float32 mono array at 16 kHz without ffmpeg."""
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
with wave.open(path, "rb") as w:
|
|
39
|
+
n_channels = w.getnchannels()
|
|
40
|
+
sampwidth = w.getsampwidth()
|
|
41
|
+
framerate = w.getframerate()
|
|
42
|
+
n_frames = w.getnframes()
|
|
43
|
+
raw = w.readframes(n_frames)
|
|
44
|
+
|
|
45
|
+
if sampwidth == 2:
|
|
46
|
+
audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
|
|
47
|
+
elif sampwidth == 4:
|
|
48
|
+
audio = np.frombuffer(raw, dtype=np.int32).astype(np.float32) / 2147483648.0
|
|
49
|
+
elif sampwidth == 1:
|
|
50
|
+
audio = (np.frombuffer(raw, dtype=np.uint8).astype(np.float32) - 128.0) / 128.0
|
|
51
|
+
else:
|
|
52
|
+
raise ValueError(f"unsupported WAV sample width: {sampwidth} bytes")
|
|
53
|
+
|
|
54
|
+
if n_channels > 1:
|
|
55
|
+
audio = audio.reshape(-1, n_channels).mean(axis=1)
|
|
56
|
+
|
|
57
|
+
if framerate != TARGET_SR and audio.size > 0:
|
|
58
|
+
# Linear resample; the browser already sends 16 kHz so this is a safety net.
|
|
59
|
+
duration = audio.size / float(framerate)
|
|
60
|
+
target_len = int(round(duration * TARGET_SR))
|
|
61
|
+
if target_len > 0:
|
|
62
|
+
src_x = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
|
|
63
|
+
dst_x = np.linspace(0.0, 1.0, num=target_len, endpoint=False)
|
|
64
|
+
audio = np.interp(dst_x, src_x, audio).astype(np.float32)
|
|
65
|
+
|
|
66
|
+
return np.ascontiguousarray(audio, dtype=np.float32)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> None:
|
|
70
|
+
try:
|
|
71
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
72
|
+
except Exception as e: # noqa: BLE001
|
|
73
|
+
fail("error", f"invalid job json: {e}")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
audio_path = str(payload.get("audioPath") or "").strip()
|
|
77
|
+
model = str(payload.get("model") or "whisper-large-v3-turbo").strip()
|
|
78
|
+
language = str(payload.get("language") or "").strip() or None
|
|
79
|
+
if not audio_path:
|
|
80
|
+
fail("error", "missing audioPath")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
import mlx_whisper # noqa: F401
|
|
85
|
+
except Exception as e: # noqa: BLE001
|
|
86
|
+
fail("lib-missing", str(e))
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
audio = load_wav_mono_16k(audio_path)
|
|
91
|
+
except Exception as e: # noqa: BLE001
|
|
92
|
+
fail("audio-error", str(e))
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
repo = f"mlx-community/{model}"
|
|
96
|
+
try:
|
|
97
|
+
result = mlx_whisper.transcribe(
|
|
98
|
+
audio,
|
|
99
|
+
path_or_hf_repo=repo,
|
|
100
|
+
task="transcribe",
|
|
101
|
+
language=language,
|
|
102
|
+
)
|
|
103
|
+
except Exception as e: # noqa: BLE001
|
|
104
|
+
msg = str(e).lower()
|
|
105
|
+
if any(
|
|
106
|
+
token in msg
|
|
107
|
+
for token in (
|
|
108
|
+
"not found",
|
|
109
|
+
"repository",
|
|
110
|
+
"couldn't find",
|
|
111
|
+
"could not find",
|
|
112
|
+
"no such file",
|
|
113
|
+
"connection",
|
|
114
|
+
"offline",
|
|
115
|
+
"resolve",
|
|
116
|
+
"404",
|
|
117
|
+
)
|
|
118
|
+
):
|
|
119
|
+
fail("model-missing", str(e))
|
|
120
|
+
else:
|
|
121
|
+
fail("error", str(e))
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
text = str(result.get("text", "")).strip() if isinstance(result, dict) else ""
|
|
125
|
+
sys.stdout.write(json.dumps({"text": text}))
|
|
126
|
+
sys.stdout.flush()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gonext-local-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.101",
|
|
4
4
|
"description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"gonext-local-worker.mjs",
|
|
25
25
|
"gonext_probe_agent.py",
|
|
26
26
|
"gonext_agent_chat.py",
|
|
27
|
+
"gonext_transcribe.py",
|
|
27
28
|
"README.md",
|
|
28
29
|
"launchd/"
|
|
29
30
|
],
|