821 lines
30 KiB
JavaScript
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>
|
|
)
|
|
}
|