diff --git a/.env.example b/.env.example index 281d216..0326cbe 100755 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ OpenWeatherMapAPIKey= +# 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) # Free plan keys use api-free.deepl.com automatically (suffix :fx) DeepLAPIKey= diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 5120561..d3600f0 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -8,7 +8,7 @@ import { syncLogbook } from '../services/sync.js' import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js' import { getErrorMessage } from '../utils/errors.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 SignatureSection from './SignatureSection.tsx' import EntryCrewSection from './EntryCrewSection.tsx' @@ -37,6 +37,12 @@ import { putEntryRecord } from '../utils/entryListCache.js' import { getLogbookAccess } from '../services/logbookAccess.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' +import { + buildTravelDayContext, + fetchTravelDaySummaryUsage, + generateTravelDaySummary, + TravelDaySummaryApiError +} from '../services/aiSummary.js' import { getDecryptedTrack, saveUploadedTrack, @@ -199,6 +205,13 @@ export default function LogEntryEditor({ const [signCrew, setSignCrew] = useState('') const [canSignSkipper, setCanSignSkipper] = useState(false) const [canSignCrew, setCanSignCrew] = useState(false) + + const [aiSummary, setAiSummary] = useState('') + const [aiSummaryGeneratedAt, setAiSummaryGeneratedAt] = useState('') + const [aiSummaryLoading, setAiSummaryLoading] = useState(false) + const [aiSummaryError, setAiSummaryError] = useState(null) + const [aiSummaryRemaining, setAiSummaryRemaining] = useState(null) + const [aiSummaryMaxAttempts, setAiSummaryMaxAttempts] = useState(3) const [isOnline, setIsOnline] = useState(navigator.onLine) const [entryHash, setEntryHash] = useState('') @@ -434,6 +447,8 @@ export default function LogEntryEditor({ eventsOverride?: LogEvent[] signSkipper?: SignatureValue | '' signCrew?: SignatureValue | '' + aiSummary?: string + aiSummaryGeneratedAt?: string } ) => { if (readOnly) return @@ -442,15 +457,22 @@ export default function LogEntryEditor({ const eventsOverride = normalized.eventsOverride const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper 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() if (!masterKey) throw new Error('Encryption key not found. Please log in.') - const entryData = { + const entryData: Record = { ...buildPayloadForSigning(eventsOverride), signSkipper: normalizedSerializedSignature(skipperToSave), signCrew: normalizedSerializedSignature(crewToSave) } + if (summaryToSave.trim()) { + entryData.aiSummary = summaryToSave.trim() + entryData.aiSummaryGeneratedAt = summaryAtToSave || new Date().toISOString() + } const encrypted = await encryptJson(entryData, masterKey) const now = new Date().toISOString() @@ -489,7 +511,8 @@ export default function LogEntryEditor({ setEntryHash(hash) lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null }, [ - readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew + readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew, + aiSummary, aiSummaryGeneratedAt ]) useEffect(() => { @@ -514,6 +537,24 @@ export default function LogEntryEditor({ }) }, [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(() => { const seq = ++entryHashSeqRef.current let cancelled = false @@ -740,6 +781,8 @@ export default function LogEntryEditor({ setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record)) loadTrackStatsFromEntry(preloadedEntry) setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent))) + setAiSummary(String(preloadedEntry.aiSummary || '')) + setAiSummaryGeneratedAt(String(preloadedEntry.aiSummaryGeneratedAt || '')) setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry)) return } @@ -779,6 +822,8 @@ export default function LogEntryEditor({ setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record)) loadTrackStatsFromEntry(decrypted) setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent))) + setAiSummary(String(decrypted.aiSummary || '')) + setAiSummaryGeneratedAt(String(decrypted.aiSummaryGeneratedAt || '')) 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' ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] : ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker'] @@ -1398,6 +1520,47 @@ export default function LogEntryEditor({ + {(aiSummary.trim() || canSignSkipper) && ( +
+
+ +

{t('logs.ai_summary_title')}

