Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c3e9ce3e | |||
| 3eaf59e2b3 | |||
| b1e17be7fd | |||
| ac7e7c92d1 | |||
| e10cef4b05 | |||
| 0ec5c51102 | |||
| 57b93b7ce7 | |||
| a4b3515711 |
+11
-9
@@ -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;
|
||||
|
||||
@@ -1,24 +1,97 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Mic, Loader2 } from 'lucide-react'
|
||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||
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
|
||||
logbookId: string
|
||||
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function EventRemarksCell({
|
||||
event,
|
||||
logbookId,
|
||||
voiceMemoLookup
|
||||
voiceMemoLookup,
|
||||
readOnly = false
|
||||
}: EventRemarksCellProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
||||
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)
|
||||
if (voiceId && 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' : ''}`}>
|
||||
<span>{summary}</span>
|
||||
{voiceId && (
|
||||
<VoiceMemoPlayer
|
||||
audioId={voiceId}
|
||||
logbookId={logbookId}
|
||||
preloaded={preloaded}
|
||||
compact
|
||||
/>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
|
||||
<VoiceMemoPlayer
|
||||
audioId={voiceId}
|
||||
logbookId={logbookId}
|
||||
preloaded={preloaded}
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
removeLastEvent
|
||||
} from '../services/quickEventLog.js'
|
||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||
import {
|
||||
getLastAutoPositionMs,
|
||||
getLastLoggedPositionWithin,
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
liveFuelRemark,
|
||||
livePhotoRemark,
|
||||
liveVoiceRemark,
|
||||
parseLiveVoiceRemark,
|
||||
livePrecipRemark,
|
||||
liveSailsRemark,
|
||||
liveSogRemark,
|
||||
@@ -80,7 +79,7 @@ import CourseDialInput from './CourseDialInput.tsx'
|
||||
import GpsSignalHint from './GpsSignalHint.tsx'
|
||||
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
||||
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
||||
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
|
||||
import EventRemarksCell from './EventRemarksCell.tsx'
|
||||
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||
@@ -836,13 +835,50 @@ export default function LiveLogView({
|
||||
void (async () => {
|
||||
try {
|
||||
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({
|
||||
logbookId,
|
||||
entryId,
|
||||
audioDataUrl,
|
||||
mimeType,
|
||||
durationSec,
|
||||
caption,
|
||||
caption: finalCaption,
|
||||
transcribed,
|
||||
analyticsContext: 'live_log'
|
||||
})
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
@@ -854,6 +890,23 @@ 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)
|
||||
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
|
||||
@@ -1225,12 +1278,6 @@ export default function LiveLogView({
|
||||
) : (
|
||||
<ol className="live-log-stream">
|
||||
{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 (
|
||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||
<time className="live-log-time">{event.time}</time>
|
||||
@@ -1240,15 +1287,12 @@ export default function LiveLogView({
|
||||
size={24}
|
||||
/>
|
||||
<div className="live-log-summary-block">
|
||||
<span className="live-log-summary">{summary}</span>
|
||||
{voiceId && (
|
||||
<VoiceMemoPlayer
|
||||
audioId={voiceId}
|
||||
logbookId={logbookId}
|
||||
preloaded={voicePreloaded}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
<EventRemarksCell
|
||||
event={event}
|
||||
logbookId={logbookId}
|
||||
voiceMemoLookup={voiceMemoLookup}
|
||||
readOnly={false}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -1909,6 +1917,7 @@ export default function LogEntryEditor({
|
||||
event={ev}
|
||||
logbookId={logbookId}
|
||||
voiceMemoLookup={voiceMemoLookup}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</td>
|
||||
{!readOnly && (
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PreloadedVoiceMemo {
|
||||
mimeType?: string
|
||||
durationSec?: number
|
||||
caption?: string
|
||||
transcribed?: boolean
|
||||
}
|
||||
|
||||
interface VoiceMemoPlayerProps {
|
||||
|
||||
@@ -48,7 +48,8 @@ export function useEntryVoiceMemos(
|
||||
audio: String(decrypted.audio),
|
||||
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : 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 {
|
||||
// skip corrupt memo
|
||||
|
||||
@@ -297,6 +297,9 @@
|
||||
"live_voice_entry_plain": "Stemmenotat",
|
||||
"live_voice_caption_label": "Billedtekst (valgfrit)",
|
||||
"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_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Indtast tekst…",
|
||||
@@ -669,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",
|
||||
|
||||
@@ -297,6 +297,9 @@
|
||||
"live_voice_entry_plain": "Sprachnotiz",
|
||||
"live_voice_caption_label": "Beschriftung (optional)",
|
||||
"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_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Freitext eingeben…",
|
||||
@@ -669,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",
|
||||
|
||||
@@ -297,6 +297,9 @@
|
||||
"live_voice_entry_plain": "Voice memo",
|
||||
"live_voice_caption_label": "Caption (optional)",
|
||||
"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_comment_btn": "Comment",
|
||||
"live_comment_placeholder": "Enter text…",
|
||||
@@ -669,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",
|
||||
|
||||
@@ -297,6 +297,9 @@
|
||||
"live_voice_entry_plain": "Talemelding",
|
||||
"live_voice_caption_label": "Bildetekst (valgfritt)",
|
||||
"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_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Skriv inn tekst…",
|
||||
@@ -669,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",
|
||||
|
||||
@@ -297,6 +297,9 @@
|
||||
"live_voice_entry_plain": "Röstanteckning",
|
||||
"live_voice_caption_label": "Bildtext (valfritt)",
|
||||
"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_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Ange text…",
|
||||
@@ -669,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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { encryptJson, decryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
|
||||
@@ -18,6 +18,7 @@ export async function saveEntryVoiceMemo(options: {
|
||||
mimeType: string
|
||||
durationSec: number
|
||||
caption?: string
|
||||
transcribed?: boolean
|
||||
analyticsContext?: string
|
||||
}): Promise<string> {
|
||||
const {
|
||||
@@ -27,6 +28,7 @@ export async function saveEntryVoiceMemo(options: {
|
||||
mimeType,
|
||||
durationSec,
|
||||
caption = '',
|
||||
transcribed = true,
|
||||
analyticsContext = 'logbook'
|
||||
} = options
|
||||
const masterKey = await getEncryptionKey(logbookId)
|
||||
@@ -35,7 +37,8 @@ export async function saveEntryVoiceMemo(options: {
|
||||
audio: audioDataUrl,
|
||||
mimeType,
|
||||
durationSec,
|
||||
caption: caption.trim()
|
||||
caption: caption.trim(),
|
||||
transcribed: !!transcribed
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(voicePayload, masterKey)
|
||||
@@ -98,3 +101,55 @@ export async function removeLastVoiceMemoForEntry(
|
||||
await deleteEntryVoiceMemo(logbookId, 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))
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -59,4 +59,12 @@ describe('API smoke', () => {
|
||||
expect(res.status).toBe(401)
|
||||
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
@@ -3,7 +3,6 @@ import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const MAX_ATTEMPTS_PER_ENTRY = 3
|
||||
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user