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 [proposal, setProposal] = useState(null)
const [importNote, setImportNote] = useState('')
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 openAnalysis = async (mode) => {
setError('')
setReviewBusy(true)
try {
const path = mode === 'initial_build'
? '/api/journal/writing-profile/initial-build/paste'
: '/api/journal/writing-profile/review/paste'
const opened = await api(path, {
token: session.token,
method: 'POST'
})
setReview(opened)
setReviewPrompt(opened.prompt || '')
setProposal(null)
setImportNote('')
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 copyReviewPrompt = async () => {
if (!reviewPrompt) {
openAnalysis(profile?.confirmed ? 'review' : 'initial_build')
return
}
try {
await navigator.clipboard.writeText(reviewPrompt)
} catch {
setError('Prompt steht im Feld. Bitte manuell kopieren.')
}
}
const acceptOne = async (index) => {
setError('')
setReviewBusy(true)
try {
const next = await api('/api/journal/writing-profile/review/accept', {
token: session.token,
method: 'POST',
body: { review_id: review?.review_id || review?.id || review?.open_review?.id, indexes: [index] }
})
if (next.profile) setProfile(next.profile)
setReview(next)
setProposal(next.proposal || null)
} catch (err) {
setError(err.message)
} finally {
setReviewBusy(false)
}
}
const rejectOne = async (index) => {
setError('')
setReviewBusy(true)
try {
const next = await api('/api/journal/writing-profile/review/reject', {
token: session.token,
method: 'POST',
body: { review_id: review?.review_id || review?.id || review?.open_review?.id, indexes: [index] }
})
if (next.profile) setProfile(next.profile)
setReview(next)
setProposal(next.proposal || null)
} 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)
setProposal(next.proposal || next.result || null)
setReviewPrompt('')
} catch (err) {
setError(err.message)
} finally {
setReviewBusy(false)
}
}
const importReviewResult = async (event) => {
event.preventDefault()
setError('')
setImportNote('')
setReviewBusy(true)
try {
let payload = reviewResult
try {
payload = JSON.parse(reviewResult)
} catch {
payload = reviewResult
}
const next = await api('/api/journal/writing-profile/review/import', {
token: session.token,
method: 'POST',
body: { result: payload, review_id: review?.review_id || review?.id || review?.open_review?.id }
})
if (next.profile) setProfile(next.profile)
setReview(next)
setProposal(next.proposal || next.result || null)
setImportNote(next.note || `${next.suggested || 0} Vorschläge geprüft.`)
if ((next.proposal || next.result)?.changes?.length) {
setReviewResult('')
}
} catch (err) {
setError(err.message)
} finally {
setReviewBusy(false)
}
}
const acceptProposal = async (baseline = false) => {
setError('')
setReviewBusy(true)
try {
const next = await api('/api/journal/writing-profile/review/accept', {
token: session.token,
method: 'POST',
body: { review_id: review?.review_id || review?.id || review?.open_review?.id, baseline }
})
if (next.profile) setProfile(next.profile)
setReview(next)
setProposal(null)
} catch (err) {
setError(err.message)
} finally {
setReviewBusy(false)
}
}
const rejectProposal = async () => {
setError('')
setReviewBusy(true)
try {
const next = await api('/api/journal/writing-profile/review/reject', {
token: session.token,
method: 'POST',
body: { review_id: review?.review_id || review?.id || review?.open_review?.id }
})
if (next.profile) setProfile(next.profile)
setReview(next)
setProposal(null)
} 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] || ''}
>
)}
Profilanalyse
Du hast schon ein JSON-Ergebnis aus ChatGPT? Dann die drei Knöpfe nicht verwenden.
JSON unten einfügen und auf „Ergebnis prüfen“ klicken. Erst danach erscheinen die Vorschläge.
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'}
))}
)}
Analyse-Ergebnis einfügen
setReviewResult(e.target.value)}
placeholder="JSON mit kind: kansho.profile_analysis_result hier einfügen …"
/>
Ergebnis prüfen
{error &&
{error}
}
{importNote &&
{importNote}
}
Nur wenn du noch keinen Prompt hast: „Profil initial erstellen“ für den ersten Aufbau,
„Profil überprüfen“ für spätere Runden, „API-Analyse“ für Kanshōs eigenes Modell.
openAnalysis('initial_build')}>
Profil initial erstellen
openAnalysis('review')}>
Profil überprüfen
API-Analyse
{reviewPrompt && (
<>
Diesen Prompt in einen bestehenden Chat mit deinen Texten einfügen. Das JSON-Ergebnis danach oben einfügen.
Prompt kopieren
>
)}
{proposal?.changes?.length > 0 && (
Vorgeschlagene Änderungen
Noch nicht übernommen. Einzelne Vorschläge unten annehmen oder alles übernehmen.
{review?.unknown_refs?.length > 0 && (
Unbekannte Referenzen: {review.unknown_refs.join(', ')}
)}
{proposal.uncertainties?.length > 0 && (
Unsicherheiten: {proposal.uncertainties.length} Hinweise im Ergebnis.
)}
{proposal.changes.map((item, index) => (
{item.action} · {item.slug || item.key}
{item.evidence_basis?.length ? ` · ${item.evidence_basis.join(', ')}` : ''}
{item.proposed_value ? ` — ${item.proposed_value}` : ''}
{' '}
acceptOne(index)}>
Übernehmen
{' '}
rejectOne(index)}>
Ablehnen
))}
acceptProposal(false)}>Alles übernehmen
{(proposal.mode === 'initial_build' || review?.mode === 'initial_build') && (
acceptProposal(true)}>
Als neue Profil-Baseline übernehmen
)}
Alles ablehnen
)}
{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
Abgeleiteter Runtime-Brief, nicht Source of Truth und kein Import-/Exportformat. Nach einer übernommenen Profiländerung wird er neu kompiliert.
{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}
/>
)
}