Compare commits

..

7 Commits

18 changed files with 210 additions and 71 deletions
-4
View File
@@ -6,10 +6,6 @@ OpenRouterAPIKey=
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
# OpenRouterModel=anthropic/claude-3.5-haiku
# Speech-to-Text Transcription Service (local Parakeet container endpoint)
# Defaults to: http://localhost:5092/v1/audio/transcriptions (or http://parakeet:5092/v1/audio/transcriptions in Docker)
# PARAKEET_URL=http://localhost:5092/v1/audio/transcriptions
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
DeepLAPIKey=
+11 -9
View File
@@ -3237,9 +3237,9 @@ html.theme-cupertino .events-scroll-container {
left: 0;
right: 0;
bottom: 0;
background: rgba(11, 12, 16, 0.75);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
background: rgba(11, 12, 16, 0.45);
backdrop-filter: var(--app-backdrop);
-webkit-backdrop-filter: var(--app-backdrop);
display: flex;
align-items: center;
justify-content: center;
@@ -3247,13 +3247,15 @@ html.theme-cupertino .events-scroll-container {
}
.custom-dialog-card {
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
background: var(--app-surface-hover, var(--app-surface));
backdrop-filter: var(--app-backdrop);
-webkit-backdrop-filter: var(--app-backdrop);
border: 1px solid var(--app-border-subtle);
border-radius: var(--app-radius-card, 16px);
padding: 28px;
width: 90%;
max-width: 420px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
box-shadow: var(--app-shadow);
text-align: center;
display: flex;
flex-direction: column;
@@ -3263,7 +3265,7 @@ html.theme-cupertino .events-scroll-container {
.custom-dialog-title {
font-size: 19px;
font-weight: 700;
color: #fbbf24;
color: var(--app-accent-light);
margin: 0 0 14px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -3271,7 +3273,7 @@ html.theme-cupertino .events-scroll-container {
.custom-dialog-message {
font-size: 15px;
color: #e2e8f0;
color: var(--app-text);
line-height: 1.5;
margin: 0 0 24px 0;
white-space: pre-line;
@@ -7,6 +7,8 @@ import { formatEventSummary } from '../utils/formatEventSummary.js'
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
import { useDialog } from './ModalDialog.tsx'
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
interface EventRemarksCellProps {
event: LogEventPayload
@@ -44,6 +46,13 @@ export default function EventRemarksCell({
e.preventDefault()
e.stopPropagation()
if (transcribing || !preloaded?.audio || !voiceId) return
if (!getAiAuthorized()) {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
return
}
setTranscribing(true)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
@@ -66,9 +75,17 @@ export default function EventRemarksCell({
throw new Error('Transcription returned empty text')
}
await updateVoiceMemoTranscript(logbookId, voiceId, text)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'manual'
})
} catch (err) {
clearTimeout(timeoutId)
console.error('[EventRemarksCell] Transcription failed:', err)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'manual'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} finally {
setTranscribing(false)
+36 -18
View File
@@ -22,6 +22,7 @@ import {
Zap
} from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
import {
appendQuickEvent as apiAppendQuickEvent,
appendQuickEvents as apiAppendQuickEvents,
@@ -834,28 +835,32 @@ export default function LiveLogView({
void (async () => {
try {
const audioDataUrl = await blobToAudioDataUrl(blob)
const authorized = getAiAuthorized()
let transcriptionText = ''
let transcribed = true
let transcriptionError = false
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 4000)
if (authorized) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 4000)
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audioDataUrl }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) throw new Error(`Status ${res.status}`)
const data = await res.json()
transcriptionText = (data.text || '').trim()
} catch (err) {
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
transcriptionError = true
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audioDataUrl }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) throw new Error(`Status ${res.status}`)
const data = await res.json()
transcriptionText = (data.text || '').trim()
} catch (err) {
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
transcriptionError = true
transcribed = false
}
} else {
transcribed = false
}
@@ -885,9 +890,22 @@ export default function LiveLogView({
setVoiceCaption('')
showUndo('voice')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
if (transcriptionError) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'auto'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} else if (authorized) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'auto'
})
} else {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
}
} catch (err: unknown) {
console.error('Live log voice save failed:', err)
+8
View File
@@ -50,6 +50,7 @@ import {
TravelDaySummaryApiError
} from '../services/aiSummary.js'
import { tryDecryptEntryPayload } from '../services/quickEventLog.js'
import { getAiAuthorized } from '../services/userPreferences.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -1209,6 +1210,13 @@ export default function LogEntryEditor({
const handleGenerateAiSummary = async () => {
if (!canSignSkipper || readOnly || aiSummaryLoading) return
if (!getAiAuthorized()) {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
return
}
if (!isOnline) {
setAiSummaryError(t('logs.ai_summary_offline'))
return
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
import ThemedSelect from './ThemedSelect.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
@@ -13,7 +13,9 @@ import {
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
setThemePreference,
getAiAuthorized,
setAiAuthorized
} from '../services/userPreferences.js'
interface UserProfilePreferencesProps {
@@ -28,6 +30,7 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
const [savingOwm, setSavingOwm] = useState(false)
const [owmSaved, setOwmSaved] = useState(false)
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
setThemePreference(userId, nextTheme)
@@ -58,6 +61,12 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
window.setTimeout(() => setOwmSaved(false), 3000)
}
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextVal = e.target.checked
setAiAuthorizedState(nextVal)
setAiAuthorized(userId, nextVal)
}
return (
<>
<section className="member-editor-card glass">
@@ -152,6 +161,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
</form>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('profile.ai_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
{t('profile.ai_desc')}
</p>
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
{t('profile.ai_help')}
</p>
<label
className="switch-label"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: 'pointer',
fontSize: '14px',
color: '#f1f5f9'
}}
>
<input
id="profile-ai-authorize"
type="checkbox"
checked={aiAuthorized}
onChange={handleAiToggle}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>{t('profile.ai_enable_label')}</span>
</label>
</section>
<PushNotificationSettings />
<PwaInstallPrompt variant="inline" />
</>
+6
View File
@@ -672,6 +672,12 @@
"integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nøgle",
"owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.",
"ai_title": "AI-funktioner og privatliv",
"ai_desc": "Autoriser integrationer af kunstig intelligens for dine logbøger.",
"ai_help": "Aktivering af AI-funktioner giver appen mulighed for at opsummere dine rejsedage og transkribere optagede stemmememoer. For at behandle disse anmodninger sendes rå stemmedata og rejselogfiler sikkert løbende til OpenRouter. Der gemmes ingen data permanent af AI-modellen.\n\nDisse cloud-ressourcer koster penge at køre. Hvis du kan lide at bruge dem, bedes du overveje at støtte projektet frivilligt med en donation via Ko-fi-linket i footeren for at holde dem gratis og bæredygtige for alle.",
"ai_enable_label": "Aktiver transkribering og resuméer af rejsedage",
"ai_unauthorized_alert_title": "AI-funktioner er ikke autoriseret",
"ai_unauthorized_alert_desc": "For at bruge transkribering eller rejsedagsresuméer skal du autorisere dataoverførslen til OpenRouter i din brugerprofil under 'AI-funktioner og privatliv'.",
"prefs_save": "Gemme",
"prefs_saving": "Vil blive reddet...",
"prefs_saved": "Gemt",
+6
View File
@@ -672,6 +672,12 @@
"integrations_title": "Integrationen",
"owm_key": "OpenWeatherMap API-Schlüssel",
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"ai_title": "KI-Funktionen & Datenschutz",
"ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.",
"ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.",
"ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren",
"ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert",
"ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.",
"prefs_save": "Speichern",
"prefs_saving": "Wird gespeichert…",
"prefs_saved": "Gespeichert",
+6
View File
@@ -672,6 +672,12 @@
"integrations_title": "Integrations",
"owm_key": "OpenWeatherMap API key",
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"ai_title": "AI Features & Privacy",
"ai_desc": "Authorize artificial intelligence integrations for your logbooks.",
"ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.",
"ai_enable_label": "Enable transcription and travel day summaries",
"ai_unauthorized_alert_title": "AI Features Not Authorized",
"ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.",
"prefs_save": "Save",
"prefs_saving": "Saving…",
"prefs_saved": "Saved",
+6
View File
@@ -672,6 +672,12 @@
"integrations_title": "Integrasjoner",
"owm_key": "OpenWeatherMap API-nøkkel",
"owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.",
"ai_title": "KI-funksjoner og personvern",
"ai_desc": "Autoriser integrasjoner av kunstig intelligens for loggbøkene dine.",
"ai_help": "Aktivering av KI-funksjoner gjør det mulig for appen å oppsummere reisedagene dine og transkribere innspilte talememoer. For å behandle disse forespørslene sendes rå stemmedata og reiselogger sikkert løpende til OpenRouter. Ingen data lagres permanent av KI-modellen.\n\nDisse nettskyressursene koster penger å drifte. Hvis du har glede av å bruke dem, kan du vurdere å støtte prosjektet frivillig med en donasjon via Ko-fi-lenken i bunnteksten for å holde dem gratis og bærekraftige for alle.",
"ai_enable_label": "Aktiver transkribering og oppsummeringer av reisedager",
"ai_unauthorized_alert_title": "KI-funktionen er ikke autorisert",
"ai_unauthorized_alert_desc": "For å bruke transkribering eller reisedagsoppsummeringer, må du autorisere dataoverføringen til OpenRouter i brukerprofilen din under 'KI-funksjoner og personvern'.",
"prefs_save": "Spar",
"prefs_saving": "...vil bli reddet...",
"prefs_saved": "Reddet",
+6
View File
@@ -672,6 +672,12 @@
"integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nyckel",
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
"ai_title": "AI-funktioner och integritet",
"ai_desc": "Auktorisera integrationer av artificiell intelligens för dina loggböcker.",
"ai_help": "Genom at aktivera AI-funktioner kan appen sammanfatta dina rejsdagar och transkribera röstmemon. För att bearbeta dessa förfrågningar skickas röstdata och rejsloggar säkert och tillfälligt till OpenRouter. Inga data sparas permanent av AI-modellen.\n\nDessa molnresurser kostar pengar att driva. Om du gillar att använda dem, överväg att frivilligt stödja projektet med en donation via Ko-fi-länken i sidfoten för att hålla dem gratis och hållbara för alla.",
"ai_enable_label": "Aktivera transkribering och sammanfattningar av rejsdagar",
"ai_unauthorized_alert_title": "AI-funktioner är inte auktoriserade",
"ai_unauthorized_alert_desc": "För att använda transkribering eller rejsdagsöversikter måste du auktorisera dataöverföringen till OpenRouter i din användarprofil under 'AI-funktioner och integritet'.",
"prefs_save": "Spara",
"prefs_saving": "Kommer att sparas...",
"prefs_saved": "Sparade",
+1
View File
@@ -42,6 +42,7 @@ export const PlausibleEvents = {
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
+12 -1
View File
@@ -6,7 +6,9 @@ import {
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js'
const USER_ID = 'test-user-123'
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
expect(getThemePreference(USER_ID)).toBe('ocean')
expect(getColorSchemePreference(USER_ID)).toBe('light')
})
it('stores AI authorization preference per user', () => {
localStorage.setItem('active_userid', USER_ID)
expect(getAiAuthorized()).toBe(false)
setAiAuthorized(USER_ID, true)
expect(getAiAuthorized()).toBe(true)
expect(getAiAuthorized(USER_ID)).toBe(true)
expect(getAiAuthorized('other-user')).toBe(false)
})
})
+17
View File
@@ -89,3 +89,20 @@ export function setOwmApiKey(userId: string, value: string): void {
localStorage.removeItem(owmKey(userId))
}
}
function aiAuthorizedKey(userId: string): string {
return `user_pref_ai_authorized_${userId}`
}
export function getAiAuthorized(userId?: string | null): boolean {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(aiAuthorizedKey(id)) === 'true'
}
return false
}
export function setAiAuthorized(userId: string, value: boolean): void {
localStorage.setItem(aiAuthorizedKey(userId), String(value))
}
-8
View File
@@ -33,7 +33,6 @@ services:
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
PARAKEET_URL: ${PARAKEET_URL:-http://parakeet:5092/v1/audio/transcriptions}
SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
@@ -67,13 +66,6 @@ services:
backend:
condition: service_healthy
parakeet:
image: ghcr.io/achetronic/parakeet:latest
container_name: daagbox-staging-parakeet
restart: always
ports:
- "5092:5092"
volumes:
pgdata:
name: daagbox-staging-pgdata
-8
View File
@@ -34,7 +34,6 @@ services:
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
PARAKEET_URL: ${PARAKEET_URL:-http://parakeet:5092/v1/audio/transcriptions}
SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
@@ -68,13 +67,6 @@ services:
backend:
condition: service_healthy
parakeet:
image: ghcr.io/achetronic/parakeet:latest
container_name: daagbox-prod-parakeet
restart: always
ports:
- "5092:5092"
volumes:
pgdata:
name: daagbox-prod-pgdata
+2
View File
@@ -47,6 +47,7 @@ Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der ak
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
| Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
@@ -161,6 +162,7 @@ trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, { status: 'success', mode: 'auto' })
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
+29 -21
View File
@@ -3,8 +3,6 @@ import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
const PARAKEET_URL = process.env.PARAKEET_URL || 'http://localhost:5092/v1/audio/transcriptions'
const MAX_ATTEMPTS_PER_ENTRY = 3
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
@@ -238,51 +236,61 @@ router.post('/transcribe', async (req: any, res) => {
return res.status(400).json({ error: 'audioDataUrl is required' })
}
const match = audioDataUrl.match(/^data:([^;]+);base64,(.+)$/)
const match = audioDataUrl.match(/^data:(.+);base64,(.+)$/)
if (!match) {
return res.status(400).json({ error: 'Invalid audio data URL format' })
}
const [, mimeType, base64Data] = match
const buffer = Buffer.from(base64Data, 'base64')
const [, fullMimeType, base64Data] = match
const mimeType = fullMimeType.split(';')[0]
let ext = 'webm'
if (mimeType.includes('mp4')) ext = 'mp4'
else if (mimeType.includes('ogg')) ext = 'ogg'
else if (mimeType.includes('wav')) ext = 'wav'
const filename = `audio.${ext}`
const file = new File([buffer], filename, { type: mimeType })
const apiKey = resolveOpenRouterApiKey()
if (!apiKey) {
console.warn('[server] OpenRouter API key not configured, transcription unavailable')
return res.status(503).json({ error: 'Transcription service not configured' })
}
const formData = new FormData()
formData.append('file', file)
console.log(`[server] Forwarding ASR request to ${PARAKEET_URL} (${filename}, ${buffer.length} bytes)`)
console.log(`[server] Forwarding ASR request to OpenRouter (${ext}, ${base64Data.length} chars)`)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
const timeoutId = setTimeout(() => controller.abort(), 30000)
try {
const parakeetRes = await fetch(PARAKEET_URL, {
const openRouterRes = await fetch('https://openrouter.ai/api/v1/audio/transcriptions', {
method: 'POST',
body: formData,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'openai/whisper-large-v3-turbo',
input_audio: {
data: base64Data,
format: ext
}
}),
signal: controller.signal
})
if (!parakeetRes.ok) {
const errorText = await parakeetRes.text().catch(() => '')
console.error(`[server] Parakeet ASR error response (status=${parakeetRes.status}):`, errorText)
throw new Error(`Parakeet returned status ${parakeetRes.status}`)
if (!openRouterRes.ok) {
const errorText = await openRouterRes.text().catch(() => '')
console.error(`[server] OpenRouter ASR error response (status=${openRouterRes.status}):`, errorText)
throw new Error(`OpenRouter returned status ${openRouterRes.status}`)
}
const data: any = await parakeetRes.json()
const data: any = await openRouterRes.json()
const text = (data?.text || '').trim()
console.log(`[server] ASR completed successfully: "${text}"`)
console.log(`[server] OpenRouter ASR completed successfully: "${text}"`)
return res.json({ text })
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
console.error('[server] Parakeet ASR request timed out')
console.error('[server] OpenRouter ASR request timed out')
return res.status(504).json({ error: 'Transcription request timed out' })
}
throw error