Kansho/frontend/src/pages/SettingsPage.jsx
2026-08-29 20:28:59 +02:00

821 lines
30 KiB
JavaScript

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 (
<div className="row-actions">
<button type="button" className="ghost" onClick={exportDoc}>JSON exportieren</button>
<label className="button-file" htmlFor={inputId}>JSON wiederherstellen</label>
<input id={inputId} type="file" accept="application/json,.json" hidden onChange={restoreFile} />
</div>
)
}
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 (
<li className="facet-row">
<div className="facet-head">
<strong>{item.label || item.facet_key}</strong>
<span className="muted">{item.layer || 'context'} · {item.origin}{item.locked ? ' · geschützt' : ''}</span>
</div>
{item.evidence && <p className="muted">{item.evidence}</p>}
<textarea rows={2} value={value} onChange={(e) => setValue(e.target.value)} />
<label className="inline">
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} />
Diese Facet vor automatischem Lernen schützen
</label>
<button type="button" disabled={!dirty} onClick={() => onSave(item.facet_key, { value, locked })}>
Facet speichern
</button>
</li>
)
}
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 (
<li className="facet-row">
<div className="facet-head">
<strong>{item.label || item.slug}</strong>
<span className="muted">{item.facet_key} · {item.slug} · {item.origin}{item.locked ? ' · geschützt' : ''}</span>
</div>
<textarea rows={2} value={statement} onChange={(e) => setStatement(e.target.value)} />
{exemplars.length > 0 && (
<ul className="muted">
{exemplars.map((ref, index) => (
<li key={ref.id || index}>Beispiel{ref.occurred_at ? ` · ${String(ref.occurred_at).slice(0, 10)}` : ''}: {ref.excerpt}</li>
))}
</ul>
)}
<label className="inline">
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} />
Diesen Trait vor automatischem Lernen schützen
</label>
<button type="button" disabled={!dirty} onClick={() => onSave(item.slug, { statement, locked, facet_key: item.facet_key })}>
Trait speichern
</button>
</li>
)
}
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 (
<section className="card">
<h1>Einstellungen</h1>
<dl className="meta">
<dt>Name</dt>
<dd>{session?.profile?.name}</dd>
<dt>E-Mail</dt>
<dd>{session?.profile?.email}</dd>
<dt>Rolle</dt>
<dd>{isAdmin ? 'Admin' : 'Nutzer'}</dd>
<dt>Rahmen-Tier</dt>
<dd>{sub?.tier?.name || '…'}</dd>
</dl>
{sub?.features && (
<ul className="muted">
{sub.features.map((item) => (
<li key={item.feature_id}>{item.feature_id}: {item.allowed ? 'verfügbar' : 'gesperrt'}{item.limit == null ? ' (ohne Limit)' : ` (${item.used}/${item.limit})`}</li>
))}
</ul>
)}
{error && <p className="error">{error}</p>}
<h2>Writing Profile</h2>
<p className="muted">
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.
</p>
<p className="muted">{LIFECYCLE_LABELS[profile?.lifecycle] || LIFECYCLE_LABELS.uninitialized}</p>
{profile?.confirmed && (
<>
<label className="stack">
Governance
<select
value={profile?.governance || 'learning'}
onChange={(e) => patchWriting({ governance: e.target.value })}
>
<option value="learning">Lernen</option>
<option value="advising">Vorschlagen</option>
<option value="frozen">Eingefroren</option>
</select>
</label>
<p className="muted">{GOVERNANCE_LABELS[profile?.governance] || ''}</p>
</>
)}
<DocumentTransfer
token={session.token}
exportPath="/api/journal/writing-profile/export"
restorePath="/api/journal/writing-profile/restore"
filename="kansho-writing-profile.json"
confirmText="Writing Profile aus der Datei wiederherstellen? Vorhandene Facets, Traits und Governance werden ersetzt. Journaltexte bleiben."
onRestored={setProfile}
onError={setError}
/>
<form className="stack" onSubmit={importOwn}>
<textarea
rows={6}
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder="Eigenen früheren Text einfügen. Für den Initial Build einen größeren Korpus nutzen, nicht nur zwei Einträge."
/>
<label className="stack">
Entstanden
<input type="date" value={importDate} onChange={(e) => setImportDate(e.target.value)} />
</label>
<label className="stack">
Quellenart
<select value={importHint} onChange={(e) => setImportHint(e.target.value)}>
{CONTEXT_HINTS.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
</label>
<button type="submit">Text in den Korpus legen</button>
</form>
<div className="stack">
<h3>Profilanalyse</h3>
<p className="muted">
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.
</p>
<p className="muted">
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)` : ''}
</p>
{review?.pending_evidence?.length > 0 && profile?.confirmed && (
<ul className="muted">
{review.pending_evidence.map((item) => (
<li key={item.id}>{item.trigger} · {item.source_kind || 'ohne Quelle'}</li>
))}
</ul>
)}
{review?.versions?.length > 0 && (
<ul className="muted">
{review.versions.slice(0, 5).map((item) => (
<li key={item.seq}>v{item.seq} · {item.cause || 'Änderung'}</li>
))}
</ul>
)}
<form className="stack" onSubmit={importReviewResult}>
<label className="stack">
Analyse-Ergebnis einfügen
<textarea
rows={10}
value={reviewResult}
onChange={(e) => setReviewResult(e.target.value)}
placeholder="JSON mit kind: kansho.profile_analysis_result hier einfügen …"
/>
</label>
<button type="submit" disabled={reviewBusy || !reviewResult.trim()}>Ergebnis prüfen</button>
</form>
{error && <p className="error">{error}</p>}
{importNote && <p className="muted">{importNote}</p>}
<p className="muted">
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.
</p>
<div className="row-actions">
<button type="button" className="ghost" disabled={reviewBusy} onClick={() => openAnalysis('initial_build')}>
Profil initial erstellen
</button>
<button type="button" className="ghost" disabled={reviewBusy} onClick={() => openAnalysis('review')}>
Profil überprüfen
</button>
<button type="button" disabled={reviewBusy} onClick={runApiReview}>
API-Analyse
</button>
</div>
{reviewBusy && (
<p className="muted">Analyse läuft … Detect und Generate können länger als eine Minute dauern.</p>
)}
{reviewPrompt && (
<>
<p className="muted">Diesen Prompt in einen bestehenden Chat mit deinen Texten einfügen. Das JSON-Ergebnis danach oben einfügen.</p>
<textarea
className="writing-review-prompt"
rows={8}
readOnly
value={reviewPrompt}
/>
<button type="button" className="ghost" onClick={copyReviewPrompt}>Prompt kopieren</button>
</>
)}
{proposal?.changes?.length > 0 && (
<div className="stack">
<h3>Vorgeschlagene Änderungen</h3>
<p className="muted">Noch nicht übernommen. Einzelne Vorschläge unten annehmen oder alles übernehmen.</p>
{review?.unknown_refs?.length > 0 && (
<p className="muted">Unbekannte Referenzen: {review.unknown_refs.join(', ')}</p>
)}
{proposal.uncertainties?.length > 0 && (
<p className="muted">Unsicherheiten: {proposal.uncertainties.length} Hinweise im Ergebnis.</p>
)}
<ul className="muted">
{proposal.changes.map((item, index) => (
<li key={`${item.slug || item.key}-${index}`}>
{item.action} · {item.slug || item.key}
{item.evidence_basis?.length ? ` · ${item.evidence_basis.join(', ')}` : ''}
{item.proposed_value ? ` — ${item.proposed_value}` : ''}
{' '}
<button type="button" className="ghost" disabled={reviewBusy} onClick={() => acceptOne(index)}>
Übernehmen
</button>
{' '}
<button type="button" className="ghost" disabled={reviewBusy} onClick={() => rejectOne(index)}>
Ablehnen
</button>
</li>
))}
</ul>
<div className="row-actions">
<button type="button" disabled={reviewBusy} onClick={() => acceptProposal(false)}>Alles übernehmen</button>
{(proposal.mode === 'initial_build' || review?.mode === 'initial_build') && (
<button type="button" disabled={reviewBusy} onClick={() => acceptProposal(true)}>
Als neue Profil-Baseline übernehmen
</button>
)}
<button type="button" className="ghost" disabled={reviewBusy} onClick={rejectProposal}>Alles ablehnen</button>
</div>
</div>
)}
</div>
{profile?.suggestions?.length > 0 && (
<div className="stack">
<h3>Vorschläge</h3>
<ul className="stack">
{profile.suggestions.map((item) => (
<li key={item.id}>
<p>{item.trait_slug || item.facet_key}: {item.proposed_value}</p>
{item.evidence && <p className="muted">{item.evidence}</p>}
<button type="button" onClick={() => resolveWriting(item.id, 'accept')}>Übernehmen</button>
{' '}
<button type="button" className="ghost" onClick={() => resolveWriting(item.id, 'reject')}>Verwerfen</button>
</li>
))}
</ul>
</div>
)}
{profile?.core && (
<div className="stack">
<h3>Core</h3>
<ul className="stack facet-list">
<FacetRow item={profile.core} onSave={saveFacet} />
</ul>
</div>
)}
{(profile?.facets || []).filter((item) => item.facet_key !== 'core').length > 0 && (
<div className="stack">
<h3>Facets</h3>
<ul className="stack facet-list">
{(profile.facets || []).filter((item) => item.facet_key !== 'core').map((item) => (
<FacetRow key={item.id || item.facet_key} item={item} onSave={saveFacet} />
))}
</ul>
</div>
)}
{(profile?.traits || []).length > 0 && (
<div className="stack">
<h3>Semantische Traits</h3>
<ul className="stack facet-list">
{profile.traits.map((item) => (
<TraitRow key={item.id || item.slug} item={item} onSave={saveTrait} />
))}
</ul>
</div>
)}
{!profile?.compiled_brief && (
<p className="muted">Noch kein Stilbrief. Lege eigene Texte in den Korpus oder übernimm einen Journal-Eintrag.</p>
)}
{profile?.sources?.length > 0 && (
<ul className="muted">
{profile.sources.map((item) => (
<li key={item.id}>
{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}` : ''}
</li>
))}
</ul>
)}
{profile?.compiled_brief && (
<div className="stack">
<h2>Aktueller Brief</h2>
<p className="muted">
Abgeleiteter Runtime-Brief, nicht Source of Truth und nicht das Import-/Exportformat.
Facets, Traits und Quellen bleiben maßgeblich.
</p>
<p className="writing-brief">{profile.compiled_brief}</p>
</div>
)}
<h2>Interaction Profile</h2>
<p className="muted">
{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.
</p>
<label className="stack">
Governance
<select
value={interaction?.governance || 'advising'}
onChange={(e) => patchInteraction({ governance: e.target.value })}
>
<option value="learning">Lernen</option>
<option value="advising">Vorschlagen</option>
<option value="frozen">Eingefroren</option>
</select>
</label>
<DocumentTransfer
token={session.token}
exportPath="/api/journal/interaction-profile/export"
restorePath="/api/journal/interaction-profile/restore"
filename="kansho-interaction-profile.json"
confirmText="Interaction Profile aus der Datei wiederherstellen? Vorhandene persönliche Präferenzen und Governance werden ersetzt."
onRestored={setInteraction}
onError={setError}
/>
{interaction?.defaults?.length > 0 && (
<div className="stack">
<h3>Produktdefaults</h3>
<ul className="stack">
{interaction.defaults.map((item) => (
<li key={`default-${item.scope}-${item.pref_key}`} className="facet-row">
<div className="facet-head">
<strong>{item.title}</strong>
<span className="muted">{item.scope}{item.overridden ? ' · persönlich überschrieben' : ''}</span>
</div>
<textarea
key={`${item.scope}-${item.pref_key}-${item.value}`}
rows={2}
defaultValue={item.value}
onChange={(e) => setPrefDrafts((prev) => ({
...prev,
[`default:${item.scope}:${item.pref_key}`]: e.target.value
}))}
/>
{!item.overridden && (
<button type="button" onClick={() => saveDefaultAsPref(item)}>Als persönliche Präferenz übernehmen</button>
)}
</li>
))}
</ul>
</div>
)}
{interaction?.preferences?.length > 0 && (
<div className="stack">
<h3>Persönliche Präferenzen</h3>
<ul className="stack">
{interaction.preferences.map((item) => (
<li key={item.id} className="facet-row">
<div className="facet-head">
<strong>{item.title || item.pref_key}</strong>
<span className="muted">{item.scope} · {item.origin}</span>
</div>
<textarea
key={`${item.id}-${item.value}`}
rows={2}
defaultValue={item.value}
onChange={(e) => setPrefDrafts((prev) => ({
...prev,
[`${item.scope}:${item.pref_key}`]: e.target.value
}))}
/>
<button type="button" onClick={() => savePref(item)}>Speichern</button>
</li>
))}
</ul>
</div>
)}
{interaction?.suggestions?.length > 0 && (
<div className="stack">
<h3>Vorschläge</h3>
<ul className="stack">
{interaction.suggestions.map((item) => (
<li key={item.id}>
<p>{item.scope}/{item.pref_key}: {item.proposed_value}</p>
{item.evidence && <p className="muted">{item.evidence}</p>}
<button type="button" onClick={() => resolveInteraction(item.id, 'accept')}>Übernehmen</button>
{' '}
<button type="button" className="ghost" onClick={() => resolveInteraction(item.id, 'reject')}>Verwerfen</button>
</li>
))}
</ul>
</div>
)}
<h2>Beide Profile</h2>
<p className="muted">
Ein JSON-Dokument mit Writing Profile und Interaction Profile. Kein Journal-Backup, keine Dialoge.
</p>
<DocumentTransfer
token={session.token}
exportPath="/api/journal/profiles/export"
restorePath="/api/journal/profiles/restore"
filename="kansho-profiles.json"
confirmText="Beide Profile aus der Datei wiederherstellen? Facets, Präferenzen und Governance werden ersetzt."
onRestored={(next) => {
if (next?.writing) setProfile(next.writing)
if (next?.interaction) setInteraction(next.interaction)
}}
onError={setError}
/>
</section>
)
}