Add AI travel day summaries via OpenRouter for skippers.
Skipper-only proxy with per-entry rate limiting, encrypted payload storage, CSV export, and Plausible tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
OpenWeatherMapAPIKey=<owm_api_key>
|
OpenWeatherMapAPIKey=<owm_api_key>
|
||||||
|
|
||||||
|
# OpenRouter API (AI travel day summaries — server-side proxy)
|
||||||
|
OpenRouterAPIKey=
|
||||||
|
# Optional model override (default: anthropic/claude-3.5-haiku)
|
||||||
|
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
|
||||||
|
# OpenRouterModel=anthropic/claude-3.5-haiku
|
||||||
|
|
||||||
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
||||||
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
||||||
DeepLAPIKey=
|
DeepLAPIKey=
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { syncLogbook } from '../services/sync.js'
|
|||||||
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
||||||
import { getErrorMessage } from '../utils/errors.js'
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react'
|
||||||
import PhotoCapture from './PhotoCapture.tsx'
|
import PhotoCapture from './PhotoCapture.tsx'
|
||||||
import SignatureSection from './SignatureSection.tsx'
|
import SignatureSection from './SignatureSection.tsx'
|
||||||
import EntryCrewSection from './EntryCrewSection.tsx'
|
import EntryCrewSection from './EntryCrewSection.tsx'
|
||||||
@@ -37,6 +37,12 @@ import { putEntryRecord } from '../utils/entryListCache.js'
|
|||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||||
|
import {
|
||||||
|
buildTravelDayContext,
|
||||||
|
fetchTravelDaySummaryUsage,
|
||||||
|
generateTravelDaySummary,
|
||||||
|
TravelDaySummaryApiError
|
||||||
|
} from '../services/aiSummary.js'
|
||||||
import {
|
import {
|
||||||
getDecryptedTrack,
|
getDecryptedTrack,
|
||||||
saveUploadedTrack,
|
saveUploadedTrack,
|
||||||
@@ -199,6 +205,13 @@ export default function LogEntryEditor({
|
|||||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||||
const [canSignSkipper, setCanSignSkipper] = useState(false)
|
const [canSignSkipper, setCanSignSkipper] = useState(false)
|
||||||
const [canSignCrew, setCanSignCrew] = useState(false)
|
const [canSignCrew, setCanSignCrew] = useState(false)
|
||||||
|
|
||||||
|
const [aiSummary, setAiSummary] = useState('')
|
||||||
|
const [aiSummaryGeneratedAt, setAiSummaryGeneratedAt] = useState('')
|
||||||
|
const [aiSummaryLoading, setAiSummaryLoading] = useState(false)
|
||||||
|
const [aiSummaryError, setAiSummaryError] = useState<string | null>(null)
|
||||||
|
const [aiSummaryRemaining, setAiSummaryRemaining] = useState<number | null>(null)
|
||||||
|
const [aiSummaryMaxAttempts, setAiSummaryMaxAttempts] = useState(3)
|
||||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
const [entryHash, setEntryHash] = useState('')
|
const [entryHash, setEntryHash] = useState('')
|
||||||
|
|
||||||
@@ -434,6 +447,8 @@ export default function LogEntryEditor({
|
|||||||
eventsOverride?: LogEvent[]
|
eventsOverride?: LogEvent[]
|
||||||
signSkipper?: SignatureValue | ''
|
signSkipper?: SignatureValue | ''
|
||||||
signCrew?: SignatureValue | ''
|
signCrew?: SignatureValue | ''
|
||||||
|
aiSummary?: string
|
||||||
|
aiSummaryGeneratedAt?: string
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
@@ -442,15 +457,22 @@ export default function LogEntryEditor({
|
|||||||
const eventsOverride = normalized.eventsOverride
|
const eventsOverride = normalized.eventsOverride
|
||||||
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
|
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
|
||||||
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
|
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
|
||||||
|
const summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
|
||||||
|
const summaryAtToSave =
|
||||||
|
normalized.aiSummaryGeneratedAt !== undefined ? normalized.aiSummaryGeneratedAt : aiSummaryGeneratedAt
|
||||||
|
|
||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
const entryData = {
|
const entryData: Record<string, unknown> = {
|
||||||
...buildPayloadForSigning(eventsOverride),
|
...buildPayloadForSigning(eventsOverride),
|
||||||
signSkipper: normalizedSerializedSignature(skipperToSave),
|
signSkipper: normalizedSerializedSignature(skipperToSave),
|
||||||
signCrew: normalizedSerializedSignature(crewToSave)
|
signCrew: normalizedSerializedSignature(crewToSave)
|
||||||
}
|
}
|
||||||
|
if (summaryToSave.trim()) {
|
||||||
|
entryData.aiSummary = summaryToSave.trim()
|
||||||
|
entryData.aiSummaryGeneratedAt = summaryAtToSave || new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
const encrypted = await encryptJson(entryData, masterKey)
|
const encrypted = await encryptJson(entryData, masterKey)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
@@ -489,7 +511,8 @@ export default function LogEntryEditor({
|
|||||||
setEntryHash(hash)
|
setEntryHash(hash)
|
||||||
lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null
|
lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null
|
||||||
}, [
|
}, [
|
||||||
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
|
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew,
|
||||||
|
aiSummary, aiSummaryGeneratedAt
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -514,6 +537,24 @@ export default function LogEntryEditor({
|
|||||||
})
|
})
|
||||||
}, [logbookId])
|
}, [logbookId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canSignSkipper || readOnly) {
|
||||||
|
setAiSummaryRemaining(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
fetchTravelDaySummaryUsage(logbookId, entryId)
|
||||||
|
.then((usage) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setAiSummaryRemaining(usage.remainingAttempts)
|
||||||
|
setAiSummaryMaxAttempts(usage.maxAttempts)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('Failed to load AI summary usage:', err)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [canSignSkipper, readOnly, logbookId, entryId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const seq = ++entryHashSeqRef.current
|
const seq = ++entryHashSeqRef.current
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -740,6 +781,8 @@ export default function LogEntryEditor({
|
|||||||
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
||||||
loadTrackStatsFromEntry(preloadedEntry)
|
loadTrackStatsFromEntry(preloadedEntry)
|
||||||
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
||||||
|
setAiSummary(String(preloadedEntry.aiSummary || ''))
|
||||||
|
setAiSummaryGeneratedAt(String(preloadedEntry.aiSummaryGeneratedAt || ''))
|
||||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -779,6 +822,8 @@ export default function LogEntryEditor({
|
|||||||
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
||||||
loadTrackStatsFromEntry(decrypted)
|
loadTrackStatsFromEntry(decrypted)
|
||||||
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
||||||
|
setAiSummary(String(decrypted.aiSummary || ''))
|
||||||
|
setAiSummaryGeneratedAt(String(decrypted.aiSummaryGeneratedAt || ''))
|
||||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1032,6 +1077,83 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGenerateAiSummary = async () => {
|
||||||
|
if (!canSignSkipper || readOnly || aiSummaryLoading) return
|
||||||
|
if (aiSummaryRemaining === 0) {
|
||||||
|
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiSummaryLoading(true)
|
||||||
|
setAiSummaryError(null)
|
||||||
|
try {
|
||||||
|
const context = buildTravelDayContext(
|
||||||
|
{
|
||||||
|
date,
|
||||||
|
dayOfTravel,
|
||||||
|
departure,
|
||||||
|
destination,
|
||||||
|
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||||
|
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||||
|
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||||
|
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
||||||
|
freshwater: {
|
||||||
|
morning: parseFloat(fwMorning) || 0,
|
||||||
|
refilled: parseFloat(fwRefilled) || 0,
|
||||||
|
evening: parseFloat(fwEvening) || 0,
|
||||||
|
consumption: parseFloat(fwConsumption) || 0
|
||||||
|
},
|
||||||
|
fuel: {
|
||||||
|
morning: parseFloat(fuelMorning) || 0,
|
||||||
|
refilled: parseFloat(fuelRefilled) || 0,
|
||||||
|
evening: parseFloat(fuelEvening) || 0,
|
||||||
|
consumption: parseFloat(fuelConsumption) || 0
|
||||||
|
},
|
||||||
|
greywaterLevel: parseFloat(greywaterLevel) || 0,
|
||||||
|
events
|
||||||
|
},
|
||||||
|
t
|
||||||
|
)
|
||||||
|
|
||||||
|
const language = i18n.language.split('-')[0] || 'en'
|
||||||
|
const result = await generateTravelDaySummary({
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
language,
|
||||||
|
context
|
||||||
|
})
|
||||||
|
|
||||||
|
const generatedAt = new Date().toISOString()
|
||||||
|
setAiSummary(result.summary)
|
||||||
|
setAiSummaryGeneratedAt(generatedAt)
|
||||||
|
setAiSummaryRemaining(result.remainingAttempts)
|
||||||
|
setAiSummaryMaxAttempts(result.maxAttempts)
|
||||||
|
|
||||||
|
await persistEntryToDb({
|
||||||
|
aiSummary: result.summary,
|
||||||
|
aiSummaryGeneratedAt: generatedAt
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TravelDaySummaryApiError) {
|
||||||
|
if (err.code === 'NO_KEY') {
|
||||||
|
setAiSummaryError(t('logs.ai_summary_error_no_key'))
|
||||||
|
} else if (err.code === 'RATE_LIMITED') {
|
||||||
|
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
|
||||||
|
setAiSummaryRemaining(0)
|
||||||
|
} else if (err.code === 'FORBIDDEN') {
|
||||||
|
setAiSummaryError(t('logs.ai_summary_error_forbidden'))
|
||||||
|
} else {
|
||||||
|
setAiSummaryError(err.message || t('logs.ai_summary_error'))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('AI summary generation failed:', err)
|
||||||
|
setAiSummaryError(getErrorMessage(err, t('logs.ai_summary_error')))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setAiSummaryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const defaultSails = i18n.language === 'de'
|
const defaultSails = i18n.language === 'de'
|
||||||
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
||||||
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
|
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
|
||||||
@@ -1398,6 +1520,47 @@ export default function LogEntryEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(aiSummary.trim() || canSignSkipper) && (
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<Sparkles size={20} className="form-icon" />
|
||||||
|
<h3>{t('logs.ai_summary_title')}</h3>
|
||||||
|
</div>
|
||||||
|
{aiSummary.trim() ? (
|
||||||
|
<p style={{ whiteSpace: 'pre-wrap', margin: '0 0 16px', lineHeight: 1.5 }}>{aiSummary}</p>
|
||||||
|
) : (
|
||||||
|
<p style={{ margin: '0 0 16px', opacity: 0.75 }}>{t('logs.ai_summary_empty')}</p>
|
||||||
|
)}
|
||||||
|
{canSignSkipper && !readOnly && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleGenerateAiSummary()}
|
||||||
|
disabled={saving || aiSummaryLoading || aiSummaryRemaining === 0}
|
||||||
|
style={{ width: 'auto' }}
|
||||||
|
>
|
||||||
|
<Sparkles size={16} />
|
||||||
|
{aiSummaryLoading
|
||||||
|
? t('logs.ai_summary_generating')
|
||||||
|
: aiSummary.trim()
|
||||||
|
? t('logs.ai_summary_regenerate')
|
||||||
|
: t('logs.ai_summary_generate')}
|
||||||
|
</button>
|
||||||
|
{aiSummaryRemaining !== null && (
|
||||||
|
<span style={{ fontSize: '0.9rem', opacity: 0.8 }}>
|
||||||
|
{t('logs.ai_summary_attempts_remaining', {
|
||||||
|
remaining: aiSummaryRemaining,
|
||||||
|
max: aiSummaryMaxAttempts
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{aiSummaryError && <div className="auth-error" style={{ marginTop: '12px' }}>{aiSummaryError}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Section 2: Freshwater and Fuel Consumption */}
|
{/* Section 2: Freshwater and Fuel Consumption */}
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
{/* Freshwater card */}
|
{/* Freshwater card */}
|
||||||
|
|||||||
@@ -371,6 +371,16 @@
|
|||||||
"share_csv": "CSV andel",
|
"share_csv": "CSV andel",
|
||||||
"export_pdf": "Download PDF.",
|
"export_pdf": "Download PDF.",
|
||||||
"exporting_pdf": "PDF er genereret...",
|
"exporting_pdf": "PDF er genereret...",
|
||||||
|
"ai_summary_title": "AI-resumé",
|
||||||
|
"ai_summary_empty": "Intet resumé endnu.",
|
||||||
|
"ai_summary_generate": "Generér resumé",
|
||||||
|
"ai_summary_regenerate": "Generér igen",
|
||||||
|
"ai_summary_generating": "Genererer…",
|
||||||
|
"ai_summary_attempts_remaining": "{{remaining}} af {{max}} forsøg tilbage",
|
||||||
|
"ai_summary_error": "AI-resumé mislykkedes. Prøv igen senere.",
|
||||||
|
"ai_summary_error_no_key": "Ingen OpenRouter API-nøgle konfigureret på serveren.",
|
||||||
|
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
|
||||||
|
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
|
||||||
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
||||||
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
||||||
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
||||||
|
|||||||
@@ -371,6 +371,16 @@
|
|||||||
"share_csv": "CSV teilen",
|
"share_csv": "CSV teilen",
|
||||||
"export_pdf": "PDF herunterladen",
|
"export_pdf": "PDF herunterladen",
|
||||||
"exporting_pdf": "PDF wird generiert...",
|
"exporting_pdf": "PDF wird generiert...",
|
||||||
|
"ai_summary_title": "KI-Zusammenfassung",
|
||||||
|
"ai_summary_empty": "Noch keine Zusammenfassung vorhanden.",
|
||||||
|
"ai_summary_generate": "Zusammenfassung generieren",
|
||||||
|
"ai_summary_regenerate": "Neu generieren",
|
||||||
|
"ai_summary_generating": "Wird generiert…",
|
||||||
|
"ai_summary_attempts_remaining": "Noch {{remaining}} von {{max}} Versuchen",
|
||||||
|
"ai_summary_error": "KI-Zusammenfassung fehlgeschlagen. Bitte später erneut versuchen.",
|
||||||
|
"ai_summary_error_no_key": "Kein OpenRouter API-Schlüssel auf dem Server konfiguriert.",
|
||||||
|
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
||||||
|
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
||||||
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
||||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||||
|
|||||||
@@ -371,6 +371,16 @@
|
|||||||
"share_csv": "Share CSV",
|
"share_csv": "Share CSV",
|
||||||
"export_pdf": "Download PDF",
|
"export_pdf": "Download PDF",
|
||||||
"exporting_pdf": "Generating PDF...",
|
"exporting_pdf": "Generating PDF...",
|
||||||
|
"ai_summary_title": "AI Summary",
|
||||||
|
"ai_summary_empty": "No summary yet.",
|
||||||
|
"ai_summary_generate": "Generate summary",
|
||||||
|
"ai_summary_regenerate": "Regenerate",
|
||||||
|
"ai_summary_generating": "Generating…",
|
||||||
|
"ai_summary_attempts_remaining": "{{remaining}} of {{max}} attempts remaining",
|
||||||
|
"ai_summary_error": "AI summary failed. Please try again later.",
|
||||||
|
"ai_summary_error_no_key": "No OpenRouter API key configured on the server.",
|
||||||
|
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
||||||
|
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
||||||
"photos_title": "Photo Attachments (E2E Encrypted)",
|
"photos_title": "Photo Attachments (E2E Encrypted)",
|
||||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||||
|
|||||||
@@ -371,6 +371,16 @@
|
|||||||
"share_csv": "CSV andel",
|
"share_csv": "CSV andel",
|
||||||
"export_pdf": "Last ned PDF",
|
"export_pdf": "Last ned PDF",
|
||||||
"exporting_pdf": "PDF genereres...",
|
"exporting_pdf": "PDF genereres...",
|
||||||
|
"ai_summary_title": "AI-sammendrag",
|
||||||
|
"ai_summary_empty": "Ingen sammendrag ennå.",
|
||||||
|
"ai_summary_generate": "Generer sammendrag",
|
||||||
|
"ai_summary_regenerate": "Generer på nytt",
|
||||||
|
"ai_summary_generating": "Genererer…",
|
||||||
|
"ai_summary_attempts_remaining": "{{remaining}} av {{max}} forsøk igjen",
|
||||||
|
"ai_summary_error": "AI-sammendrag mislyktes. Prøv igjen senere.",
|
||||||
|
"ai_summary_error_no_key": "Ingen OpenRouter API-nøkkel konfigurert på serveren.",
|
||||||
|
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
|
||||||
|
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
|
||||||
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
||||||
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
||||||
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
||||||
|
|||||||
@@ -371,6 +371,16 @@
|
|||||||
"share_csv": "Aktie",
|
"share_csv": "Aktie",
|
||||||
"export_pdf": "Hämta PDF.",
|
"export_pdf": "Hämta PDF.",
|
||||||
"exporting_pdf": "PDF genereras...",
|
"exporting_pdf": "PDF genereras...",
|
||||||
|
"ai_summary_title": "AI-sammanfattning",
|
||||||
|
"ai_summary_empty": "Ingen sammanfattning ännu.",
|
||||||
|
"ai_summary_generate": "Generera sammanfattning",
|
||||||
|
"ai_summary_regenerate": "Generera igen",
|
||||||
|
"ai_summary_generating": "Genererar…",
|
||||||
|
"ai_summary_attempts_remaining": "{{remaining}} av {{max}} försök kvar",
|
||||||
|
"ai_summary_error": "AI-sammanfattning misslyckades. Försök igen senare.",
|
||||||
|
"ai_summary_error_no_key": "Ingen OpenRouter API-nyckel konfigurerad på servern.",
|
||||||
|
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
|
||||||
|
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
|
||||||
"photos_title": "Fotobilagor (E2E-krypterade)",
|
"photos_title": "Fotobilagor (E2E-krypterade)",
|
||||||
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
||||||
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { buildTravelDayContext } from './aiSummary.js'
|
||||||
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
|
||||||
|
const t = ((key: string, opts?: Record<string, unknown>) => {
|
||||||
|
if (key === 'logs.live_motor_start') return 'Motor started'
|
||||||
|
if (key === 'logs.live_event_generic') return 'Event'
|
||||||
|
if (opts && 'course' in opts) return `Course ${opts.course}`
|
||||||
|
return key
|
||||||
|
}) as any
|
||||||
|
|
||||||
|
describe('buildTravelDayContext', () => {
|
||||||
|
it('includes route metadata and formatted events', () => {
|
||||||
|
const events: LogEventPayload[] = [
|
||||||
|
{
|
||||||
|
time: '09:00',
|
||||||
|
mgk: '180',
|
||||||
|
rwk: '',
|
||||||
|
windPressure: '',
|
||||||
|
windDirection: '',
|
||||||
|
windStrength: '',
|
||||||
|
seaState: '',
|
||||||
|
visibility: '',
|
||||||
|
weatherIcon: '',
|
||||||
|
current: '',
|
||||||
|
heel: '',
|
||||||
|
sailsOrMotor: 'Genua',
|
||||||
|
logReading: '',
|
||||||
|
distance: '',
|
||||||
|
gpsLat: '',
|
||||||
|
gpsLng: '',
|
||||||
|
remarks: '__live:motor_start'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const context = buildTravelDayContext(
|
||||||
|
{
|
||||||
|
date: '2026-06-03',
|
||||||
|
dayOfTravel: '5',
|
||||||
|
departure: 'Kiel',
|
||||||
|
destination: 'Copenhagen',
|
||||||
|
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
|
||||||
|
fuel: { morning: 50, refilled: 10, evening: 40, consumption: 20 },
|
||||||
|
greywaterLevel: 0,
|
||||||
|
trackDistanceNm: 42.5,
|
||||||
|
motorHours: 3.5,
|
||||||
|
events
|
||||||
|
},
|
||||||
|
t
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(context.departure).toBe('Kiel')
|
||||||
|
expect(context.destination).toBe('Copenhagen')
|
||||||
|
expect(context.trackDistanceNm).toBe(42.5)
|
||||||
|
expect(context.motorHours).toBe(3.5)
|
||||||
|
expect(context.events).toHaveLength(1)
|
||||||
|
expect(context.events[0].summary).toBe('Motor started')
|
||||||
|
expect(context.events[0].sailsOrMotor).toBe('Genua')
|
||||||
|
expect(context.greywater).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { TFunction } from 'i18next'
|
||||||
|
import { apiFetch } from './api.js'
|
||||||
|
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||||
|
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
|
|
||||||
|
export class TravelDaySummaryApiError extends Error {
|
||||||
|
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'TravelDaySummaryApiError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TravelDaySummaryContext {
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
trackDistanceNm?: number
|
||||||
|
trackSpeedMaxKn?: number
|
||||||
|
trackSpeedAvgKn?: number
|
||||||
|
motorHours?: number
|
||||||
|
freshwater?: {
|
||||||
|
morning: number
|
||||||
|
refilled: number
|
||||||
|
evening: number
|
||||||
|
consumption: number
|
||||||
|
}
|
||||||
|
fuel?: {
|
||||||
|
morning: number
|
||||||
|
refilled: number
|
||||||
|
evening: number
|
||||||
|
consumption: number
|
||||||
|
}
|
||||||
|
greywater?: { level: number }
|
||||||
|
events: Array<{
|
||||||
|
time: string
|
||||||
|
summary: string
|
||||||
|
sailsOrMotor?: string
|
||||||
|
mgk?: string
|
||||||
|
windDirection?: string
|
||||||
|
windStrength?: string
|
||||||
|
windPressure?: string
|
||||||
|
seaState?: string
|
||||||
|
visibility?: string
|
||||||
|
distance?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TravelDaySummaryInput {
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
trackDistanceNm?: number
|
||||||
|
trackSpeedMaxKn?: number
|
||||||
|
trackSpeedAvgKn?: number
|
||||||
|
motorHours?: number
|
||||||
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
greywaterLevel?: number
|
||||||
|
events: LogEventPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMARY_FETCH_TIMEOUT_MS = 90_000
|
||||||
|
|
||||||
|
export function buildTravelDayContext(
|
||||||
|
input: TravelDaySummaryInput,
|
||||||
|
t: TFunction
|
||||||
|
): TravelDaySummaryContext {
|
||||||
|
const context: TravelDaySummaryContext = {
|
||||||
|
date: input.date,
|
||||||
|
dayOfTravel: input.dayOfTravel,
|
||||||
|
departure: input.departure,
|
||||||
|
destination: input.destination,
|
||||||
|
freshwater: input.freshwater,
|
||||||
|
fuel: input.fuel,
|
||||||
|
events: sortLogEventsByTime(input.events).map((event) => ({
|
||||||
|
time: event.time,
|
||||||
|
summary: formatEventSummary(event, t),
|
||||||
|
...(event.sailsOrMotor ? { sailsOrMotor: event.sailsOrMotor } : {}),
|
||||||
|
...(event.mgk ? { mgk: event.mgk } : {}),
|
||||||
|
...(event.windDirection ? { windDirection: event.windDirection } : {}),
|
||||||
|
...(event.windStrength ? { windStrength: event.windStrength } : {}),
|
||||||
|
...(event.windPressure ? { windPressure: event.windPressure } : {}),
|
||||||
|
...(event.seaState ? { seaState: event.seaState } : {}),
|
||||||
|
...(event.visibility ? { visibility: event.visibility } : {}),
|
||||||
|
...(event.distance ? { distance: event.distance } : {})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.trackDistanceNm !== undefined) context.trackDistanceNm = input.trackDistanceNm
|
||||||
|
if (input.trackSpeedMaxKn !== undefined) context.trackSpeedMaxKn = input.trackSpeedMaxKn
|
||||||
|
if (input.trackSpeedAvgKn !== undefined) context.trackSpeedAvgKn = input.trackSpeedAvgKn
|
||||||
|
if (input.motorHours !== undefined && input.motorHours > 0) context.motorHours = input.motorHours
|
||||||
|
if (input.greywaterLevel !== undefined && input.greywaterLevel > 0) {
|
||||||
|
context.greywater = { level: input.greywaterLevel }
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApiError(status: number, data: unknown): TravelDaySummaryApiError {
|
||||||
|
const code =
|
||||||
|
typeof data === 'object' && data !== null && 'code' in data
|
||||||
|
? String((data as { code?: string }).code)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (status === 503 || code === 'NO_KEY') {
|
||||||
|
return new TravelDaySummaryApiError('No OpenRouter API key configured', 'NO_KEY')
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
|
return new TravelDaySummaryApiError('Forbidden', 'FORBIDDEN')
|
||||||
|
}
|
||||||
|
if (status === 429 || code === 'RATE_LIMITED') {
|
||||||
|
return new TravelDaySummaryApiError('Rate limit exceeded', 'RATE_LIMITED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
typeof data === 'object' && data !== null && 'error' in data && typeof (data as { error: unknown }).error === 'string'
|
||||||
|
? (data as { error: string }).error
|
||||||
|
: 'Request failed'
|
||||||
|
return new TravelDaySummaryApiError(message, 'REQUEST_FAILED')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTravelDaySummaryUsage(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string
|
||||||
|
): Promise<{ remainingAttempts: number; maxAttempts: number }> {
|
||||||
|
const params = new URLSearchParams({ logbookId, entryId })
|
||||||
|
const res = await apiFetch(`/api/ai/usage?${params.toString()}`)
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw mapApiError(res.status, data)
|
||||||
|
return data as { remainingAttempts: number; maxAttempts: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateTravelDaySummary(params: {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
language: string
|
||||||
|
context: TravelDaySummaryContext
|
||||||
|
}): Promise<{ summary: string; remainingAttempts: number; maxAttempts: number }> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), SUMMARY_FETCH_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await apiFetch('/api/ai/summary', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new TravelDaySummaryApiError('AI summary request timed out')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw mapApiError(res.status, data)
|
||||||
|
|
||||||
|
trackPlausibleEvent(PlausibleEvents.AI_SUMMARY_GENERATED)
|
||||||
|
|
||||||
|
return data as { summary: string; remainingAttempts: number; maxAttempts: number }
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export const PlausibleEvents = {
|
|||||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||||
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
|
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
|
||||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||||
|
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||||
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
|
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
|
||||||
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
|
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
|
|
||||||
// Headers matching the requested event fields & metadata
|
// Headers matching the requested event fields & metadata
|
||||||
const headers = [
|
const headers = [
|
||||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
||||||
'Skipper Signature', 'Crew Signature',
|
'Skipper Signature', 'Crew Signature',
|
||||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||||
'Event Time', 'MgK Course', 'RwK Course',
|
'Event Time', 'MgK Course', 'RwK Course',
|
||||||
@@ -120,12 +120,13 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const fuelE = entry.fuel?.evening ?? '';
|
const fuelE = entry.fuel?.evening ?? '';
|
||||||
const fuelCons = entry.fuel?.consumption ?? '';
|
const fuelCons = entry.fuel?.consumption ?? '';
|
||||||
const greywaterLevel = entry.greywater?.level ?? '';
|
const greywaterLevel = entry.greywater?.level ?? '';
|
||||||
|
const aiSummary = entry.aiSummary ?? '';
|
||||||
|
|
||||||
const eventsList = entry.events || [];
|
const eventsList = entry.events || [];
|
||||||
if (eventsList.length === 0) {
|
if (eventsList.length === 0) {
|
||||||
// Create one row even if there are no events for the day
|
// Create one row even if there are no events for the day
|
||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
'', '', '',
|
'', '', '',
|
||||||
@@ -142,7 +143,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const sortedEvents = sortLogEventsByTime(eventsList);
|
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||||
for (const ev of sortedEvents) {
|
for (const ev of sortedEvents) {
|
||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { hashEntryForSigning } from './entryCanonicalHash.js'
|
||||||
|
|
||||||
|
describe('hashEntryForSigning', () => {
|
||||||
|
it('excludes aiSummary fields from the signing hash', async () => {
|
||||||
|
const base = {
|
||||||
|
date: '2026-06-03',
|
||||||
|
dayOfTravel: '1',
|
||||||
|
departure: 'A',
|
||||||
|
destination: 'B',
|
||||||
|
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
events: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutSummary = await hashEntryForSigning(base)
|
||||||
|
const withSummary = await hashEntryForSigning({
|
||||||
|
...base,
|
||||||
|
aiSummary: 'A calm day at sea.',
|
||||||
|
aiSummaryGeneratedAt: '2026-06-03T12:00:00.000Z'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(withSummary).toBe(withoutSummary)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
|
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
|
||||||
|
const AI_SUMMARY_KEYS = new Set(['aiSummary', 'aiSummaryGeneratedAt'])
|
||||||
|
|
||||||
function sortEventsByTime(items: unknown[]): unknown[] {
|
function sortEventsByTime(items: unknown[]): unknown[] {
|
||||||
return [...items]
|
return [...items]
|
||||||
@@ -25,7 +26,7 @@ function sortValue(value: unknown, parentKey?: string): unknown {
|
|||||||
const obj = value as Record<string, unknown>
|
const obj = value as Record<string, unknown>
|
||||||
const sorted: Record<string, unknown> = {}
|
const sorted: Record<string, unknown> = {}
|
||||||
for (const key of Object.keys(obj).sort()) {
|
for (const key of Object.keys(obj).sort()) {
|
||||||
if (SIGNATURE_KEYS.has(key)) continue
|
if (SIGNATURE_KEYS.has(key) || AI_SUMMARY_KEYS.has(key)) continue
|
||||||
sorted[key] = sortValue(obj[key], key)
|
sorted[key] = sortValue(obj[key], key)
|
||||||
}
|
}
|
||||||
return sorted
|
return sorted
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ services:
|
|||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
||||||
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
||||||
|
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||||
|
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
|||||||
| 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` |
|
||||||
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
|
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
|
||||||
| 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`) | — |
|
||||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
|
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
|
||||||
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
||||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||||
|
|||||||
@@ -252,3 +252,14 @@ model GpsTrackPayload {
|
|||||||
|
|
||||||
@@index([logbookId])
|
@@index([logbookId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AiSummaryUsage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
logbookId String
|
||||||
|
entryId String
|
||||||
|
count Int @default(0)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([logbookId, entryId])
|
||||||
|
@@index([logbookId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,4 +45,18 @@ describe('API smoke', () => {
|
|||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
expect(res.body.error).toMatch(/Token/i)
|
expect(res.body.error).toMatch(/Token/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST /api/ai/summary requires session', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/ai/summary')
|
||||||
|
.send({ logbookId: 'x', entryId: 'y', context: {} })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/ai/usage requires session', async () => {
|
||||||
|
const res = await request(app).get('/api/ai/usage')
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js'
|
|||||||
import signRouter from './routes/sign.js'
|
import signRouter from './routes/sign.js'
|
||||||
import pushRouter from './routes/push.js'
|
import pushRouter from './routes/push.js'
|
||||||
import weatherRouter from './routes/weather.js'
|
import weatherRouter from './routes/weather.js'
|
||||||
|
import aiRouter from './routes/ai.js'
|
||||||
import feedbackRouter from './routes/feedback.js'
|
import feedbackRouter from './routes/feedback.js'
|
||||||
import { prisma } from './db.js'
|
import { prisma } from './db.js'
|
||||||
import { buildCorsOptions } from './cors.js'
|
import { buildCorsOptions } from './cors.js'
|
||||||
@@ -118,6 +119,7 @@ export function createApp(): express.Express {
|
|||||||
app.use('/api/sign', signRouter)
|
app.use('/api/sign', signRouter)
|
||||||
app.use('/api/push', pushRouter)
|
app.use('/api/push', pushRouter)
|
||||||
app.use('/api/weather', weatherRouter)
|
app.use('/api/weather', weatherRouter)
|
||||||
|
app.use('/api/ai', aiRouter)
|
||||||
app.use('/api/feedback', feedbackRouter)
|
app.use('/api/feedback', feedbackRouter)
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
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'
|
||||||
|
const FETCH_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
/** Common misconfiguration aliases → valid OpenRouter model IDs */
|
||||||
|
const MODEL_ALIASES: Record<string, string> = {
|
||||||
|
'anthropic/claude-haiku-latest': 'anthropic/claude-3.5-haiku',
|
||||||
|
'claude-haiku-latest': 'anthropic/claude-3.5-haiku'
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANGUAGE_LABELS: Record<string, string> = {
|
||||||
|
de: 'German',
|
||||||
|
en: 'English',
|
||||||
|
da: 'Danish',
|
||||||
|
nb: 'Norwegian Bokmål',
|
||||||
|
sv: 'Swedish'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpenRouterApiKey(): string | null {
|
||||||
|
const fromEnv =
|
||||||
|
process.env.OpenRouterAPIKey?.trim() ||
|
||||||
|
process.env.OPENROUTER_API_KEY?.trim()
|
||||||
|
return fromEnv || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpenRouterModel(): string {
|
||||||
|
const configured =
|
||||||
|
process.env.OpenRouterModel?.trim() ||
|
||||||
|
process.env.OPENROUTER_MODEL?.trim() ||
|
||||||
|
DEFAULT_MODEL
|
||||||
|
return MODEL_ALIASES[configured] ?? configured
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOpenRouterError(data: unknown): string | null {
|
||||||
|
if (typeof data !== 'object' || data === null) return null
|
||||||
|
const nested = (data as { error?: { message?: string } }).error
|
||||||
|
if (nested && typeof nested.message === 'string' && nested.message.trim()) {
|
||||||
|
return nested.message.trim()
|
||||||
|
}
|
||||||
|
const topLevel = (data as { error?: string }).error
|
||||||
|
if (typeof topLevel === 'string' && topLevel.trim()) return topLevel.trim()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLogbookOwner(logbookId: string) {
|
||||||
|
return prisma.logbook.findUnique({
|
||||||
|
where: { id: logbookId },
|
||||||
|
select: { userId: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUsageCount(logbookId: string, entryId: string): Promise<number> {
|
||||||
|
const row = await prisma.aiSummaryUsage.findUnique({
|
||||||
|
where: { logbookId_entryId: { logbookId, entryId } },
|
||||||
|
select: { count: true }
|
||||||
|
})
|
||||||
|
return row?.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingAttempts(used: number): number {
|
||||||
|
return Math.max(0, MAX_ATTEMPTS_PER_ENTRY - used)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLanguageLabel(language: unknown): string {
|
||||||
|
if (typeof language === 'string' && LANGUAGE_LABELS[language]) {
|
||||||
|
return LANGUAGE_LABELS[language]
|
||||||
|
}
|
||||||
|
return LANGUAGE_LABELS.en
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemPrompt(languageLabel: string): string {
|
||||||
|
return [
|
||||||
|
'You are a maritime logbook assistant for sailing yachts.',
|
||||||
|
`Write a concise narrative summary of one travel day in ${languageLabel}.`,
|
||||||
|
'Use 2–4 short paragraphs in plain prose.',
|
||||||
|
'Cover route, sailing conditions, notable events, and tank/fuel highlights when data is present.',
|
||||||
|
'Do not invent facts not supported by the input.',
|
||||||
|
'Do not include coordinates, personal names, or signature metadata.',
|
||||||
|
'Respond with the summary text only — no title, markdown, or JSON.'
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(requireUser)
|
||||||
|
|
||||||
|
router.get('/usage', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const logbookId = String(req.query.logbookId || '')
|
||||||
|
const entryId = String(req.query.entryId || '')
|
||||||
|
if (!logbookId || !entryId) {
|
||||||
|
return res.status(400).json({ error: 'logbookId and entryId are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logbook = await getLogbookOwner(logbookId)
|
||||||
|
if (!logbook) return res.status(404).json({ error: 'Logbook not found' })
|
||||||
|
if (logbook.userId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Skipper only' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = await getUsageCount(logbookId, entryId)
|
||||||
|
return res.json({ remainingAttempts: remainingAttempts(used), maxAttempts: MAX_ATTEMPTS_PER_ENTRY })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('AI summary usage lookup failed:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to load AI summary usage' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/summary', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { logbookId, entryId, language, context } = req.body ?? {}
|
||||||
|
if (!logbookId || !entryId || !context || typeof context !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'logbookId, entryId, and context are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logbook = await getLogbookOwner(String(logbookId))
|
||||||
|
if (!logbook) return res.status(404).json({ error: 'Logbook not found' })
|
||||||
|
if (logbook.userId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Skipper only' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = await getUsageCount(String(logbookId), String(entryId))
|
||||||
|
if (used >= MAX_ATTEMPTS_PER_ENTRY) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Rate limit exceeded for this travel day',
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
remainingAttempts: 0,
|
||||||
|
maxAttempts: MAX_ATTEMPTS_PER_ENTRY
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = resolveOpenRouterApiKey()
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'No OpenRouter API key configured',
|
||||||
|
code: 'NO_KEY'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageLabel = resolveLanguageLabel(language)
|
||||||
|
const model = resolveOpenRouterModel()
|
||||||
|
const contextJson = JSON.stringify(context)
|
||||||
|
if (contextJson.length > 100_000) {
|
||||||
|
return res.status(400).json({ error: 'Travel day context is too large' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let openRouterRes: Response
|
||||||
|
try {
|
||||||
|
openRouterRes = await fetch(OPENROUTER_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': process.env.ORIGIN || 'https://kapteins-daagbok.eu',
|
||||||
|
'X-Title': 'Kapteins Daagbok'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: buildSystemPrompt(languageLabel) },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Summarize this travel day from the structured log data:\n\n${contextJson}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens: 800,
|
||||||
|
temperature: 0.4
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return res.status(504).json({ error: 'OpenRouter request timed out' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await openRouterRes.json().catch(() => ({}))
|
||||||
|
if (!openRouterRes.ok) {
|
||||||
|
const detail = extractOpenRouterError(data)
|
||||||
|
console.error('OpenRouter error:', openRouterRes.status, data)
|
||||||
|
return res.status(502).json({
|
||||||
|
error: detail || 'OpenRouter request failed',
|
||||||
|
code: 'OPENROUTER_ERROR'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
typeof data === 'object' &&
|
||||||
|
data !== null &&
|
||||||
|
Array.isArray((data as { choices?: unknown[] }).choices) &&
|
||||||
|
(data as { choices: Array<{ message?: { content?: string } }> }).choices[0]?.message?.content
|
||||||
|
? String((data as { choices: Array<{ message?: { content?: string } }> }).choices[0].message?.content).trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return res.status(502).json({ error: 'OpenRouter returned an empty summary' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.aiSummaryUsage.upsert({
|
||||||
|
where: {
|
||||||
|
logbookId_entryId: { logbookId: String(logbookId), entryId: String(entryId) }
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
logbookId: String(logbookId),
|
||||||
|
entryId: String(entryId),
|
||||||
|
count: 1
|
||||||
|
},
|
||||||
|
update: { count: { increment: 1 } }
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
summary,
|
||||||
|
remainingAttempts: remainingAttempts(updated.count),
|
||||||
|
maxAttempts: MAX_ATTEMPTS_PER_ENTRY
|
||||||
|
})
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('AI summary generation failed:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to generate AI summary' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
Reference in New Issue
Block a user