+
+ {aiSummary.trim() ? ( +

{aiSummary}

+ ) : ( +

{t('logs.ai_summary_empty')}

+ )} + {canSignSkipper && !readOnly && ( +
+ + {aiSummaryRemaining !== null && ( + + {t('logs.ai_summary_attempts_remaining', { + remaining: aiSummaryRemaining, + max: aiSummaryMaxAttempts + })} + + )} +
+ )} + {aiSummaryError &&
{aiSummaryError}
} +
+ )} + {/* Section 2: Freshwater and Fuel Consumption */}
{/* Freshwater card */} diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index b048f61..283a2bc 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -371,6 +371,16 @@ "share_csv": "CSV andel", "export_pdf": "Download PDF.", "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)", "photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)", "photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index b2a49f3..ecb2346 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -371,6 +371,16 @@ "share_csv": "CSV teilen", "export_pdf": "PDF herunterladen", "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)", "photo_caption_label": "Foto-Beschreibung / Label (Optional)", "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 697264a..cdf8659 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -371,6 +371,16 @@ "share_csv": "Share CSV", "export_pdf": "Download 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)", "photo_caption_label": "Photo Caption / Label (Optional)", "photo_caption_placeholder": "e.g. Setting sails near harbor entrance", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 4d4a448..e02244c 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -371,6 +371,16 @@ "share_csv": "CSV andel", "export_pdf": "Last ned PDF", "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)", "photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)", "photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 91e5029..03004bf 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -371,6 +371,16 @@ "share_csv": "Aktie", "export_pdf": "Hämta PDF.", "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)", "photo_caption_label": "Fotobeskrivning/etikett (valfritt)", "photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet", diff --git a/client/src/services/aiSummary.test.ts b/client/src/services/aiSummary.test.ts new file mode 100644 index 0000000..2374f2f --- /dev/null +++ b/client/src/services/aiSummary.test.ts @@ -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) => { + 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() + }) +}) diff --git a/client/src/services/aiSummary.ts b/client/src/services/aiSummary.ts new file mode 100644 index 0000000..7651344 --- /dev/null +++ b/client/src/services/aiSummary.ts @@ -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 } +} diff --git a/client/src/services/analytics.ts b/client/src/services/analytics.ts index a59f6fb..d81b085 100644 --- a/client/src/services/analytics.ts +++ b/client/src/services/analytics.ts @@ -42,6 +42,7 @@ export const PlausibleEvents = { LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged', LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded', OWM_WEATHER_FETCHED: 'OWM Weather Fetched', + AI_SUMMARY_GENERATED: 'AI Summary Generated', PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft', PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard', PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback', diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts index f79168e..40fa083 100644 --- a/client/src/services/csvExport.ts +++ b/client/src/services/csvExport.ts @@ -74,7 +74,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya // Headers matching the requested event fields & metadata const headers = [ - 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', + 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary', 'Skipper Signature', 'Crew Signature', 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)', 'Event Time', 'MgK Course', 'RwK Course', @@ -120,12 +120,13 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya const fuelE = entry.fuel?.evening ?? ''; const fuelCons = entry.fuel?.consumption ?? ''; const greywaterLevel = entry.greywater?.level ?? ''; + const aiSummary = entry.aiSummary ?? ''; const eventsList = entry.events || []; if (eventsList.length === 0) { // Create one row even if there are no events for the day rows.push([ - dateVal, travelDay, dep, dest, + dateVal, travelDay, dep, dest, aiSummary, signS, signC, trackDist, trackMax, trackAvg, motorH, '', '', '', @@ -142,7 +143,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya const sortedEvents = sortLogEventsByTime(eventsList); for (const ev of sortedEvents) { rows.push([ - dateVal, travelDay, dep, dest, + dateVal, travelDay, dep, dest, aiSummary, signS, signC, trackDist, trackMax, trackAvg, motorH, ev.time || '', ev.mgk || '', ev.rwk || '', diff --git a/client/src/utils/entryCanonicalHash.test.ts b/client/src/utils/entryCanonicalHash.test.ts new file mode 100644 index 0000000..65bc85b --- /dev/null +++ b/client/src/utils/entryCanonicalHash.test.ts @@ -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) + }) +}) diff --git a/client/src/utils/entryCanonicalHash.ts b/client/src/utils/entryCanonicalHash.ts index 251b17c..5ffb6df 100644 --- a/client/src/utils/entryCanonicalHash.ts +++ b/client/src/utils/entryCanonicalHash.ts @@ -1,4 +1,5 @@ const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew']) +const AI_SUMMARY_KEYS = new Set(['aiSummary', 'aiSummaryGeneratedAt']) function sortEventsByTime(items: unknown[]): unknown[] { return [...items] @@ -25,7 +26,7 @@ function sortValue(value: unknown, parentKey?: string): unknown { const obj = value as Record const sorted: Record = {} 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) } return sorted diff --git a/docker-compose.yml b/docker-compose.yml index 3baef24..19c96f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,8 @@ services: VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu} OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-} + OpenRouterAPIKey: ${OpenRouterAPIKey:-} + OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku} SESSION_SECRET: ${SESSION_SECRET:-} NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh} NTFY_TOPIC: ${NTFY_TOPIC:-} diff --git a/docs/plausible-events.md b/docs/plausible-events.md index bc00edc..edce8e6 100644 --- a/docs/plausible-events.md +++ b/docs/plausible-events.md @@ -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` | | 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) | +| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — | | 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` | | Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — | diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index e8fd81e..20af7ed 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -252,3 +252,14 @@ model GpsTrackPayload { @@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]) +} diff --git a/server/src/api.smoke.test.ts b/server/src/api.smoke.test.ts index 94d3a61..2a6f780 100644 --- a/server/src/api.smoke.test.ts +++ b/server/src/api.smoke.test.ts @@ -45,4 +45,18 @@ describe('API smoke', () => { expect(res.status).toBe(400) 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) + }) }) diff --git a/server/src/app.ts b/server/src/app.ts index cb28084..44d9114 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js' import signRouter from './routes/sign.js' import pushRouter from './routes/push.js' import weatherRouter from './routes/weather.js' +import aiRouter from './routes/ai.js' import feedbackRouter from './routes/feedback.js' import { prisma } from './db.js' import { buildCorsOptions } from './cors.js' @@ -118,6 +119,7 @@ export function createApp(): express.Express { app.use('/api/sign', signRouter) app.use('/api/push', pushRouter) app.use('/api/weather', weatherRouter) + app.use('/api/ai', aiRouter) app.use('/api/feedback', feedbackRouter) app.get('/api/health', async (_req, res) => { diff --git a/server/src/routes/ai.ts b/server/src/routes/ai.ts new file mode 100644 index 0000000..8ee1f46 --- /dev/null +++ b/server/src/routes/ai.ts @@ -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 = { + 'anthropic/claude-haiku-latest': 'anthropic/claude-3.5-haiku', + 'claude-haiku-latest': 'anthropic/claude-3.5-haiku' +} + +const LANGUAGE_LABELS: Record = { + 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 { + 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