Kansho/frontend/src/pages/SettingsPage.jsx

660 lines
24 KiB
React
Raw Normal View History

2026-08-25 13:57:23 +02:00
import { useEffect, useId, useState } from 'react'
import { useAuth } from '../context/AuthContext.jsx'
import { api } from '../api.js'
2026-08-25 13:57:23 +02:00
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)
2026-08-25 13:57:23 +02:00
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)
2026-08-25 13:57:23 +02:00
const load = () => {
api('/api/subscription', { token: session.token }).then(setSub).catch(() => setSub(null))
2026-08-25 13:57:23 +02:00
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 (
<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>
)}
2026-08-25 13:57:23 +02:00
{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>{profile?.confirmed ? 'Profile Review' : 'Initial Profile Build'}</h3>
<p className="muted">
{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.'}
</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>
)}
<div className="row-actions">
<button type="button" className="ghost" disabled={reviewBusy} onClick={copyReviewPrompt}>
{profile?.confirmed ? 'Review-Paket kopieren' : 'Initial-Paket kopieren'}
</button>
<button type="button" disabled={reviewBusy} onClick={runApiReview}>
{profile?.confirmed ? 'API-Review' : 'Initial Build (API)'}
</button>
</div>
{reviewPrompt && (
<textarea
className="writing-review-prompt"
rows={8}
readOnly
value={reviewPrompt}
/>
)}
<form className="stack" onSubmit={importReviewResult}>
<textarea
rows={6}
value={reviewResult}
onChange={(e) => setReviewResult(e.target.value)}
placeholder="Review-Ergebnis (JSON: kansho.profile_review_result) einfügen …"
/>
<button type="submit" disabled={reviewBusy || !reviewResult.trim()}>Ergebnis prüfen</button>
</form>
</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. Sobald Einträge oder Importe mit Zeitbezug vorliegen, entstehen Exzerpte. Traits folgen erst aus dem Initial Build.
</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">Abgeleitet aus bestätigten Traits und zeitgestempelten Quellen, nicht die einzige Wahrheitsquelle.</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>
)
}