Pluggable AI providers, relay credit system, picker UX overhaul

Captures roughly forty version bumps (v0.2.6 → v0.2.47) of work that
accumulated without commits.

- Pluggable provider system under server/providers/: gemini, anthropic,
  openai, openai-compatible, ollama, whisper-compatible, relay. Mix and
  match transcription + analysis per request via the picker UI.
- Relay backend integration. Hardcoded relay URL in server/relay-default.js
  (operator-controlled at build time, not user-configurable). New
  /api/relay/{status,policy} endpoints proxy to the relay; balance pings
  populate a cached credit display.
- Per-install identity in server/install-id.js for relay credit accounting.
  Sent to the relay as X-Recap-Install-Id; persists across upgrades, lost
  on a full uninstall + reinstall. Not surfaced in the UI.
- Admin login gate (server/admin-auth.js + setAdminPassword action). Scrypt
  password hash + HMAC-signed session cookie.
- Entitlement scheme rename: pro / max (each paired with subscriptions and
  relay_pro / relay_max), replacing the misleading "core" entitlement
  that conflicted with the user-facing "Core" tier name.
- Activation screen: dynamic credit count pulled from /api/relay/policy,
  "Skip — use free mode" button, accurate paid-feature list.
- Top toolbar: inline credit-balance pill (or "BYO configured" fallback),
  Upgrade + "I have a key" buttons.
- Picker UI: per-provider sections with Save/Test/Delete buttons, sections
  collapsible by chevron, default-collapsed unless currently selected,
  "Use comped credits (reset to relay)" link when the user has strayed,
  green hint under inputs whose values are server-configured.
- Activity log: chevron-collapsible groups per video, refresh-survival via
  localStorage + a 500-entry server-side buffer, explicit Clear button.
- YouTube captions fast-path with user toggle (skips audio download + AI
  transcription when captions are available — uncheck for speaker labels).
- Cancel button: AbortController plumbed through every provider SDK call;
  retryAPI short-circuits on AbortError; cancellation events surface in
  the activity log instead of silent retries.
- Long-video analysis: auto-coalesce transcript entries before building the
  analysis prompt so local-model context windows (32k-ish) don't overflow.
  Original entries preserved for transcript display via an index map; the
  analyzer sees a coarser view but click-to-seek timestamps stay precise.
- StartOS action grouping (Setup / AI Providers) so the actions list is
  navigable.
- Manifest description rewritten to reflect multi-provider support and
  free-tier relay credits.
- Smaller fixes: summarize-button enablement no longer requires a Gemini
  key when other providers are configured; analysis fallback chain handles
  context-length and 503 capacity errors; single-segment expansion for
  providers that don't return per-segment timestamps (Parakeet et al.);
  many other UX polish items.
This commit is contained in:
Keysat
2026-05-11 23:46:20 -05:00
parent 2544cf7dde
commit 373d10595b
79 changed files with 6322 additions and 397 deletions
+108
View File
@@ -0,0 +1,108 @@
import { sdk } from '../sdk'
import { configFile } from '../file-models/config.json'
import { randomBytes, scryptSync } from 'crypto'
const { InputSpec, Value } = sdk
const SCRYPT_KEYLEN = 64
const inputSpec = InputSpec.of({
recap_admin_username: Value.text({
name: 'Admin Username',
description:
'Username required at the login screen. Defaults to "admin".',
required: true,
default: 'admin',
minLength: 1,
maxLength: 64,
}),
recap_admin_password: Value.text({
name: 'Admin Password',
description:
'Password required at the login screen. Must be at least 8 characters. Leave blank to disable the login gate.',
required: false,
default: null,
masked: true,
minLength: 0,
maxLength: 256,
}),
recap_admin_password_confirm: Value.text({
name: 'Confirm Password',
description: 'Re-enter the password to confirm.',
required: false,
default: null,
masked: true,
minLength: 0,
maxLength: 256,
}),
})
export const setAdminPassword = sdk.Action.withInput(
'set-admin-password',
async ({ effects }) => ({
name: 'Set Admin Password',
description:
'Set a username and password that gate the Recap web UI. Anyone visiting the site (LAN or clearnet) must log in before reaching the activation screen. Leave the password blank to disable the gate.',
warning: null,
allowedStatuses: 'any',
group: 'Setup',
visibility: 'enabled',
}),
inputSpec,
async ({ effects }) => {
const config = await configFile.read().once()
return {
recap_admin_username: config?.recap_admin_username || 'admin',
recap_admin_password: undefined,
recap_admin_password_confirm: undefined,
}
},
async ({ effects, input }) => {
const username = (input.recap_admin_username || '').trim()
const password = input.recap_admin_password || ''
const confirm = input.recap_admin_password_confirm || ''
if (!username) {
throw new Error('Username is required.')
}
if (password === '' && confirm === '') {
// Disable the gate: clear hash + salt, keep username for next time.
await configFile.merge(effects, {
recap_admin_username: username,
recap_admin_password_hash: '',
recap_admin_password_salt: '',
})
return null
}
if (password !== confirm) {
throw new Error('Password and confirmation do not match.')
}
if (password.length < 8) {
throw new Error('Password must be at least 8 characters.')
}
const salt = randomBytes(16).toString('hex')
const hash = scryptSync(password, salt, SCRYPT_KEYLEN).toString('hex')
const existing = await configFile.read().once()
const sessionSecret =
existing?.recap_admin_session_secret && existing.recap_admin_session_secret.length > 0
? existing.recap_admin_session_secret
: randomBytes(32).toString('hex')
await configFile.merge(effects, {
recap_admin_username: username,
recap_admin_password_hash: hash,
recap_admin_password_salt: salt,
recap_admin_session_secret: sessionSecret,
})
return null
},
)