diff --git a/client/src/components/EventRemarksCell.tsx b/client/src/components/EventRemarksCell.tsx index 7b58d28..9788179 100644 --- a/client/src/components/EventRemarksCell.tsx +++ b/client/src/components/EventRemarksCell.tsx @@ -8,6 +8,7 @@ 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 @@ -45,6 +46,13 @@ export default function EventRemarksCell({ e.preventDefault() e.stopPropagation() if (transcribing || !preloaded?.audio || !voiceId) return + if (!getAiAuthorized()) { + void showAlert( + t('profile.ai_unauthorized_alert_desc'), + t('profile.ai_unauthorized_alert_title') + ) + return + } setTranscribing(true) const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 15000) diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index 4a17d2a..e7bb539 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -22,6 +22,7 @@ import { Zap } from 'lucide-react' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' +import { getAiAuthorized } from '../services/userPreferences.js' import { appendQuickEvent as apiAppendQuickEvent, appendQuickEvents as apiAppendQuickEvents, @@ -834,28 +835,32 @@ export default function LiveLogView({ void (async () => { try { const audioDataUrl = await blobToAudioDataUrl(blob) - + const authorized = getAiAuthorized() let transcriptionText = '' let transcribed = true let transcriptionError = false - try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 4000) + if (authorized) { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 4000) - const res = await fetch('/api/ai/transcribe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ audioDataUrl }), - signal: controller.signal - }) - clearTimeout(timeoutId) - if (!res.ok) throw new Error(`Status ${res.status}`) - const data = await res.json() - transcriptionText = (data.text || '').trim() - } catch (err) { - console.warn('[LiveLogView] Automatic transcription failed or timed out:', err) - transcriptionError = true + const res = await fetch('/api/ai/transcribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ audioDataUrl }), + signal: controller.signal + }) + clearTimeout(timeoutId) + if (!res.ok) throw new Error(`Status ${res.status}`) + const data = await res.json() + transcriptionText = (data.text || '').trim() + } catch (err) { + console.warn('[LiveLogView] Automatic transcription failed or timed out:', err) + transcriptionError = true + transcribed = false + } + } else { transcribed = false } @@ -891,11 +896,16 @@ export default function LiveLogView({ mode: 'auto' }) void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn')) - } else { + } 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) diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 88324a4..9434a18 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -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 diff --git a/client/src/components/UserProfilePreferences.tsx b/client/src/components/UserProfilePreferences.tsx index 252a716..21f6aca 100644 --- a/client/src/components/UserProfilePreferences.tsx +++ b/client/src/components/UserProfilePreferences.tsx @@ -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) => { + const nextVal = e.target.checked + setAiAuthorizedState(nextVal) + setAiAuthorized(userId, nextVal) + } + return ( <>
@@ -152,6 +161,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
+
+
+ +

+ {t('profile.ai_title')} +

+
+

+ {t('profile.ai_desc')} +

+

+ {t('profile.ai_help')} +

+ + +
+ diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index 2959502..069b855 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -672,6 +672,12 @@ "integrations_title": "Integrationer", "owm_key": "OpenWeatherMap API-nøgle", "owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.", + "ai_title": "AI-funktioner og privatliv", + "ai_desc": "Autoriser integrationer af kunstig intelligens for dine logbøger.", + "ai_help": "Aktivering af AI-funktioner giver appen mulighed for at opsummere dine rejsedage og transkribere optagede stemmememoer. For at behandle disse anmodninger sendes rå stemmedata og rejselogfiler sikkert løbende til OpenRouter. Der gemmes ingen data permanent af AI-modellen.\n\nDisse cloud-ressourcer koster penge at køre. Hvis du kan lide at bruge dem, bedes du overveje at støtte projektet frivilligt med en donation via Ko-fi-linket i footeren for at holde dem gratis og bæredygtige for alle.", + "ai_enable_label": "Aktiver transkribering og resuméer af rejsedage", + "ai_unauthorized_alert_title": "AI-funktioner er ikke autoriseret", + "ai_unauthorized_alert_desc": "For at bruge transkribering eller rejsedagsresuméer skal du autorisere dataoverførslen til OpenRouter i din brugerprofil under 'AI-funktioner og privatliv'.", "prefs_save": "Gemme", "prefs_saving": "Vil blive reddet...", "prefs_saved": "Gemt", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 6308e12..df8c73a 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -672,6 +672,12 @@ "integrations_title": "Integrationen", "owm_key": "OpenWeatherMap API-Schlüssel", "owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.", + "ai_title": "KI-Funktionen & Datenschutz", + "ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.", + "ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.", + "ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren", + "ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert", + "ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.", "prefs_save": "Speichern", "prefs_saving": "Wird gespeichert…", "prefs_saved": "Gespeichert", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 1728dbe..b898fd6 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -672,6 +672,12 @@ "integrations_title": "Integrations", "owm_key": "OpenWeatherMap API key", "owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.", + "ai_title": "AI Features & Privacy", + "ai_desc": "Authorize artificial intelligence integrations for your logbooks.", + "ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.", + "ai_enable_label": "Enable transcription and travel day summaries", + "ai_unauthorized_alert_title": "AI Features Not Authorized", + "ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.", "prefs_save": "Save", "prefs_saving": "Saving…", "prefs_saved": "Saved", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index a3de24e..2c1bfa6 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -672,6 +672,12 @@ "integrations_title": "Integrasjoner", "owm_key": "OpenWeatherMap API-nøkkel", "owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.", + "ai_title": "KI-funksjoner og personvern", + "ai_desc": "Autoriser integrasjoner av kunstig intelligens for loggbøkene dine.", + "ai_help": "Aktivering av KI-funksjoner gjør det mulig for appen å oppsummere reisedagene dine og transkribere innspilte talememoer. For å behandle disse forespørslene sendes rå stemmedata og reiselogger sikkert løpende til OpenRouter. Ingen data lagres permanent av KI-modellen.\n\nDisse nettskyressursene koster penger å drifte. Hvis du har glede av å bruke dem, kan du vurdere å støtte prosjektet frivillig med en donasjon via Ko-fi-lenken i bunnteksten for å holde dem gratis og bærekraftige for alle.", + "ai_enable_label": "Aktiver transkribering og oppsummeringer av reisedager", + "ai_unauthorized_alert_title": "KI-funktionen er ikke autorisert", + "ai_unauthorized_alert_desc": "For å bruke transkribering eller reisedagsoppsummeringer, må du autorisere dataoverføringen til OpenRouter i brukerprofilen din under 'KI-funksjoner og personvern'.", "prefs_save": "Spar", "prefs_saving": "...vil bli reddet...", "prefs_saved": "Reddet", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 30b95ae..4afbaac 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -672,6 +672,12 @@ "integrations_title": "Integrationer", "owm_key": "OpenWeatherMap API-nyckel", "owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.", + "ai_title": "AI-funktioner och integritet", + "ai_desc": "Auktorisera integrationer av artificiell intelligens för dina loggböcker.", + "ai_help": "Genom at aktivera AI-funktioner kan appen sammanfatta dina rejsdagar och transkribera röstmemon. För att bearbeta dessa förfrågningar skickas röstdata och rejsloggar säkert och tillfälligt till OpenRouter. Inga data sparas permanent av AI-modellen.\n\nDessa molnresurser kostar pengar att driva. Om du gillar att använda dem, överväg att frivilligt stödja projektet med en donation via Ko-fi-länken i sidfoten för att hålla dem gratis och hållbara för alla.", + "ai_enable_label": "Aktivera transkribering och sammanfattningar av rejsdagar", + "ai_unauthorized_alert_title": "AI-funktioner är inte auktoriserade", + "ai_unauthorized_alert_desc": "För att använda transkribering eller rejsdagsöversikter måste du auktorisera dataöverföringen till OpenRouter i din användarprofil under 'AI-funktioner och integritet'.", "prefs_save": "Spara", "prefs_saving": "Kommer att sparas...", "prefs_saved": "Sparade", diff --git a/client/src/services/userPreferences.test.ts b/client/src/services/userPreferences.test.ts index a7d3949..39616a5 100644 --- a/client/src/services/userPreferences.test.ts +++ b/client/src/services/userPreferences.test.ts @@ -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) + }) }) diff --git a/client/src/services/userPreferences.ts b/client/src/services/userPreferences.ts index fb6d7f3..a04af3d 100644 --- a/client/src/services/userPreferences.ts +++ b/client/src/services/userPreferences.ts @@ -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)) +} +