import { useEffect, useId, useState } from 'react'
import { useAuth } from '../context/AuthContext.jsx'
import { api } from '../api.js'
const KIND_LABELS = {
journal_entry: 'Finale Fassung (Stimme)',
imported_text: 'Import',
dialogue_style: 'Dialogstil (schwächer)'
}
const GOVERNANCE_LABELS = {
learning: 'Lernen — geeignete Quellen dürfen das Profil weiterentwickeln',
advising: 'Vorschlagen — Änderungen erst nach deiner Bestätigung',
frozen: 'Eingefroren — neue Beobachtungen ändern nichts, du kannst weiter manuell editieren'
}
function kindLabel(kind) {
return KIND_LABELS[kind] || kind
}
function downloadJson(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
function DocumentTransfer({ token, exportPath, restorePath, filename, confirmText, onRestored, onError }) {
const inputId = useId()
const exportDoc = async () => {
onError('')
try {
downloadJson(filename, await api(exportPath, { token }))
} catch (err) {
onError(err.message)
}
}
const restoreFile = async (event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
onError('')
if (!window.confirm(confirmText)) return
try {
const document = JSON.parse(await file.text())
onRestored(await api(restorePath, { token, method: 'POST', body: document }))
} catch (err) {
onError(err.message || 'JSON konnte nicht gelesen werden.')
}
}
return (
JSON exportieren
JSON wiederherstellen
)
}
const CONTEXT_HINTS = [
{ value: 'vacation_diary', label: 'Urlaubstagebuch → autobiografisches Journal' },
{ value: 'autobiographical_journal', label: 'Autobiografisches Journal' },
{ value: 'reflective', label: 'Reflektierender Text' }
]
const LIFECYCLE_LABELS = {
uninitialized: 'Noch kein bestätigtes Profil. Quellen sammeln, dann Initial Profile Build.',
initial_pending: 'Initiales Profilpaket ist offen. Nach der Prüfung wird das Profil bestätigt.',
confirmed: 'Profilhülle bestätigt. Neue Texte gehen in die Review-Queue, nicht in ein Blind-Reinfer.'
}
function FacetRow({ item, onSave }) {
const [value, setValue] = useState(item.value || '')
const [locked, setLocked] = useState(Boolean(item.locked))
useEffect(() => {
setValue(item.value || '')
setLocked(Boolean(item.locked))
}, [item.id, item.value, item.locked])
const dirty = value !== (item.value || '') || locked !== Boolean(item.locked)
return (
{item.label || item.facet_key}
{item.layer || 'context'} · {item.origin}{item.locked ? ' · geschützt' : ''}
{item.evidence && {item.evidence}
}
)
}
function TraitRow({ item, onSave }) {
const [statement, setStatement] = useState(item.statement || '')
const [locked, setLocked] = useState(Boolean(item.locked))
useEffect(() => {
setStatement(item.statement || '')
setLocked(Boolean(item.locked))
}, [item.id, item.statement, item.locked])
const dirty = statement !== (item.statement || '') || locked !== Boolean(item.locked)
const exemplars = item.exemplars || []
return (
{item.label || item.slug}
{item.facet_key} · {item.slug} · {item.origin}{item.locked ? ' · geschützt' : ''}
)
}
export default function SettingsPage() {
const { session, isAdmin } = useAuth()
const [sub, setSub] = useState(null)
const [profile, setProfile] = useState(null)
const [interaction, setInteraction] = useState(null)
const [importText, setImportText] = useState('')
const [importDate, setImportDate] = useState('')
const [importHint, setImportHint] = useState('vacation_diary')
const [prefDrafts, setPrefDrafts] = useState({})
const [error, setError] = useState('')
const [review, setReview] = useState(null)
const [reviewPrompt, setReviewPrompt] = useState('')
const [reviewResult, setReviewResult] = useState('')
const [reviewBusy, setReviewBusy] = useState(false)
const load = () => {
api('/api/subscription', { token: session.token }).then(setSub).catch(() => setSub(null))
api('/api/journal/writing-profile', { token: session.token }).then(setProfile).catch(() => setProfile(null))
api('/api/journal/writing-profile/review', { token: session.token }).then(setReview).catch(() => setReview(null))
api('/api/journal/interaction-profile', { token: session.token }).then(setInteraction).catch(() => setInteraction(null))
}
useEffect(() => { load() }, [session.token])
const patchWriting = async (body) => {
setError('')
try {
setProfile(await api('/api/journal/writing-profile', { token: session.token, method: 'PATCH', body }))
} catch (err) {
setError(err.message)
}
}
const saveFacet = async (facetKey, body) => {
setError('')
try {
setProfile(await api(`/api/journal/writing-profile/facets/${facetKey}`, {
token: session.token,
method: 'PATCH',
body
}))
} catch (err) {
setError(err.message)
}
}
const saveTrait = async (slug, body) => {
setError('')
try {
setProfile(await api(`/api/journal/writing-profile/traits/${slug}`, {
token: session.token,
method: 'PATCH',
body
}))
} catch (err) {
setError(err.message)
}
}
const resolveWriting = async (id, action) => {
setError('')
try {
setProfile(await api(`/api/journal/writing-profile/suggestions/${id}/${action}`, {
token: session.token,
method: 'POST'
}))
} catch (err) {
setError(err.message)
}
}
const importOwn = async (event) => {
event.preventDefault()
setError('')
try {
const next = await api('/api/journal/writing-profile/import', {
token: session.token,
method: 'POST',
body: {
body: importText,
occurred_at: importDate || undefined,
context_hint: importHint || undefined
}
})
setProfile(next)
setImportText('')
setReview(await api('/api/journal/writing-profile/review', { token: session.token }))
} catch (err) {
setError(err.message)
}
}
const copyReviewPrompt = async () => {
setError('')
setReviewBusy(true)
try {
const path = profile?.confirmed
? '/api/journal/writing-profile/review/paste'
: '/api/journal/writing-profile/initial-build/paste'
const opened = await api(path, {
token: session.token,
method: 'POST'
})
setReview(opened)
setReviewPrompt(opened.prompt || '')
setProfile(await api('/api/journal/writing-profile', { token: session.token }))
try {
await navigator.clipboard.writeText(opened.prompt || '')
} catch {
/* textarea remains for manual copy */
}
} catch (err) {
setError(err.message)
} finally {
setReviewBusy(false)
}
}
const runApiReview = async () => {
setError('')
setReviewBusy(true)
try {
const path = profile?.confirmed
? '/api/journal/writing-profile/review/api'
: '/api/journal/writing-profile/initial-build/api'
const next = await api(path, {
token: session.token,
method: 'POST'
})
if (next.profile) setProfile(next.profile)
setReview(next)
setReviewPrompt('')
} catch (err) {
setError(err.message)
} finally {
setReviewBusy(false)
}
}
const importReviewResult = async (event) => {
event.preventDefault()
setError('')
setReviewBusy(true)
try {
const next = await api('/api/journal/writing-profile/review/import', {
token: session.token,
method: 'POST',
body: { result: reviewResult, review_id: review?.open_review?.id }
})
if (next.profile) setProfile(next.profile)
setReview(next)
setReviewResult('')
} catch (err) {
setError(err.message)
} finally {
setReviewBusy(false)
}
}
const patchInteraction = async (body) => {
setError('')
try {
setInteraction(await api('/api/journal/interaction-profile', {
token: session.token,
method: 'PATCH',
body
}))
} catch (err) {
setError(err.message)
}
}
const savePref = async (item) => {
setError('')
const value = prefDrafts[`${item.scope}:${item.pref_key}`] ?? item.value
try {
const next = await api('/api/journal/interaction-profile/preferences', {
token: session.token,
method: 'PATCH',
body: { scope: item.scope, pref_key: item.pref_key, value, origin: 'manual' }
})
setInteraction(next)
} catch (err) {
setError(err.message)
}
}
const saveDefaultAsPref = async (item) => {
setError('')
const value = prefDrafts[`default:${item.scope}:${item.pref_key}`] ?? item.value
try {
const next = await api('/api/journal/interaction-profile/preferences', {
token: session.token,
method: 'PATCH',
body: { scope: item.scope, pref_key: item.pref_key, value, origin: 'manual' }
})
setInteraction(next)
} catch (err) {
setError(err.message)
}
}
const resolveInteraction = async (id, action) => {
setError('')
try {
setInteraction(await api(`/api/journal/interaction-profile/suggestions/${id}/${action}`, {
token: session.token,
method: 'POST'
}))
} catch (err) {
setError(err.message)
}
}
return (
Einstellungen
Name
{session?.profile?.name}
E-Mail
{session?.profile?.email}
Rolle
{isAdmin ? 'Admin' : 'Nutzer'}
Rahmen-Tier
{sub?.tier?.name || '…'}
{sub?.features && (
{sub.features.map((item) => (
{item.feature_id}: {item.allowed ? 'verfügbar' : 'gesperrt'}{item.limit == null ? ' (ohne Limit)' : ` (${item.used}/${item.limit})`}
))}
)}
{error && {error}
}
Writing Profile
Stabile Hülle: Core, optionale Facets, darin dynamische semantische Traits, Evidence und Exemplare.
Die Keys chronology, humor, detail, rhythm sind nur eine erste Ordnungshilfe, kein festes Raster.
Semantische Traits entstehen durch Review, nicht durch Wortzählungen. Kontinuierliches Lernen erst nach dem bestätigten Initial Profile Build.
{LIFECYCLE_LABELS[profile?.lifecycle] || LIFECYCLE_LABELS.uninitialized}
{profile?.confirmed && (
<>
Governance
patchWriting({ governance: e.target.value })}
>
Lernen
Vorschlagen
Eingefroren
{GOVERNANCE_LABELS[profile?.governance] || ''}
>
)}
{profile?.confirmed ? 'Profile Review' : 'Initial Profile Build'}
{profile?.confirmed
? 'Neue Texte werden lokal auf Drift geprüft. Eine AI-Review startet nur, wenn du sie auslöst — gebündelt, als API-Call oder als kopierbares Paket. Existing-before-New: bestehenden Trait bestätigen, präzisieren, Scope ändern, zusammenführen oder aufteilen, erst dann neu anlegen.'
: 'Sammle zuerst den historischen Korpus. Der Initial Build liest viele zeitgestempelte Quellen, nicht nur die letzten zwei Einträge. Urlaubstagebücher belegen primär das autobiografische Journal, nicht automatisch den globalen Core.'}
Version {review?.version ?? profile?.version ?? 0}
{review?.review_ready ? ' · Sammelprüfung bereit' : ''}
{(review?.pending_evidence?.length || profile?.pending_evidence) ? ` · ${review?.pending_evidence?.length ?? profile?.pending_evidence} Evidenz(en)` : ''}
{review?.pending_evidence?.length > 0 && profile?.confirmed && (
{review.pending_evidence.map((item) => (
{item.trigger} · {item.source_kind || 'ohne Quelle'}
))}
)}
{review?.versions?.length > 0 && (
{review.versions.slice(0, 5).map((item) => (
v{item.seq} · {item.cause || 'Änderung'}
))}
)}
{profile?.confirmed ? 'Review-Paket kopieren' : 'Initial-Paket kopieren'}
{profile?.confirmed ? 'API-Review' : 'Initial Build (API)'}
{reviewPrompt && (
)}
setReviewResult(e.target.value)}
placeholder="Review-Ergebnis (JSON: kansho.profile_review_result) einfügen …"
/>
Ergebnis prüfen
{profile?.suggestions?.length > 0 && (
)}
{profile?.core && (
)}
{(profile?.facets || []).filter((item) => item.facet_key !== 'core').length > 0 && (
Facets
{(profile.facets || []).filter((item) => item.facet_key !== 'core').map((item) => (
))}
)}
{(profile?.traits || []).length > 0 && (
Semantische Traits
{profile.traits.map((item) => (
))}
)}
{!profile?.compiled_brief && (
Noch kein Stilbrief. Sobald Einträge oder Importe mit Zeitbezug vorliegen, entstehen Exzerpte. Traits folgen erst aus dem Initial Build.
)}
{profile?.sources?.length > 0 && (
{profile.sources.map((item) => (
{kindLabel(item.kind)}
{item.occurred_at ? ` · ${String(item.occurred_at).slice(0, 10)}` : ''}
{item.context_hint ? ` · ${item.context_hint}` : ''}
{item.recency_role ? ` · ${item.recency_role}` : ''}
))}
)}
{profile?.compiled_brief && (
Aktueller Brief
Abgeleitet aus bestätigten Traits und zeitgestempelten Quellen, nicht die einzige Wahrheitsquelle.
{profile.compiled_brief}
)}
Interaction Profile
{interaction?.note || 'Stabile Dialogpräferenzen, getrennt vom Writing Profile. Der aktuelle Gesprächszustand bleibt automatisch und ist kein Profil.'}
{' '}Dauerhafte Präferenzen nur aus explizitem Feedback, manuellen Einstellungen oder angenommenen Vorschlägen. Beobachtetes bleibt Candidate.
Governance
patchInteraction({ governance: e.target.value })}
>
Lernen
Vorschlagen
Eingefroren
{interaction?.defaults?.length > 0 && (
)}
{interaction?.preferences?.length > 0 && (
)}
{interaction?.suggestions?.length > 0 && (
)}
Beide Profile
Ein JSON-Dokument mit Writing Profile und Interaction Profile. Kein Journal-Backup, keine Dialoge.
{
if (next?.writing) setProfile(next.writing)
if (next?.interaction) setInteraction(next.interaction)
}}
onError={setError}
/>
)
}