v0.2 hardware backend

This commit is contained in:
local
2026-05-11 20:14:50 -05:00
parent b9d86fa303
commit cccbee27e4
9 changed files with 607 additions and 40 deletions
+28
View File
@@ -0,0 +1,28 @@
# Recap Relay
Operator-side credit-metered proxy for Recap clients. Fronts Google Gemini (and optionally a local Parakeet+Gemma setup) so Recap users on Core/Pro/Max tiers can summarize videos without bringing their own API keys.
## What it does
- Receives `POST /relay/transcribe` and `POST /relay/analyze` from Recap installs
- Validates `X-Recap-Install-Id` and optional `Authorization: Bearer LIC1-...` against a Keysat license server (cached online check)
- Tracks per-install credit balances in `/data/credits.json` with calendar-month rollover for paid tiers
- Routes to Gemini first; falls back to the operator's Parakeet+Gemma above the per-user-per-month Gemini cap
## Setup
After installing, set:
1. **Gemini API Key** — required, the relay's primary backend
2. **Keysat URL** — defaults to `https://keysat.xyz`; override to the internal hostname if Keysat is co-located on this Start9 server
3. **Admin Password** — gates the `/admin/*` dashboard
4. (Optional) **Parakeet URL** + **Gemma URL** — operator-hardware fallback for Pro/Max overflow
Then forward a public hostname (e.g. `relay.yourdomain.com`) to this service via StartTunnel, and point your Recap install's "Set Relay URL" action at that hostname.
## Tier defaults
- **Core** (unlicensed): 5 lifetime credits
- **Pro** (`relay_pro` entitlement): 50 monthly credits, max 25 via Gemini
- **Max** (`relay_max` entitlement): unlimited monthly, max 50 via Gemini
Adjust via the "Adjust Tier Quotas" action without redeploying.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA=
-----END PUBLIC KEY-----
+359
View File
@@ -0,0 +1,359 @@
{
"name": "recap-relay-startos",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "recap-relay-startos",
"dependencies": {
"@start9labs/start-sdk": "1.0.0"
},
"devDependencies": {
"@types/node": "^22.19.0",
"@vercel/ncc": "^0.38.4",
"prettier": "^3.6.2",
"typescript": "^5.9.3"
}
},
"node_modules/@iarna/toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==",
"license": "ISC"
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@start9labs/start-sdk": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.0.0.tgz",
"integrity": "sha512-rtAfumVbMy90iw2WRbWH7fGcuwAvvuFfR4YwgSsh5R2Bz9MXtcEfmznwhnrp+ntQ6BOUSQ0wLzePbfsS6kUagg==",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^3.0.0",
"@noble/curves": "^1.8.2",
"@noble/hashes": "^1.7.2",
"@types/ini": "^4.1.1",
"deep-equality-data-structures": "^2.0.0",
"fast-xml-parser": "^5.5.6",
"ini": "^5.0.0",
"isomorphic-fetch": "^3.0.0",
"mime": "^4.0.7",
"yaml": "^2.7.1",
"zod": "^4.3.6",
"zod-deep-partial": "^1.2.0"
}
},
"node_modules/@types/ini": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz",
"integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@vercel/ncc": {
"version": "0.38.4",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz",
"integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==",
"dev": true,
"license": "MIT",
"bin": {
"ncc": "dist/ncc/cli.js"
}
},
"node_modules/deep-equality-data-structures": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz",
"integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==",
"license": "MIT",
"dependencies": {
"object-hash": "^3.0.0"
}
},
"node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.7",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/ini": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz",
"integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==",
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/isomorphic-fetch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.1",
"whatwg-fetch": "^3.4.1"
}
},
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/strnum": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-deep-partial": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz",
"integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==",
"license": "MIT",
"peerDependencies": {
"zod": "^4.1.13"
}
}
}
}
+194 -30
View File
@@ -1,55 +1,219 @@
// Operator-hardware fallback backend. Forwards transcribe requests to
// the operator's Parakeet (or any Whisper-API-compatible) endpoint and
// analyze requests to their Gemma (or any OpenAI-API-compatible) endpoint.
// a Parakeet endpoint (or any Whisper-API-compatible server — same wire
// format) and analyze requests to a Gemma endpoint (or any
// OpenAI-compatible chat-completions server).
//
// v0.1 is a stub — the endpoints are wired up, but no operator has
// pointed a real Parakeet/Gemma at the relay yet. Returns a 503
// "hardware fallback not yet wired" so the credits.js routing logic
// still applies but users get a clear message instead of a silent
// failure.
// Used when a Pro/Max user has exceeded their monthly Gemini cap.
// Returns the same shape gemini.js produces so route handlers don't
// need a backend-specific branch downstream:
// transcribeAudio → { text, segments, duration_seconds }
// analyzeText → { text }
//
// Both endpoints are reached via plain fetch — no SDK dependency keeps
// the relay container slim and the upstream wire format is dead-simple
// for these two well-known shapes.
const ANALYZE_MAX_TOKENS = 16000;
// Gemma served locally tends to live on the host's LAN, not the public
// internet, so generous timeouts. Same scale as Recap's defaults.
const DEFAULT_TIMEOUT_MS = 900_000;
// Pull the model identifier out of the prompt if the operator wants a
// specific Gemma SKU. We default to "gemma3:27b" which is the typical
// Ollama tag for the analysis-capable Gemma model. Operators with a
// different deployment can update this via a future StartOS action;
// for v0.2 it's hardcoded.
const HARDWARE_ANALYZE_MODEL = process.env.RELAY_GEMMA_MODEL || "gemma3:27b";
// Parakeet's typical model identifier. Mirrors what Recap's whisper.js
// sends when the operator points the relay at a NeMo Parakeet HTTP
// wrapper. Configurable via env var for non-default deployments.
const HARDWARE_TRANSCRIBE_MODEL =
process.env.RELAY_PARAKEET_MODEL || "parakeet-tdt-0.6b-v3";
export function createHardwareBackend({
parakeetBaseURL = "",
gemmaBaseURL = "",
timeoutMs = DEFAULT_TIMEOUT_MS,
} = {}) {
const hasParakeet = !!parakeetBaseURL;
const hasGemma = !!gemmaBaseURL;
const parakeet = parakeetBaseURL ? parakeetBaseURL.replace(/\/$/, "") : "";
const gemma = gemmaBaseURL ? gemmaBaseURL.replace(/\/$/, "") : "";
return {
hasTranscribe: hasParakeet,
hasAnalyze: hasGemma,
hasTranscribe: !!parakeet,
hasAnalyze: !!gemma,
async transcribeAudio() {
if (!hasParakeet) {
// POST <parakeet>/v1/audio/transcriptions with the OpenAI Whisper
// multipart shape. Parakeet wrappers (NeMo + the patched one Recap
// already talks to) honor this format and return segments with
// per-segment timestamps when timestamp_granularities=segment is
// requested. Falls back to a bare request if the rich shape 4xx/5xxs.
async transcribeAudio({
audio,
mimeType = "application/octet-stream",
offsetSeconds = 0,
}) {
if (!parakeet) {
const err = new Error(
"operator-hardware transcribe path is not configured (relay_parakeet_base_url is empty)"
"operator-hardware transcribe is not configured (relay_parakeet_base_url is empty)"
);
err.status = 503;
throw err;
}
// TODO v0.2: POST audio to parakeetBaseURL using the OpenAI
// audio-transcriptions wire format Recap already speaks. Return
// { text, segments, duration_seconds } in the same shape as
// gemini.js's transcribeAudio.
const err = new Error("operator-hardware transcribe path not yet implemented in relay v0.1");
err.status = 503;
throw err;
// Try the rich request first (verbose_json + segment timestamps).
// FormData/Blob globals are available in Node 20+. Wrap the
// received Buffer in a Blob so the multipart body is properly
// chunked instead of falling back to base64.
const buildForm = (richMode) => {
const form = new FormData();
const blob = new Blob([audio], { type: mimeType });
form.append("file", blob, "audio.bin");
form.append("model", HARDWARE_TRANSCRIBE_MODEL);
if (richMode) {
form.append("response_format", "verbose_json");
form.append("timestamp_granularities[]", "segment");
}
return form;
};
const url = `${parakeet}/v1/audio/transcriptions`;
let res;
try {
res = await fetch(url, {
method: "POST",
body: buildForm(true),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const e = new Error(
`Parakeet transcribe network error: ${err?.message || err}`
);
e.status = 502;
throw e;
}
// If the wrapper rejects the rich params, retry with bare-bones.
if (!res.ok && res.status >= 400 && res.status < 600) {
const richBody = await safeBody(res);
console.warn(
`[hardware] rich Parakeet request returned ${res.status}: ${richBody.slice(0, 200)} — retrying bare`
);
try {
res = await fetch(url, {
method: "POST",
body: buildForm(false),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const e = new Error(
`Parakeet transcribe network error (fallback): ${err?.message || err}`
);
e.status = 502;
throw e;
}
}
if (!res.ok) {
const body = await safeBody(res);
const e = new Error(
`Parakeet transcribe ${res.status}: ${body.slice(0, 300)}`
);
e.status = res.status;
throw e;
}
const data = await res.json();
const segments = Array.isArray(data.segments) ? data.segments : [];
// Offset support: when the relay caller is processing a chunked
// audio file, it asks for transcripts at a non-zero base time.
// Parakeet returns timestamps relative to the chunk; shift them
// up by offsetSeconds so the combined transcript downstream
// lines up with the real video timeline.
const shifted = segments.map((s) => ({
start: (s.start || 0) + offsetSeconds,
end: (s.end || 0) + offsetSeconds,
text: (s.text || "").trim(),
}));
// Build the [MM:SS] text format Recap's parseTimestampedTranscript
// already speaks. The route handler will pass this straight back
// to Recap, which parses it on the client side.
const lines = shifted.length
? shifted.map((s) => `[${formatMmSs(s.start)}] ${s.text}`)
: [`[0:00] ${(data.text || "").trim()}`];
return {
text: lines.join("\n"),
segments: shifted,
duration_seconds: data.duration || 0,
};
},
async analyzeText() {
if (!hasGemma) {
// POST <gemma>/v1/chat/completions with the OpenAI shape. Ollama's
// server, vLLM, llama.cpp's HTTP server, and most other OSS LLM
// runners support this wire format — so we don't lock the relay
// to one specific Gemma deployment.
async analyzeText({ prompt }) {
if (!gemma) {
const err = new Error(
"operator-hardware analyze path is not configured (relay_gemma_base_url is empty)"
"operator-hardware analyze is not configured (relay_gemma_base_url is empty)"
);
err.status = 503;
throw err;
}
// TODO v0.2: POST prompt to gemmaBaseURL using either /api/generate
// (Ollama native) or /v1/chat/completions (OpenAI-compatible).
// Return { text } matching gemini.js's analyzeText.
const err = new Error("operator-hardware analyze path not yet implemented in relay v0.1");
err.status = 503;
throw err;
const url = `${gemma}/v1/chat/completions`;
let res;
try {
res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: HARDWARE_ANALYZE_MODEL,
max_tokens: ANALYZE_MAX_TOKENS,
messages: [{ role: "user", content: prompt }],
stream: false,
}),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const e = new Error(
`Gemma analyze network error: ${err?.message || err}`
);
e.status = 502;
throw e;
}
if (!res.ok) {
const body = await safeBody(res);
const e = new Error(`Gemma analyze ${res.status}: ${body.slice(0, 300)}`);
e.status = res.status;
throw e;
}
const data = await res.json();
const text = data?.choices?.[0]?.message?.content || "";
return { text };
},
};
}
function formatMmSs(seconds) {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0)
return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${m}:${String(sec).padStart(2, "0")}`;
}
async function safeBody(res) {
try {
return await res.text();
} catch {
return "";
}
}
+6 -6
View File
@@ -2,14 +2,14 @@ export const DEFAULT_LANG = 'en_US'
const dict = {
// main.ts
'Starting Recap...': 0,
'Web Interface': 1,
'Recap is ready': 2,
'Recap is not responding': 3,
'Starting Recap Relay...': 0,
'Relay Endpoint': 1,
'Relay is accepting connections': 2,
'Relay is not responding': 3,
// interfaces.ts
'Web UI': 4,
'The web interface for Recap — browse, search, and manage your transcript library': 5,
'HTTP endpoint for Recap clients to relay transcribe + analyze calls. Also serves the operator admin dashboard at /admin/.':
4,
} as const
/**
+1 -2
View File
@@ -17,8 +17,7 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
name: i18n('Relay Endpoint'),
id: 'api',
description: i18n(
'HTTP endpoint for Recap clients to relay transcribe + analyze ' +
'calls. Also serves the operator admin dashboard at /admin/.',
'HTTP endpoint for Recap clients to relay transcribe + analyze calls. Also serves the operator admin dashboard at /admin/.',
),
type: 'ui',
masked: false,
+3 -2
View File
@@ -1,7 +1,8 @@
import { VersionGraph } from '@start9labs/start-sdk'
import { v_0_1_0 } from './v0.1.0'
import { v_0_2_0 } from './v0.2.0'
export const versionGraph = VersionGraph.of({
current: v_0_1_0,
other: [],
current: v_0_2_0,
other: [v_0_1_0],
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_0 = VersionInfo.of({
version: '0.2.0:0',
releaseNotes: {
en_US:
'Implements the operator-hardware fallback path. Parakeet transcribe forwarding speaks the OpenAI Whisper API wire format (with verbose_json + segment timestamps, and a bare-shape fallback for wrappers that reject the rich params); Gemma analyze forwarding uses /v1/chat/completions for broad compatibility with Ollama / vLLM / llama.cpp servers.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})