Compare commits

...

10 Commits

20 changed files with 457 additions and 43 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.1.22 0.1.1.24
+11 -9
View File
@@ -3237,9 +3237,9 @@ html.theme-cupertino .events-scroll-container {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(11, 12, 16, 0.75); background: rgba(11, 12, 16, 0.45);
backdrop-filter: blur(10px); backdrop-filter: var(--app-backdrop);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: var(--app-backdrop);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -3247,13 +3247,15 @@ html.theme-cupertino .events-scroll-container {
} }
.custom-dialog-card { .custom-dialog-card {
background: rgba(15, 23, 42, 0.85); background: var(--app-surface-hover, var(--app-surface));
border: 1px solid rgba(255, 255, 255, 0.08); backdrop-filter: var(--app-backdrop);
border-radius: 16px; -webkit-backdrop-filter: var(--app-backdrop);
border: 1px solid var(--app-border-subtle);
border-radius: var(--app-radius-card, 16px);
padding: 28px; padding: 28px;
width: 90%; width: 90%;
max-width: 420px; max-width: 420px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6); box-shadow: var(--app-shadow);
text-align: center; text-align: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3263,7 +3265,7 @@ html.theme-cupertino .events-scroll-container {
.custom-dialog-title { .custom-dialog-title {
font-size: 19px; font-size: 19px;
font-weight: 700; font-weight: 700;
color: #fbbf24; color: var(--app-accent-light);
margin: 0 0 14px 0; margin: 0 0 14px 0;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -3271,7 +3273,7 @@ html.theme-cupertino .events-scroll-container {
.custom-dialog-message { .custom-dialog-message {
font-size: 15px; font-size: 15px;
color: #e2e8f0; color: var(--app-text);
line-height: 1.5; line-height: 1.5;
margin: 0 0 24px 0; margin: 0 0 24px 0;
white-space: pre-line; white-space: pre-line;
+101 -1
View File
@@ -1,24 +1,97 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Mic, Loader2 } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js' import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js' import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { formatEventSummary } from '../utils/formatEventSummary.js' import { formatEventSummary } from '../utils/formatEventSummary.js'
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx' 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 { interface EventRemarksCellProps {
event: LogEventPayload event: LogEventPayload
logbookId: string logbookId: string
voiceMemoLookup?: Map<string, PreloadedVoiceMemo> voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
readOnly?: boolean
} }
export default function EventRemarksCell({ export default function EventRemarksCell({
event, event,
logbookId, logbookId,
voiceMemoLookup voiceMemoLookup,
readOnly = false
}: EventRemarksCellProps) { }: EventRemarksCellProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { showAlert } = useDialog()
const voiceId = parseLiveVoiceRemark(event.remarks.trim()) const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
const [transcribing, setTranscribing] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
const handleTranscribe = async (e: React.MouseEvent) => {
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)
try {
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ audioDataUrl: preloaded.audio }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) {
throw new Error(`Server returned status ${res.status}`)
}
const data = await res.json()
const text = (data.text || '').trim()
if (!text) {
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)
}
}
let summary = formatEventSummary(event, t) let summary = formatEventSummary(event, t)
if (voiceId && preloaded?.caption) { if (voiceId && preloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: preloaded.caption }) summary = t('logs.live_voice_entry', { caption: preloaded.caption })
@@ -28,12 +101,39 @@ export default function EventRemarksCell({
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}> <div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
<span>{summary}</span> <span>{summary}</span>
{voiceId && ( {voiceId && (
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
<VoiceMemoPlayer <VoiceMemoPlayer
audioId={voiceId} audioId={voiceId}
logbookId={logbookId} logbookId={logbookId}
preloaded={preloaded} preloaded={preloaded}
compact compact
/> />
{!readOnly && preloaded && preloaded.transcribed === false && isOnline && (
<button
type="button"
className="btn-icon-text link-sec"
style={{
fontSize: '0.8rem',
padding: '2px 6px',
height: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
margin: 0
}}
onClick={handleTranscribe}
disabled={transcribing}
title={t('logs.live_voice_transcribe_action')}
>
{transcribing ? (
<Loader2 size={12} className="spin" />
) : (
<Mic size={12} />
)}
{transcribing ? t('logs.live_voice_transcribing') : t('logs.live_voice_transcribe_action')}
</button>
)}
</div>
)} )}
</div> </div>
) )
+61 -17
View File
@@ -22,6 +22,7 @@ import {
Zap Zap
} from 'lucide-react' } from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
import { import {
appendQuickEvent as apiAppendQuickEvent, appendQuickEvent as apiAppendQuickEvent,
appendQuickEvents as apiAppendQuickEvents, appendQuickEvents as apiAppendQuickEvents,
@@ -31,7 +32,6 @@ import {
removeLastEvent removeLastEvent
} from '../services/quickEventLog.js' } from '../services/quickEventLog.js'
import CreatorAvatar from './CreatorAvatar.tsx' import CreatorAvatar from './CreatorAvatar.tsx'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import { import {
getLastAutoPositionMs, getLastAutoPositionMs,
getLastLoggedPositionWithin, getLastLoggedPositionWithin,
@@ -43,7 +43,6 @@ import {
liveFuelRemark, liveFuelRemark,
livePhotoRemark, livePhotoRemark,
liveVoiceRemark, liveVoiceRemark,
parseLiveVoiceRemark,
livePrecipRemark, livePrecipRemark,
liveSailsRemark, liveSailsRemark,
liveSogRemark, liveSogRemark,
@@ -80,7 +79,7 @@ import CourseDialInput from './CourseDialInput.tsx'
import GpsSignalHint from './GpsSignalHint.tsx' import GpsSignalHint from './GpsSignalHint.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx' import LiveCameraCapture from './LiveCameraCapture.tsx'
import LiveVoiceCapture from './LiveVoiceCapture.tsx' import LiveVoiceCapture from './LiveVoiceCapture.tsx'
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx' import EventRemarksCell from './EventRemarksCell.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js' import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js' import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js' import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
@@ -836,13 +835,50 @@ export default function LiveLogView({
void (async () => { void (async () => {
try { try {
const audioDataUrl = await blobToAudioDataUrl(blob) const audioDataUrl = await blobToAudioDataUrl(blob)
const authorized = getAiAuthorized()
let transcriptionText = ''
let transcribed = true
let transcriptionError = false
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
transcribed = false
}
} else {
transcribed = false
}
let finalCaption = caption
if (transcriptionText) {
finalCaption = caption
? `${caption}\n(Transkript: ${transcriptionText})`
: transcriptionText
}
const voiceId = await saveEntryVoiceMemo({ const voiceId = await saveEntryVoiceMemo({
logbookId, logbookId,
entryId, entryId,
audioDataUrl, audioDataUrl,
mimeType, mimeType,
durationSec, durationSec,
caption, caption: finalCaption,
transcribed,
analyticsContext: 'live_log' analyticsContext: 'live_log'
}) })
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
@@ -854,6 +890,23 @@ export default function LiveLogView({
setVoiceCaption('') setVoiceCaption('')
showUndo('voice') showUndo('voice')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: '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) { } catch (err: unknown) {
console.error('Live log voice save failed:', err) console.error('Live log voice save failed:', err)
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE' const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
@@ -1225,12 +1278,6 @@ export default function LiveLogView({
) : ( ) : (
<ol className="live-log-stream"> <ol className="live-log-stream">
{events.map((event, index) => { {events.map((event, index) => {
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
let summary = formatEventSummary(event, t)
if (voiceId && voicePreloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption })
}
return ( return (
<li key={`${event.time}-${index}`} className="live-log-entry"> <li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time> <time className="live-log-time">{event.time}</time>
@@ -1240,15 +1287,12 @@ export default function LiveLogView({
size={24} size={24}
/> />
<div className="live-log-summary-block"> <div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span> <EventRemarksCell
{voiceId && ( event={event}
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId} logbookId={logbookId}
preloaded={voicePreloaded} voiceMemoLookup={voiceMemoLookup}
compact readOnly={false}
/> />
)}
</div> </div>
</li> </li>
) )
+9
View File
@@ -50,6 +50,7 @@ import {
TravelDaySummaryApiError TravelDaySummaryApiError
} from '../services/aiSummary.js' } from '../services/aiSummary.js'
import { tryDecryptEntryPayload } from '../services/quickEventLog.js' import { tryDecryptEntryPayload } from '../services/quickEventLog.js'
import { getAiAuthorized } from '../services/userPreferences.js'
import { import {
getDecryptedTrack, getDecryptedTrack,
saveUploadedTrack, saveUploadedTrack,
@@ -1209,6 +1210,13 @@ export default function LogEntryEditor({
const handleGenerateAiSummary = async () => { const handleGenerateAiSummary = async () => {
if (!canSignSkipper || readOnly || aiSummaryLoading) return if (!canSignSkipper || readOnly || aiSummaryLoading) return
if (!getAiAuthorized()) {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
return
}
if (!isOnline) { if (!isOnline) {
setAiSummaryError(t('logs.ai_summary_offline')) setAiSummaryError(t('logs.ai_summary_offline'))
return return
@@ -1909,6 +1917,7 @@ export default function LogEntryEditor({
event={ev} event={ev}
logbookId={logbookId} logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup} voiceMemoLookup={voiceMemoLookup}
readOnly={readOnly}
/> />
</td> </td>
{!readOnly && ( {!readOnly && (
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' 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 ThemedSelect from './ThemedSelect.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx' import PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx' import PwaInstallPrompt from './PwaInstallPrompt.tsx'
@@ -13,7 +13,9 @@ import {
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setOwmApiKey, setOwmApiKey,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from '../services/userPreferences.js' } from '../services/userPreferences.js'
interface UserProfilePreferencesProps { interface UserProfilePreferencesProps {
@@ -28,6 +30,7 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId)) const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
const [savingOwm, setSavingOwm] = useState(false) const [savingOwm, setSavingOwm] = useState(false)
const [owmSaved, setOwmSaved] = useState(false) const [owmSaved, setOwmSaved] = useState(false)
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
const persistAppearance = (nextTheme: string, nextColorScheme: string) => { const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
setThemePreference(userId, nextTheme) setThemePreference(userId, nextTheme)
@@ -58,6 +61,12 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
window.setTimeout(() => setOwmSaved(false), 3000) window.setTimeout(() => setOwmSaved(false), 3000)
} }
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextVal = e.target.checked
setAiAuthorizedState(nextVal)
setAiAuthorized(userId, nextVal)
}
return ( return (
<> <>
<section className="member-editor-card glass"> <section className="member-editor-card glass">
@@ -152,6 +161,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
</form> </form>
</section> </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 /> <PushNotificationSettings />
<PwaInstallPrompt variant="inline" /> <PwaInstallPrompt variant="inline" />
</> </>
@@ -11,6 +11,7 @@ export interface PreloadedVoiceMemo {
mimeType?: string mimeType?: string
durationSec?: number durationSec?: number
caption?: string caption?: string
transcribed?: boolean
} }
interface VoiceMemoPlayerProps { interface VoiceMemoPlayerProps {
+2 -1
View File
@@ -48,7 +48,8 @@ export function useEntryVoiceMemos(
audio: String(decrypted.audio), audio: String(decrypted.audio),
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined, mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined, durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
caption: decrypted.caption ? String(decrypted.caption) : '' caption: decrypted.caption ? String(decrypted.caption) : '',
transcribed: decrypted.transcribed !== false
}) })
} catch { } catch {
// skip corrupt memo // skip corrupt memo
+9
View File
@@ -297,6 +297,9 @@
"live_voice_entry_plain": "Stemmenotat", "live_voice_entry_plain": "Stemmenotat",
"live_voice_caption_label": "Billedtekst (valgfrit)", "live_voice_caption_label": "Billedtekst (valgfrit)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester", "live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
"live_voice_transcribe_action": "Transkribere",
"live_voice_transcribing": "Transkriberer…",
"live_voice_transcribe_failed": "Stemmebesked gemt, men transkribering mislykkedes.",
"live_undo_voice_hint": "Stemmenotat gemt", "live_undo_voice_hint": "Stemmenotat gemt",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…", "live_comment_placeholder": "Indtast tekst…",
@@ -669,6 +672,12 @@
"integrations_title": "Integrationer", "integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nøgle", "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.", "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_save": "Gemme",
"prefs_saving": "Vil blive reddet...", "prefs_saving": "Vil blive reddet...",
"prefs_saved": "Gemt", "prefs_saved": "Gemt",
+9
View File
@@ -297,6 +297,9 @@
"live_voice_entry_plain": "Sprachnotiz", "live_voice_entry_plain": "Sprachnotiz",
"live_voice_caption_label": "Beschriftung (optional)", "live_voice_caption_label": "Beschriftung (optional)",
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister", "live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
"live_voice_transcribe_action": "Transkribieren",
"live_voice_transcribing": "Transkribiere...",
"live_voice_transcribe_failed": "Sprachmemo gespeichert, aber Transkription fehlgeschlagen.",
"live_undo_voice_hint": "Sprachnotiz gespeichert", "live_undo_voice_hint": "Sprachnotiz gespeichert",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…", "live_comment_placeholder": "Freitext eingeben…",
@@ -669,6 +672,12 @@
"integrations_title": "Integrationen", "integrations_title": "Integrationen",
"owm_key": "OpenWeatherMap API-Schlüssel", "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.", "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_save": "Speichern",
"prefs_saving": "Wird gespeichert…", "prefs_saving": "Wird gespeichert…",
"prefs_saved": "Gespeichert", "prefs_saved": "Gespeichert",
+9
View File
@@ -297,6 +297,9 @@
"live_voice_entry_plain": "Voice memo", "live_voice_entry_plain": "Voice memo",
"live_voice_caption_label": "Caption (optional)", "live_voice_caption_label": "Caption (optional)",
"live_voice_caption_placeholder": "e.g. radio call with harbour master", "live_voice_caption_placeholder": "e.g. radio call with harbour master",
"live_voice_transcribe_action": "Transcribe",
"live_voice_transcribing": "Transcribing…",
"live_voice_transcribe_failed": "Voice memo saved, but transcription failed.",
"live_undo_voice_hint": "Voice memo saved", "live_undo_voice_hint": "Voice memo saved",
"live_comment_btn": "Comment", "live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…", "live_comment_placeholder": "Enter text…",
@@ -669,6 +672,12 @@
"integrations_title": "Integrations", "integrations_title": "Integrations",
"owm_key": "OpenWeatherMap API key", "owm_key": "OpenWeatherMap API key",
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.", "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_save": "Save",
"prefs_saving": "Saving…", "prefs_saving": "Saving…",
"prefs_saved": "Saved", "prefs_saved": "Saved",
+9
View File
@@ -297,6 +297,9 @@
"live_voice_entry_plain": "Talemelding", "live_voice_entry_plain": "Talemelding",
"live_voice_caption_label": "Bildetekst (valgfritt)", "live_voice_caption_label": "Bildetekst (valgfritt)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef", "live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
"live_voice_transcribe_action": "Transkribere",
"live_voice_transcribing": "Transkriberer…",
"live_voice_transcribe_failed": "Taleopptak lagret, men transkribering mislyktes.",
"live_undo_voice_hint": "Talemelding lagret", "live_undo_voice_hint": "Talemelding lagret",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…", "live_comment_placeholder": "Skriv inn tekst…",
@@ -669,6 +672,12 @@
"integrations_title": "Integrasjoner", "integrations_title": "Integrasjoner",
"owm_key": "OpenWeatherMap API-nøkkel", "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.", "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_save": "Spar",
"prefs_saving": "...vil bli reddet...", "prefs_saving": "...vil bli reddet...",
"prefs_saved": "Reddet", "prefs_saved": "Reddet",
+9
View File
@@ -297,6 +297,9 @@
"live_voice_entry_plain": "Röstanteckning", "live_voice_entry_plain": "Röstanteckning",
"live_voice_caption_label": "Bildtext (valfritt)", "live_voice_caption_label": "Bildtext (valfritt)",
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare", "live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
"live_voice_transcribe_action": "Transkribera",
"live_voice_transcribing": "Transkriberar…",
"live_voice_transcribe_failed": "Röstanteckning sparad, men transkribering misslyckades.",
"live_undo_voice_hint": "Röstanteckning sparad", "live_undo_voice_hint": "Röstanteckning sparad",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…", "live_comment_placeholder": "Ange text…",
@@ -669,6 +672,12 @@
"integrations_title": "Integrationer", "integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nyckel", "owm_key": "OpenWeatherMap API-nyckel",
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.", "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_save": "Spara",
"prefs_saving": "Kommer att sparas...", "prefs_saving": "Kommer att sparas...",
"prefs_saved": "Sparade", "prefs_saved": "Sparade",
+1
View File
@@ -42,6 +42,7 @@ export const PlausibleEvents = {
LIVE_LOG_OPENED: 'Live Log Opened', LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged', LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded', VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched', OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated', AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft', PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
+12 -1
View File
@@ -6,7 +6,9 @@ import {
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setOwmApiKey, setOwmApiKey,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js' } from './userPreferences.js'
const USER_ID = 'test-user-123' const USER_ID = 'test-user-123'
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
expect(getThemePreference(USER_ID)).toBe('ocean') expect(getThemePreference(USER_ID)).toBe('ocean')
expect(getColorSchemePreference(USER_ID)).toBe('light') 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)) 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))
}
+57 -2
View File
@@ -1,7 +1,7 @@
import { db } from './db.js' import { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js' import { encryptJson, decryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js' import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
@@ -18,6 +18,7 @@ export async function saveEntryVoiceMemo(options: {
mimeType: string mimeType: string
durationSec: number durationSec: number
caption?: string caption?: string
transcribed?: boolean
analyticsContext?: string analyticsContext?: string
}): Promise<string> { }): Promise<string> {
const { const {
@@ -27,6 +28,7 @@ export async function saveEntryVoiceMemo(options: {
mimeType, mimeType,
durationSec, durationSec,
caption = '', caption = '',
transcribed = true,
analyticsContext = 'logbook' analyticsContext = 'logbook'
} = options } = options
const masterKey = await getEncryptionKey(logbookId) const masterKey = await getEncryptionKey(logbookId)
@@ -35,7 +37,8 @@ export async function saveEntryVoiceMemo(options: {
audio: audioDataUrl, audio: audioDataUrl,
mimeType, mimeType,
durationSec, durationSec,
caption: caption.trim() caption: caption.trim(),
transcribed: !!transcribed
} }
const encrypted = await encryptJson(voicePayload, masterKey) const encrypted = await encryptJson(voicePayload, masterKey)
@@ -98,3 +101,55 @@ export async function removeLastVoiceMemoForEntry(
await deleteEntryVoiceMemo(logbookId, lastId) await deleteEntryVoiceMemo(logbookId, lastId)
return lastId return lastId
} }
/** Updates an existing voice memo payload with a new transcript and sets transcribed: true. */
export async function updateVoiceMemoTranscript(
logbookId: string,
voiceId: string,
transcript: string
): Promise<void> {
const masterKey = await getEncryptionKey(logbookId)
const record = await db.voiceMemos.get(voiceId)
if (!record) throw new Error('Voice memo not found')
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (!decrypted) throw new Error('Failed to decrypt voice memo')
const manualCaption = decrypted.caption ? String(decrypted.caption).trim() : ''
const finalCaption = manualCaption
? `${manualCaption}\n(Transkript: ${transcript.trim()})`
: transcript.trim()
const updatedPayload = {
...decrypted,
caption: finalCaption,
transcribed: true
}
const encrypted = await encryptJson(updatedPayload, masterKey)
const now = new Date().toISOString()
await db.voiceMemos.put({
...record,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId: record.entryId
}),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
+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`) | — | | 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` | | 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 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) | | 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`) | — | | 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) | | 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.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' }) trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_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.OWM_WEATHER_FETCHED, { source: 'live_log' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true }) trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true }) trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
+8
View File
@@ -59,4 +59,12 @@ describe('API smoke', () => {
expect(res.status).toBe(401) expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i) expect(res.body.error).toMatch(/Unauthorized/i)
}) })
it('POST /api/ai/transcribe requires session', async () => {
const res = await request(app)
.post('/api/ai/transcribe')
.send({ audioDataUrl: 'data:audio/webm;base64,abcdef' })
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
}) })
+74 -1
View File
@@ -3,7 +3,6 @@ import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js' import { requireUser } from '../middleware/auth.js'
const router = Router() const router = Router()
const MAX_ATTEMPTS_PER_ENTRY = 3 const MAX_ATTEMPTS_PER_ENTRY = 3
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku' const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions' const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
@@ -230,4 +229,78 @@ router.post('/summary', async (req: any, res) => {
} }
}) })
router.post('/transcribe', async (req: any, res) => {
try {
const { audioDataUrl } = req.body ?? {}
if (!audioDataUrl || typeof audioDataUrl !== 'string') {
return res.status(400).json({ error: 'audioDataUrl is required' })
}
const match = audioDataUrl.match(/^data:(.+);base64,(.+)$/)
if (!match) {
return res.status(400).json({ error: 'Invalid audio data URL format' })
}
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 apiKey = resolveOpenRouterApiKey()
if (!apiKey) {
console.warn('[server] OpenRouter API key not configured, transcription unavailable')
return res.status(503).json({ error: 'Transcription service not configured' })
}
console.log(`[server] Forwarding ASR request to OpenRouter (${ext}, ${base64Data.length} chars)`)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
try {
const openRouterRes = await fetch('https://openrouter.ai/api/v1/audio/transcriptions', {
method: 'POST',
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 (!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 openRouterRes.json()
const text = (data?.text || '').trim()
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] OpenRouter ASR request timed out')
return res.status(504).json({ error: 'Transcription request timed out' })
}
throw error
} finally {
clearTimeout(timeoutId)
}
} catch (error: unknown) {
console.error('ASR transcription failed:', error)
return res.status(503).json({ error: 'Transcription service unavailable' })
}
})
export default router export default router